diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 32 | ||||
| -rw-r--r-- | src/components/grid/Cell.tsx | 19 | ||||
| -rw-r--r-- | src/components/grid/GridComponent.tsx | 30 | ||||
| -rw-r--r-- | src/pages/Paint.tsx | 65 | ||||
| -rw-r--r-- | src/styles/styles.css | 52 | ||||
| -rw-r--r-- | src/types/grid.ts | 23 | ||||
| -rw-r--r-- | src/types/num.ts | 13 | ||||
| -rw-r--r-- | src/utils/ansi.ts | 68 | ||||
| -rw-r--r-- | src/utils/grid.ts | 50 |
9 files changed, 333 insertions, 19 deletions
diff --git a/src/App.tsx b/src/App.tsx index 3623384..c50d1fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,29 @@ -import { useState } from "react"; -import { ChooseArt } from "./pages/ChooseArt"; -import { Paint } from "./pages/Paint"; +import { useEffect, useState } from 'react'; + +import { ChooseArt } from '@/pages/ChooseArt'; +import { Paint } from '@/pages/Paint'; + +import { gridFromAscii } from '@/utils/grid'; + +const butterfly = `| | +| ⠀⠀⠀⠀⊹ | +| ⢶⢻⣑⣒⢤⡀⠀⢄⠀⠀⡠⠀⢀⡤⣆⣊⡿⡷ | +| ⠀⠹⠹⣚⣣⠻⣦⡀⠀⠀⢀⣴⠟⣸⢓⢎⠏⠀ | +| ⠀⠀⢡⣱⣖⣢⡾⢿⣾⣷⡿⢷⣖⣒⣎⡎⠀⠀ | +| ⠀⠀⠀⣠⠓⢬⠅⡺⢻⡟⢗⠨⡥⠚⣄⠀⠀⠀ | +| ⠀⠀⠀⣿⡆⠘⠆⢇⢸⡇⠸⠰⠃⢰⣿⠀⠀⠀ | +| ⠀⠀⠀⠐⡻⣮⣬⠞⠈⠁⠳⣤⣴⢿⠂⠀⠀⠀ | +| ⠀⠀⠀⡜⠀⠁⠉⠀⠀⠀⠀⠈⠈⠀⢣⠀⠀⠀ | +| ⊹ | +| |`; export const App: React.FC = () => { - const [chosenArt, setChosenArt] = useState<undefined | string>(undefined); + // const [chosenArt, setChosenArt] = useState<undefined | string>(undefined); + const [chosenArt, setChosenArt] = useState<undefined | string>(butterfly); - if (chosenArt === undefined) { - return <ChooseArt artSubmissionCallback={setChosenArt} /> + if (chosenArt !== undefined) { + return <Paint grid={gridFromAscii(chosenArt)} />; } - return <Paint art={chosenArt} /> -} + return <ChooseArt artSubmissionCallback={setChosenArt} />; +}; diff --git a/src/components/grid/Cell.tsx b/src/components/grid/Cell.tsx new file mode 100644 index 0000000..fe91da8 --- /dev/null +++ b/src/components/grid/Cell.tsx @@ -0,0 +1,19 @@ +import type { GridCell } from '@/types/grid'; +import { getStyleForAnsiColor } from '@/utils/ansi'; + +interface CellProps { + cell: GridCell; + onClick?: () => void; +} + +export const Cell: React.FC<CellProps> = ({ cell, onClick }) => { + return ( + <span + className={`grid-cell ${onClick ? 'highlightable' : ''}`} + onMouseDown={onClick} + style={getStyleForAnsiColor(cell.color)} + > + {cell.char === ' ' ? '\u00A0' : cell.char} + </span> + ); +}; diff --git a/src/components/grid/GridComponent.tsx b/src/components/grid/GridComponent.tsx new file mode 100644 index 0000000..75d65aa --- /dev/null +++ b/src/components/grid/GridComponent.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import type { Grid, GridCell } from '@/types/grid'; +import { Cell } from '@/components/grid/Cell'; + +interface GridProps { + grid: Grid; + onCellInteract?: (cell: GridCell) => void; +} + +export const GridComponent: React.FC<GridProps> = ({ + grid, + onCellInteract, +}) => { + return ( + <div className='grid'> + {grid.map((row, i) => ( + <div className='grid-row' key={i}> + {row.map((cell, j) => ( + <Cell + key={j} + cell={cell} + onClick={() => onCellInteract?.(cell)} + /> + ))} + </div> + ))} + </div> + ); +}; diff --git a/src/pages/Paint.tsx b/src/pages/Paint.tsx index 5284b26..136a347 100644 --- a/src/pages/Paint.tsx +++ b/src/pages/Paint.tsx @@ -1,11 +1,66 @@ -import { useState } from 'react'; +import { GridComponent } from '@/components/grid/GridComponent'; +import type { AnsiTermColor, Grid, GridCell } from '@/types/grid'; +import { + type IZipper, + ListZipper, +} from '@emprespresso/pengueno'; +import { useCallback, useState } from 'react'; export interface ChooseArtProps { - art: string; + grid: Grid; } -export const Paint: React.FC<ChooseArtProps> = ({ art: initArt }) => { - const [art, setArt] = useState<string>(initArt); +export const Paint: React.FC<ChooseArtProps> = ({ grid }) => { + const [selectedColor, setSelectedColor] = useState<AnsiTermColor>({ + foreground: { r: 5, g: 5, b: 5 }, + background: null, + }); + const [history, setHistory] = useState<IZipper<Grid>>( + ListZipper.from([grid]), + ); - return <div>{art}</div>; + const cellInteractionCallback = useCallback( + (cell: GridCell) => { + setHistory((currentHistory) => { + const currentGrid = currentHistory.read().get(); + const newGrid = currentGrid.map((row) => [...row]); // Deep copy for current state + newGrid[cell.y][cell.x] = { ...cell, color: selectedColor }; + return currentHistory.prepend(newGrid).previous().get(); + }); + }, + [selectedColor], + ); + + return ( + <div> + <GridComponent + grid={history.read().get()} + onCellInteract={cellInteractionCallback} + /> + <button + disabled={ + !history + .next() + .flatMap((it) => it.read()) + .present() + } + onClick={() => setHistory((history) => history.next().get())} + > + Undo + </button> + <button + disabled={ + !history + .previous() + .flatMap((it) => it.read()) + .present() + } + onClick={() => + setHistory((history) => history.previous().get()) + } + > + Redo + </button> + </div> + ); }; diff --git a/src/styles/styles.css b/src/styles/styles.css index 1bf4663..96b39ec 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -13,7 +13,6 @@ /* <global> */ * { - cursor: url('/img/cursor/regular.png'), auto !important; color: var(--foreground); font-family: var(--font), var(--font-fallback); @@ -99,9 +98,6 @@ textarea { background: none; resize: vertical; } -textarea:hover { - cursor: url('/img/cursor/hover.png'), auto !important; -} *:focus { border-color: var(--regular6); outline: none; @@ -133,8 +129,52 @@ button { padding: 8px; } button:hover { - cursor: url('/img/cursor/hover.png'), auto !important; border: 3px solid var(--regular6); - transition: border-color 0.2s; + transition: border-color 0.2s ease-in-out; +} +button:disabled, +button[disabled] { } /* </buttons> */ + +/* <grid> */ +.grid { + border: 3px solid var(--regular4); + padding: 1rem; + + font-size: 1.5rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + line-height: 1; + white-space: pre; + border-radius: 4px; + overflow-x: auto; + font-feature-settings: + 'tnum', + 'kern' 0; + font-variant-numeric: tabular-nums; +} + +.grid-row { + display: flex; +} + +.grid-cell { + display: inline-block; + min-width: 1ch; + width: 1ch; + max-width: 1ch; + text-align: center; + overflow: hidden; + vertical-align: top; + transition: background-color 0.1s ease-in-out; + box-sizing: border-box; + white-space: nowrap; +} + +.grid-cell:hover { + background-color: var(--background-body); +} +/* </grid> */ diff --git a/src/types/grid.ts b/src/types/grid.ts new file mode 100644 index 0000000..1bc6b18 --- /dev/null +++ b/src/types/grid.ts @@ -0,0 +1,23 @@ +import type { NumericRange } from '@/types/num'; + +export type AnsiColorVal = NumericRange<0, 5>; +export type AnsiRgb = { + r: AnsiColorVal; + g: AnsiColorVal; + b: AnsiColorVal; +}; +export type AnsiDefaultColor = null; +export type AnsiTermColor = { + foreground: AnsiRgb | AnsiDefaultColor; + background: AnsiRgb | AnsiDefaultColor; +}; + +export interface GridCell { + x: number; + y: number; + + char: string; + color: AnsiTermColor; +} + +export type Grid = GridCell[][]; diff --git a/src/types/num.ts b/src/types/num.ts new file mode 100644 index 0000000..e8e2083 --- /dev/null +++ b/src/types/num.ts @@ -0,0 +1,13 @@ +export type NumericRange< + START extends number, + END extends number, + ARR extends unknown[] = [], + ACC extends number = never, +> = ARR['length'] extends END + ? ACC | START | END + : NumericRange< + START, + END, + [...ARR, 1], + ARR[START] extends undefined ? ACC : ACC | ARR['length'] + >; diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts new file mode 100644 index 0000000..551a365 --- /dev/null +++ b/src/utils/ansi.ts @@ -0,0 +1,68 @@ +import type { AnsiTermColor, AnsiColorVal, AnsiRgb } from '@/types/grid'; +import type { NumericRange } from '@/types/num'; +import type { CSSProperties } from 'react'; + +const byteToAnsiValRatio = 255 / 5; + +export const ansiRgbToHex = ({ r, g, b }: AnsiRgb): string => { + // Scale ANSI colors (0-5) to RGB range (0-255) + const scaleToRgb = (val: number) => Math.round(val * byteToAnsiValRatio); + const rHex = scaleToRgb(r).toString(16).padStart(2, '0'); + const gHex = scaleToRgb(g).toString(16).padStart(2, '0'); + const bHex = scaleToRgb(b).toString(16).padStart(2, '0'); + return `#${rHex}${gHex}${bHex}`; +}; + +export const hexToAnsi = (_hex: string): AnsiRgb => { + const hex = _hex.split('#').at(-1)!; + const rgb = [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map( + (val) => parseInt(val, 16) || 0, + ); + + const [r, g, b] = rgb.map( + (value) => + Math.floor( + Math.min(value, 255) * (1 / byteToAnsiValRatio), + ) as AnsiColorVal, + ); + return { r, g, b }; +}; + +export const getAnsiColorEscape = (ansi: AnsiTermColor) => { + const [fg, bg] = [ansi.foreground, ansi.background].map((color, i) => { + if (color === null) return `\x1b[39;49m`; + const { r, g, b } = color; + const id = (16 + 36 * r + 6 * g + b) as NumericRange<16, 231>; + return `\x1b[${3 + i}8;5;${id}m`; + }); + return { fg, bg }; +}; + +export const getAnsiEscapeCodeFromDiff = ( + currentColor: AnsiTermColor, + newColor: AnsiTermColor, +): string => { + const { fg: currentFg, bg: currentBg } = getAnsiColorEscape(currentColor); + const { fg: newFg, bg: newBg } = getAnsiColorEscape(newColor); + + const fg = currentFg === newFg ? '' : newFg; + const bg = currentBg === newBg ? '' : newBg; + + return `${fg}${bg}`; +}; + +export const ansiColorsEqual = ( + color1: AnsiTermColor, + color2: AnsiTermColor, +): boolean => { + // eh. + return JSON.stringify(color1) === JSON.stringify(color2); +}; + +export const getStyleForAnsiColor = (color: AnsiTermColor): CSSProperties => { + const { background, foreground } = color; + return { + backgroundColor: background ? ansiRgbToHex(background) : undefined, + color: foreground ? ansiRgbToHex(foreground) : undefined, + }; +}; diff --git a/src/utils/grid.ts b/src/utils/grid.ts new file mode 100644 index 0000000..71370da --- /dev/null +++ b/src/utils/grid.ts @@ -0,0 +1,50 @@ +import type { AnsiTermColor, Grid } from '@/types/grid'; +import { getAnsiColorEscape, getAnsiEscapeCodeFromDiff } from './ansi'; + +const defaultColor: AnsiTermColor = { foreground: null, background: null }; +export const gridFromAscii = ( + ascii: string, + color: AnsiTermColor = defaultColor, +): Grid => { + const lineWidth = Math.max(...ascii.split('\n').map((line) => line.length)); + return ascii.split('\n').map((line, y) => + line + .split('') + .map((char, x) => ({ + char, + color, + x, + y, + })) + .concat( + Array(lineWidth - line.length) + .fill(0) + .map((_, x) => ({ + char: ' ', + color, + x: x + line.length, + y, + })), + ), + ); +}; + +export const gridToAnsi = (grid: Grid) => { + const reset: AnsiTermColor = { foreground: null, background: null }; + const { fg, bg } = getAnsiColorEscape(reset); + const resetCode = `${fg}${bg}`; + + const rows = []; + for (let y = 0; y < grid.length; y++) { + let row = ''; + for (let x = 0; x < grid[y].length; x++) { + const cell = grid[y][x]; + const previousColor = x > 0 ? grid[y][x - 1].color : reset; + row += + getAnsiEscapeCodeFromDiff(previousColor, cell.color) + + cell.char; + } + rows.push(row); + } + return resetCode + rows.join(resetCode + '\n'); +}; |
