diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-10-04 18:36:10 -0700 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-10-04 18:36:10 -0700 |
| commit | 35add63ec4dce39710095f17abd86777de9e5b49 (patch) | |
| tree | 1afdf952f310e09a663e85541474efdc95155a73 | |
| parent | 507c972ecafeceaf4f8962ad881f8fb50c9b86c1 (diff) | |
| download | ansicolor-35add63ec4dce39710095f17abd86777de9e5b49.tar.gz ansicolor-35add63ec4dce39710095f17abd86777de9e5b49.zip | |
Working history state
| -rw-r--r-- | package-lock.json | 36 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | public/img/cursor/hover.png | bin | 1789 -> 0 bytes | |||
| -rw-r--r-- | public/img/cursor/regular.png | bin | 3255 -> 0 bytes | |||
| -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 | ||||
| -rw-r--r-- | vite.config.ts | 9 |
14 files changed, 381 insertions, 19 deletions
diff --git a/package-lock.json b/package-lock.json index 58af3f9..bb8da91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "ansicolor", "version": "0.0.0", "dependencies": { + "@emprespresso/pengueno": "^0.0.13", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@hono/node-server": "^1.19.5", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -21,6 +23,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "hono": "^4.9.9", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" @@ -308,6 +311,16 @@ "node": ">=6.9.0" } }, + "node_modules/@emprespresso/pengueno": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@emprespresso/pengueno/-/pengueno-0.0.13.tgz", + "integrity": "sha512-eEMwbuF1hJsWNmVp8I5zCmWnIVlgTV+9w6wrkfcX6SMvEliotZ4ROZ/njndbFqzLRSID/+RptfYXLGYw2ZbswQ==", + "license": "MIT", + "engines": { + "node": ">=22.16.0", + "npm": ">=10.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -907,6 +920,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.5.tgz", + "integrity": "sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2453,6 +2479,16 @@ "node": ">=8" } }, + "node_modules/hono": { + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.9.tgz", + "integrity": "sha512-Hxw4wT6zjJGZJdkJzAx9PyBdf7ZpxaTSA0NfxqjLghwMrLBX8p33hJBzoETRakF3UJu6OdNQBZAlNSkGqKFukw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 1518044..367c06c 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@emprespresso/pengueno": "^0.0.13", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@hono/node-server": "^1.19.5", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -23,6 +25,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "hono": "^4.9.9", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/public/img/cursor/hover.png b/public/img/cursor/hover.png Binary files differdeleted file mode 100644 index 2e2c248..0000000 --- a/public/img/cursor/hover.png +++ /dev/null diff --git a/public/img/cursor/regular.png b/public/img/cursor/regular.png Binary files differdeleted file mode 100644 index f9fbfca..0000000 --- a/public/img/cursor/regular.png +++ /dev/null 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'); +}; diff --git a/vite.config.ts b/vite.config.ts index 8022455..e71fdf3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,4 +9,13 @@ export default defineConfig({ }, }, plugins: [react()], + define: { + 'process.env': {}, + 'global': 'globalThis', + }, + build: { + rollupOptions: { + external: ['hono', '@hono/node-server'], + }, + }, }); |
