summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-10-04 18:36:10 -0700
committerElizabeth Hunt <me@liz.coffee>2025-10-04 18:36:10 -0700
commit35add63ec4dce39710095f17abd86777de9e5b49 (patch)
tree1afdf952f310e09a663e85541474efdc95155a73
parent507c972ecafeceaf4f8962ad881f8fb50c9b86c1 (diff)
downloadansicolor-35add63ec4dce39710095f17abd86777de9e5b49.tar.gz
ansicolor-35add63ec4dce39710095f17abd86777de9e5b49.zip
Working history state
-rw-r--r--package-lock.json36
-rw-r--r--package.json3
-rw-r--r--public/img/cursor/hover.pngbin1789 -> 0 bytes
-rw-r--r--public/img/cursor/regular.pngbin3255 -> 0 bytes
-rw-r--r--src/App.tsx32
-rw-r--r--src/components/grid/Cell.tsx19
-rw-r--r--src/components/grid/GridComponent.tsx30
-rw-r--r--src/pages/Paint.tsx65
-rw-r--r--src/styles/styles.css52
-rw-r--r--src/types/grid.ts23
-rw-r--r--src/types/num.ts13
-rw-r--r--src/utils/ansi.ts68
-rw-r--r--src/utils/grid.ts50
-rw-r--r--vite.config.ts9
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
deleted file mode 100644
index 2e2c248..0000000
--- a/public/img/cursor/hover.png
+++ /dev/null
Binary files differ
diff --git a/public/img/cursor/regular.png b/public/img/cursor/regular.png
deleted file mode 100644
index f9fbfca..0000000
--- a/public/img/cursor/regular.png
+++ /dev/null
Binary files differ
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'],
+ },
+ },
});