summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-10-05 16:42:02 -0700
committerElizabeth Hunt <me@liz.coffee>2025-10-05 23:11:41 -0700
commitde43eb05d2e43ab31effce3dcca62ad91a556b26 (patch)
tree47a62b61bfc97dda639dea70627ecf3005ba7b02 /src
parent35add63ec4dce39710095f17abd86777de9e5b49 (diff)
downloadansicolor-main.tar.gz
ansicolor-main.zip
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx62
-rw-r--r--src/components/LoadScreen.tsx164
-rw-r--r--src/components/SaveModal.tsx79
-rw-r--r--src/components/grid/Cell.tsx11
-rw-r--r--src/components/grid/GridComponent.tsx37
-rw-r--r--src/components/toolbar/ColorSwatch.tsx227
-rw-r--r--src/components/toolbar/Toolbar.tsx9
-rw-r--r--src/components/toolbar/ToolbarItem.tsx57
-rw-r--r--src/pages/Paint.tsx156
-rw-r--r--src/styles/styles.css107
-rw-r--r--src/utils/ansi.ts12
-rw-r--r--src/utils/grid.ts83
-rw-r--r--src/utils/storage.ts42
13 files changed, 983 insertions, 63 deletions
diff --git a/src/App.tsx b/src/App.tsx
index c50d1fc..dd3d76d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,28 +2,54 @@ import { useEffect, useState } from 'react';
import { ChooseArt } from '@/pages/ChooseArt';
import { Paint } from '@/pages/Paint';
+import { LoadScreen } from '@/components/LoadScreen';
-import { gridFromAscii } from '@/utils/grid';
-
-const butterfly = `| |
-| ⠀⠀⠀⠀⊹ |
-| ⢶⢻⣑⣒⢤⡀⠀⢄⠀⠀⡠⠀⢀⡤⣆⣊⡿⡷ |
-| ⠀⠹⠹⣚⣣⠻⣦⡀⠀⠀⢀⣴⠟⣸⢓⢎⠏⠀ |
-| ⠀⠀⢡⣱⣖⣢⡾⢿⣾⣷⡿⢷⣖⣒⣎⡎⠀⠀ |
-| ⠀⠀⠀⣠⠓⢬⠅⡺⢻⡟⢗⠨⡥⠚⣄⠀⠀⠀ |
-| ⠀⠀⠀⣿⡆⠘⠆⢇⢸⡇⠸⠰⠃⢰⣿⠀⠀⠀ |
-| ⠀⠀⠀⠐⡻⣮⣬⠞⠈⠁⠳⣤⣴⢿⠂⠀⠀⠀ |
-| ⠀⠀⠀⡜⠀⠁⠉⠀⠀⠀⠀⠈⠈⠀⢣⠀⠀⠀ |
-| ⊹ |
-| |`;
+import { gridFromAscii, gridFromAnsi } from '@/utils/grid';
+import type { Grid } from '@/types/grid';
export const App: React.FC = () => {
- // const [chosenArt, setChosenArt] = useState<undefined | string>(undefined);
- const [chosenArt, setChosenArt] = useState<undefined | string>(butterfly);
+ const [route, setRoute] = useState(window.location.hash || '#home');
+ const [chosenArt, setChosenArt] = useState<null | string>(null);
+ const [loadedGrid, setLoadedGrid] = useState<Grid | null>(null);
+
+ useEffect(() => {
+ const handleHashChange = () => {
+ setRoute(window.location.hash || '#home');
+ };
+ window.addEventListener('hashchange', handleHashChange);
+ return () => window.removeEventListener('hashchange', handleHashChange);
+ }, []);
+
+ const handleLoad = (grid: Grid) => {
+ setLoadedGrid(grid);
+ window.location.hash = '#paint';
+ };
+
+ const handleNew = () => {
+ setChosenArt('');
+ window.location.hash = '#paint';
+ };
+
+ const handlePaste = (ansiText: string) => {
+ const importedGrid = gridFromAnsi(ansiText);
+ setLoadedGrid(importedGrid);
+ window.location.hash = '#paint';
+ };
+
+ const handleGoHome = () => {
+ setLoadedGrid(null);
+ setChosenArt(null);
+ window.location.hash = '#home';
+ };
- if (chosenArt !== undefined) {
- return <Paint grid={gridFromAscii(chosenArt)} />;
+ if (route === '#paint') {
+ if (loadedGrid !== null) {
+ return <Paint grid={loadedGrid} onGoHome={handleGoHome} />;
+ }
+ if (chosenArt !== null) {
+ return <Paint grid={gridFromAscii(chosenArt)} onGoHome={handleGoHome} />;
+ }
}
- return <ChooseArt artSubmissionCallback={setChosenArt} />;
+ return <LoadScreen onLoad={handleLoad} onNew={handleNew} onPaste={handlePaste} />;
};
diff --git a/src/components/LoadScreen.tsx b/src/components/LoadScreen.tsx
new file mode 100644
index 0000000..e51ff6a
--- /dev/null
+++ b/src/components/LoadScreen.tsx
@@ -0,0 +1,164 @@
+import React, { useState } from 'react';
+import type { Grid } from '@/types/grid';
+import { getSavedArt, deleteSavedArt, type SavedArt } from '@/utils/storage';
+import { GridComponent } from './grid/GridComponent';
+import { gridFromAscii } from '@/utils/grid';
+
+const demoArt = [
+ { name: '🎨 Butterfly', art: `| |
+| ⠀⠀⠀⠀⊹ |
+| ⢶⢻⣑⣒⢤⡀⠀⢄⠀⠀⡠⠀⢀⡤⣆⣊⡿⡷ |
+| ⠀⠹⠹⣚⣣⠻⣦⡀⠀⠀⢀⣴⠟⣸⢓⢎⠏⠀ |
+| ⠀⠀⢡⣱⣖⣢⡾⢿⣾⣷⡿⢷⣖⣒⣎⡎⠀⠀ |
+| ⠀⠀⠀⣠⠓⢬⠅⡺⢻⡟⢗⠨⡥⠚⣄⠀⠀⠀ |
+| ⠀⠀⠀⣿⡆⠘⠆⢇⢸⡇⠸⠰⠃⢰⣿⠀⠀⠀ |
+| ⠀⠀⠀⠐⡻⣮⣬⠞⠈⠁⠳⣤⣴⢿⠂⠀⠀⠀ |
+| ⠀⠀⠀⡜⠀⠁⠉⠀⠀⠀⠀⠈⠈⠀⢣⠀⠀⠀ |
+| ⊹ |
+| |` },
+ { name: '😊 Smiley', art: ` ████████
+ ██ ██
+ ██ ██ ██ ██
+██ ██
+██ ██ ██ ██
+██ ██
+ ██ ██████████ ██
+ ██ ██
+ ████████ ` },
+ { name: '🌟 Star', art: ` ★
+ ███
+ █████
+ ███████
+█████████
+ ███████
+ █████
+ ███
+ █ ` },
+];
+
+interface LoadScreenProps {
+ onLoad: (grid: Grid) => void;
+ onNew: () => void;
+ onPaste: (ansiText: string) => void;
+}
+
+export const LoadScreen: React.FC<LoadScreenProps> = ({ onLoad, onNew, onPaste }) => {
+ const [saves] = useState<SavedArt[]>(getSavedArt());
+ const [pasteText, setPasteText] = useState('');
+
+ const handleDemoLoad = (art: string) => {
+ const grid = gridFromAscii(art);
+ onLoad(grid);
+ };
+
+ const formatDate = (timestamp: number) => {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+ };
+
+ const handleDelete = (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ deleteSavedArt(id);
+ window.location.reload();
+ };
+
+ const handlePaste = () => {
+ if (pasteText.trim()) {
+ onPaste(pasteText.trim());
+ }
+ };
+
+ return (
+ <div style={{
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '1.5rem',
+ padding: '2rem',
+ width: '100%',
+ maxWidth: '900px',
+ margin: '0 auto',
+ }}>
+ <h2>ANSI Color Paint</h2>
+
+ <div>
+ <h3>Demo Templates</h3>
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
+ {demoArt.map((demo, idx) => (
+ <button
+ key={idx}
+ onClick={() => handleDemoLoad(demo.art)}
+ style={{ flex: '1 1 calc(33% - 0.5rem)', minWidth: '150px' }}
+ >
+ {demo.name}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div>
+ <h3>Recent Saves</h3>
+ {saves.length > 0 ? (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
+ {saves.map((save) => (
+ <div
+ key={save.id}
+ onClick={() => onLoad(save.grid)}
+ style={{
+ border: '2px solid var(--regular3)',
+ borderRadius: '4px',
+ padding: '1rem',
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ transition: 'border-color 0.2s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = 'var(--regular6)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = 'var(--regular3)';
+ }}
+ >
+ <div>
+ <div style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
+ {save.name}
+ </div>
+ <div style={{ fontSize: '0.9rem', opacity: 0.7 }}>
+ {formatDate(save.timestamp)}
+ </div>
+ </div>
+ <button
+ onClick={(e) => handleDelete(save.id, e)}
+ style={{
+ padding: '0.25rem 0.5rem',
+ fontSize: '0.9rem',
+ width: 'auto',
+ minWidth: 'fit-content',
+ }}
+ >
+ Delete
+ </button>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <p style={{ opacity: 0.7 }}>No saves yet</p>
+ )}
+ </div>
+
+ <div>
+ <h3>Import from ANSI Text</h3>
+ <textarea
+ value={pasteText}
+ onChange={(e) => setPasteText(e.target.value)}
+ placeholder="Paste ANSI art here..."
+ style={{ width: '100%', minHeight: '100px', marginBottom: '0.5rem' }}
+ />
+ <button onClick={handlePaste} disabled={!pasteText.trim()}>
+ Import
+ </button>
+ </div>
+ </div>
+ );
+};
diff --git a/src/components/SaveModal.tsx b/src/components/SaveModal.tsx
new file mode 100644
index 0000000..1ed8ada
--- /dev/null
+++ b/src/components/SaveModal.tsx
@@ -0,0 +1,79 @@
+import React, { useState } from 'react';
+
+interface SaveModalProps {
+ ansiOutput: string;
+ onSave: (name: string) => void;
+ onClose: () => void;
+}
+
+export const SaveModal: React.FC<SaveModalProps> = ({ ansiOutput, onSave, onClose }) => {
+ const [name, setName] = useState('');
+ const [copied, setCopied] = useState(false);
+
+ const handleSave = () => {
+ if (name.trim()) {
+ onSave(name.trim());
+ onClose();
+ }
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(ansiOutput).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ return (
+ <div style={{
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 2000,
+ }}>
+ <div style={{
+ backgroundColor: 'var(--background)',
+ border: '3px solid var(--regular6)',
+ borderRadius: '4px',
+ padding: '1.5rem',
+ minWidth: '300px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '1rem',
+ }}>
+ <h3>Save ANSI Art</h3>
+ <input
+ type="text"
+ placeholder="Enter a name..."
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
+ autoFocus
+ style={{
+ padding: '0.5rem',
+ border: '2px solid var(--regular3)',
+ borderRadius: '4px',
+ backgroundColor: 'var(--background)',
+ color: 'var(--foreground)',
+ fontSize: '1rem',
+ }}
+ />
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
+ <button onClick={handleCopy} style={{ flex: 1 }}>
+ {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ <button onClick={handleSave} disabled={!name.trim()} style={{ flex: 1 }}>
+ Save
+ </button>
+ </div>
+ <button onClick={onClose}>Cancel</button>
+ </div>
+ </div>
+ );
+};
diff --git a/src/components/grid/Cell.tsx b/src/components/grid/Cell.tsx
index fe91da8..1a21164 100644
--- a/src/components/grid/Cell.tsx
+++ b/src/components/grid/Cell.tsx
@@ -4,13 +4,20 @@ import { getStyleForAnsiColor } from '@/utils/ansi';
interface CellProps {
cell: GridCell;
onClick?: () => void;
+ onMouseEnter?: () => void;
}
-export const Cell: React.FC<CellProps> = ({ cell, onClick }) => {
+export const Cell: React.FC<CellProps> = ({ cell, onClick, onMouseEnter }) => {
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault(); // Prevent text selection
+ onClick?.();
+ };
+
return (
<span
className={`grid-cell ${onClick ? 'highlightable' : ''}`}
- onMouseDown={onClick}
+ onMouseDown={handleMouseDown}
+ onMouseEnter={onMouseEnter}
style={getStyleForAnsiColor(cell.color)}
>
{cell.char === ' ' ? '\u00A0' : cell.char}
diff --git a/src/components/grid/GridComponent.tsx b/src/components/grid/GridComponent.tsx
index 75d65aa..9ec528e 100644
--- a/src/components/grid/GridComponent.tsx
+++ b/src/components/grid/GridComponent.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import type { Grid, GridCell } from '@/types/grid';
import { Cell } from '@/components/grid/Cell';
@@ -6,21 +6,52 @@ import { Cell } from '@/components/grid/Cell';
interface GridProps {
grid: Grid;
onCellInteract?: (cell: GridCell) => void;
+ onDragStart?: () => void;
+ onDragEnd?: () => void;
}
export const GridComponent: React.FC<GridProps> = ({
grid,
onCellInteract,
+ onDragStart,
+ onDragEnd,
}) => {
+ const [isDragging, setIsDragging] = useState(false);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault(); // Prevent text selection
+ setIsDragging(true);
+ onDragStart?.();
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ onDragEnd?.();
+ };
+
+ const handleMouseLeave = () => {
+ if (isDragging) {
+ setIsDragging(false);
+ onDragEnd?.();
+ }
+ };
+
return (
- <div className='grid'>
+ <div
+ className='grid'
+ onMouseDown={handleMouseDown}
+ onMouseUp={handleMouseUp}
+ onMouseLeave={handleMouseLeave}
+ style={{ userSelect: 'none' }}
+ >
{grid.map((row, i) => (
<div className='grid-row' key={i}>
{row.map((cell, j) => (
<Cell
key={j}
cell={cell}
- onClick={() => onCellInteract?.(cell)}
+ onClick={onCellInteract ? () => onCellInteract(cell) : undefined}
+ onMouseEnter={isDragging && onCellInteract ? () => onCellInteract(cell) : undefined}
/>
))}
</div>
diff --git a/src/components/toolbar/ColorSwatch.tsx b/src/components/toolbar/ColorSwatch.tsx
new file mode 100644
index 0000000..6f33483
--- /dev/null
+++ b/src/components/toolbar/ColorSwatch.tsx
@@ -0,0 +1,227 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import type { AnsiTermColor, Grid } from '@/types/grid';
+import { hexToAnsi } from '@/utils/ansi';
+import { gridFromAscii } from '@/utils/grid';
+import { HexColorPicker } from 'react-colorful';
+import { GridComponent } from '../grid/GridComponent';
+
+interface AnsiColorSwatchProps {
+ onSelect: (color: AnsiTermColor) => void;
+ defaultColors: AnsiTermColor[];
+ onClose?: () => void;
+}
+
+const STORAGE_KEY = 'ansicolor-history';
+
+export const ColorSwatch: React.FC<AnsiColorSwatchProps> = ({
+ onSelect,
+ defaultColors,
+ onClose,
+}) => {
+ const [foregroundColor, setForegroundColor] = useState<string | null>(null);
+ const [backgroundColor, setBackgroundColor] = useState<string | null>(null);
+
+ // Initialize history from localStorage or defaults
+ const [history, setHistory] = useState<AnsiTermColor[]>(() => {
+ const savedHistory = localStorage.getItem(STORAGE_KEY);
+ if (savedHistory) {
+ try {
+ return JSON.parse(savedHistory);
+ } catch (e) {
+ console.error(
+ 'Failed to parse color history from localStorage',
+ e,
+ );
+ }
+ }
+ return defaultColors;
+ });
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
+ }, [history]);
+
+ const [selectedHistory, setSelectedHistory] = useState<number>(0);
+
+ const handleHistorySelection = (selected: number) => {
+ const color = history.at(selected);
+ if (color === undefined) return;
+ setSelectedHistory(selected);
+
+ // Update the hex color pickers
+ if (color.foreground) {
+ const hex = `#${[color.foreground.r, color.foreground.g, color.foreground.b]
+ .map(c => Math.round(c * 51).toString(16).padStart(2, '0'))
+ .join('')}`;
+ setForegroundColor(hex);
+ } else {
+ setForegroundColor(null);
+ }
+
+ if (color.background) {
+ const hex = `#${[color.background.r, color.background.g, color.background.b]
+ .map(c => Math.round(c * 51).toString(16).padStart(2, '0'))
+ .join('')}`;
+ setBackgroundColor(hex);
+ } else {
+ setBackgroundColor(null);
+ }
+
+ onSelect(color);
+ };
+
+ const handleColorSelect = () => {
+ // Only add to history if it has at least one non-null color
+ const hasColor = ansiColor.foreground !== null || ansiColor.background !== null;
+
+ if (hasColor) {
+ // Add to history if not already present
+ const colorExists = history.some(
+ (c) =>
+ JSON.stringify(c.foreground) ===
+ JSON.stringify(ansiColor.foreground) &&
+ JSON.stringify(c.background) ===
+ JSON.stringify(ansiColor.background),
+ );
+ if (!colorExists) {
+ setHistory((prev) => [ansiColor, ...prev]);
+ }
+ }
+
+ onSelect(ansiColor);
+ };
+
+ const ansiColor = useMemo((): AnsiTermColor => {
+ const [fg, bg] = [foregroundColor, backgroundColor].map((h) =>
+ h ? hexToAnsi(h) : null,
+ );
+ return { foreground: fg, background: bg };
+ }, [foregroundColor, backgroundColor]);
+ const previewGrid = useMemo(
+ (): Grid => gridFromAscii(butterfly, ansiColor),
+ [ansiColor],
+ );
+
+ return (
+ <div>
+ <div style={{ marginBottom: '0.75rem' }}>
+ <div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap', maxWidth: '350px', justifyContent: 'center' }}>
+ {history.map((color, idx) => {
+ const hasForeground = color.foreground !== null;
+ const hasBackground = color.background !== null;
+
+ return (
+ <div
+ key={idx}
+ onClick={() => handleHistorySelection(idx)}
+ style={{
+ cursor: 'pointer',
+ border:
+ selectedHistory === idx
+ ? '2px solid var(--regular6)'
+ : '1px solid var(--regular3)',
+ borderRadius: '3px',
+ padding: '4px',
+ width: '28px',
+ height: '28px',
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '16px',
+ lineHeight: 1,
+ overflow: 'hidden',
+ }}
+ >
+ {hasForeground && hasBackground ? (
+ <>
+ <div style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '50%',
+ backgroundColor: `rgb(${color.foreground!.r * 51}, ${color.foreground!.g * 51}, ${color.foreground!.b * 51})`,
+ }} />
+ <div style={{
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ width: '100%',
+ height: '50%',
+ backgroundColor: `rgb(${color.background!.r * 51}, ${color.background!.g * 51}, ${color.background!.b * 51})`,
+ }} />
+ </>
+ ) : hasForeground ? (
+ <div style={{
+ width: '100%',
+ height: '100%',
+ backgroundColor: `rgb(${color.foreground!.r * 51}, ${color.foreground!.g * 51}, ${color.foreground!.b * 51})`,
+ }} />
+ ) : hasBackground ? (
+ <div style={{
+ width: '100%',
+ height: '100%',
+ backgroundColor: `rgb(${color.background!.r * 51}, ${color.background!.g * 51}, ${color.background!.b * 51})`,
+ }} />
+ ) : (
+ <div style={{
+ width: '100%',
+ height: '100%',
+ background: 'linear-gradient(135deg, transparent 45%, var(--regular3) 45%, var(--regular3) 55%, transparent 55%)',
+ }} />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'row',
+ marginBottom: '1rem',
+ }}
+ >
+ <div style={{ padding: '1rem' }}>
+ <HexColorPicker
+ color={foregroundColor ?? ''}
+ onChange={setForegroundColor}
+ />
+ <br />
+ <button onClick={() => setForegroundColor(null)}>
+ clear
+ </button>
+ </div>
+ <div style={{ padding: '1rem' }}>
+ <HexColorPicker
+ color={backgroundColor ?? ''}
+ onChange={setBackgroundColor}
+ />
+ <br />
+ <button onClick={() => setBackgroundColor(null)}>
+ clear
+ </button>
+ </div>
+ </div>
+ <button
+ style={{ marginBottom: '1rem' }}
+ onClick={handleColorSelect}
+ >
+ use
+ </button>
+ <GridComponent onCellInteract={undefined} grid={previewGrid} />
+ </div>
+ );
+};
+
+const butterfly = `| |
+| ⠀⠀⠀⠀⊹ |
+| ⢶⢻⣑⣒⢤⡀⠀⢄⠀⠀⡠⠀⢀⡤⣆⣊⡿⡷ |
+| ⠀⠹⠹⣚⣣⠻⣦⡀⠀⠀⢀⣴⠟⣸⢓⢎⠏⠀ |
+| ⠀⠀⢡⣱⣖⣢⡾⢿⣾⣷⡿⢷⣖⣒⣎⡎⠀⠀ |
+| ⠀⠀⠀⣠⠓⢬⠅⡺⢻⡟⢗⠨⡥⠚⣄⠀⠀⠀ |
+| ⠀⠀⠀⣿⡆⠘⠆⢇⢸⡇⠸⠰⠃⢰⣿⠀⠀⠀ |
+| ⠀⠀⠀⠐⡻⣮⣬⠞⠈⠁⠳⣤⣴⢿⠂⠀⠀⠀ |
+| ⠀⠀⠀⡜⠀⠁⠉⠀⠀⠀⠀⠈⠈⠀⢣⠀⠀⠀ |
+| ⊹ |
+| |`;
diff --git a/src/components/toolbar/Toolbar.tsx b/src/components/toolbar/Toolbar.tsx
new file mode 100644
index 0000000..b2217e0
--- /dev/null
+++ b/src/components/toolbar/Toolbar.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+export interface ToolbarProps {
+ children?: React.ReactNode;
+}
+
+export const Toolbar: React.FC<ToolbarProps> = ({ children }) => {
+ return <div className="toolbar">{children}</div>;
+};
diff --git a/src/components/toolbar/ToolbarItem.tsx b/src/components/toolbar/ToolbarItem.tsx
new file mode 100644
index 0000000..76bd615
--- /dev/null
+++ b/src/components/toolbar/ToolbarItem.tsx
@@ -0,0 +1,57 @@
+import React, { useState } from 'react';
+
+export interface ToolbarItemProps {
+ children: React.ReactNode;
+ renderContent?: (onClose: () => void) => React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}
+
+export const ToolbarItem: React.FC<ToolbarItemProps> = ({
+ children,
+ renderContent,
+ onClick,
+ disabled = false,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isFading, setIsFading] = useState(false);
+
+ const handleClose = () => {
+ setIsFading(true);
+ setTimeout(() => {
+ setIsOpen(false);
+ setIsFading(false);
+ }, 200);
+ };
+
+ const handleToggle = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (disabled) return;
+
+ if (renderContent) {
+ if (isOpen) {
+ handleClose();
+ } else {
+ setIsOpen(true);
+ }
+ }
+
+ onClick?.();
+ };
+
+ return (
+ <div className={`toolbar-item ${disabled ? 'disabled' : ''}`} onClick={handleToggle}>
+ <div className='toolbar-item-content'>
+ {children}
+ </div>
+ {renderContent && isOpen && !disabled && (
+ <div
+ className={`toolbar-item-content-panel ${isFading ? 'fading' : ''}`}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {renderContent(handleClose)}
+ </div>
+ )}
+ </div>
+ );
+};
diff --git a/src/pages/Paint.tsx b/src/pages/Paint.tsx
index 136a347..995bcc3 100644
--- a/src/pages/Paint.tsx
+++ b/src/pages/Paint.tsx
@@ -1,16 +1,34 @@
import { GridComponent } from '@/components/grid/GridComponent';
+import { ColorSwatch } from '@/components/toolbar/ColorSwatch';
+import { Toolbar } from '@/components/toolbar/Toolbar';
+import { ToolbarItem } from '@/components/toolbar/ToolbarItem';
+import { SaveModal } from '@/components/SaveModal';
import type { AnsiTermColor, Grid, GridCell } from '@/types/grid';
-import {
- type IZipper,
- ListZipper,
-} from '@emprespresso/pengueno';
-import { useCallback, useState } from 'react';
+import { gridToAnsi } from '@/utils/grid';
+import { saveArt } from '@/utils/storage';
+import { type IZipper, ListZipper } from '@emprespresso/pengueno';
+import { useCallback, useEffect, useState } from 'react';
+import { flushSync } from 'react-dom';
export interface ChooseArtProps {
grid: Grid;
+ onGoHome?: () => void;
}
-export const Paint: React.FC<ChooseArtProps> = ({ grid }) => {
+// Gruvbox theme colors converted to ANSI RGB values
+const defaultColors: AnsiTermColor[] = [
+ { foreground: { r: 5, g: 5, b: 5 }, background: null }, // bright7 #ebdbb2
+ { foreground: { r: 5, g: 1, b: 1 }, background: null }, // regular1 #cc241d
+ { foreground: { r: 4, g: 4, b: 1 }, background: null }, // regular2 #98971a
+ { foreground: { r: 5, g: 4, b: 1 }, background: null }, // regular3 #d79921
+ { foreground: { r: 2, g: 3, b: 4 }, background: null }, // regular4 #458588
+ { foreground: { r: 4, g: 2, b: 3 }, background: null }, // regular5 #b16286
+ { foreground: { r: 3, g: 4, b: 3 }, background: null }, // regular6 #689d6a
+ { foreground: { r: 4, g: 4, b: 3 }, background: null }, // regular7 #a89984
+];
+
+export const Paint: React.FC<ChooseArtProps> = ({ grid, onGoHome }) => {
+ const [showSaveModal, setShowSaveModal] = useState(false);
const [selectedColor, setSelectedColor] = useState<AnsiTermColor>({
foreground: { r: 5, g: 5, b: 5 },
background: null,
@@ -18,49 +36,113 @@ export const Paint: React.FC<ChooseArtProps> = ({ grid }) => {
const [history, setHistory] = useState<IZipper<Grid>>(
ListZipper.from([grid]),
);
+ const [isDragging, setIsDragging] = useState(false);
+ const [workingGrid, setWorkingGrid] = useState<Grid>(grid);
+
+ const handleDragStart = useCallback(() => {
+ setIsDragging(true);
+ // Don't reset workingGrid here - let it accumulate changes
+ }, []);
+
+ const handleDragEnd = useCallback(() => {
+ if (isDragging) {
+ // Commit the working grid to history as a single atomic operation
+ setHistory((currentHistory) => {
+ return currentHistory.prepend(workingGrid).previous().get();
+ });
+ setIsDragging(false);
+ }
+ }, [isDragging, workingGrid]);
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();
+ flushSync(() => {
+ setWorkingGrid((currentGrid) => {
+ const newGrid = currentGrid.map((row) => [...row]);
+ newGrid[cell.y][cell.x] = { ...cell, color: selectedColor };
+ return newGrid;
+ });
});
},
[selectedColor],
);
+ const handleSave = (name: string) => {
+ const currentGrid = history.read().get();
+ saveArt(name, currentGrid);
+ };
+
+ const currentGrid = workingGrid;
+ const ansiOutput = gridToAnsi(currentGrid);
+
return (
- <div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
+ {showSaveModal && (
+ <SaveModal
+ ansiOutput={ansiOutput}
+ onSave={handleSave}
+ onClose={() => setShowSaveModal(false)}
+ />
+ )}
+ <Toolbar>
+ <ToolbarItem
+ renderContent={(onClose) => (
+ <ColorSwatch
+ onSelect={setSelectedColor}
+ onClose={onClose}
+ defaultColors={defaultColors}
+ ></ColorSwatch>
+ )}
+ >
+ 🎨
+ </ToolbarItem>
+ <ToolbarItem
+ disabled={
+ !history
+ .previous()
+ .flatMap((it) => it.read())
+ .present()
+ }
+ onClick={() => {
+ setHistory((history) => {
+ const newHistory = history.previous().get();
+ setWorkingGrid(newHistory.read().get());
+ return newHistory;
+ });
+ }}
+ >
+ ↷
+ </ToolbarItem>
+ <ToolbarItem
+ disabled={
+ !history
+ .next()
+ .flatMap((it) => it.read())
+ .present()
+ }
+ onClick={() => {
+ setHistory((history) => {
+ const newHistory = history.next().get();
+ setWorkingGrid(newHistory.read().get());
+ return newHistory;
+ });
+ }}
+ >
+ ↶
+ </ToolbarItem>
+ <ToolbarItem onClick={() => setShowSaveModal(true)}>
+ 💾
+ </ToolbarItem>
+ <ToolbarItem onClick={onGoHome}>
+ 🏠
+ </ToolbarItem>
+ </Toolbar>
<GridComponent
- grid={history.read().get()}
+ grid={currentGrid}
onCellInteract={cellInteractionCallback}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
/>
- <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 96b39ec..7a9e9ec 100644
--- a/src/styles/styles.css
+++ b/src/styles/styles.css
@@ -97,6 +97,14 @@ textarea {
border-radius: 4px;
background: none;
resize: vertical;
+ font-family: var(--font), var(--font-fallback);
+ font-size: 1.5rem;
+ line-height: 1;
+ white-space: pre;
+ font-feature-settings:
+ 'tnum',
+ 'kern' 0;
+ font-variant-numeric: tabular-nums;
}
*:focus {
border-color: var(--regular6);
@@ -141,7 +149,7 @@ button[disabled] {
.grid {
border: 3px solid var(--regular4);
padding: 1rem;
-
+ background-color: var(--background);
font-size: 1.5rem;
cursor: pointer;
display: flex;
@@ -174,7 +182,102 @@ button[disabled] {
white-space: nowrap;
}
-.grid-cell:hover {
+.highlightable:hover {
background-color: var(--background-body);
}
/* </grid> */
+
+/* <toolbar> */
+.toolbar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background-color: var(--background);
+ border: 3px solid var(--regular4);
+ border-radius: 4px;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto;
+ width: fit-content;
+}
+
+.toolbar-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem;
+ border: 2px solid var(--regular3);
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ transition: border-color 0.2s ease-in-out;
+ min-width: 2.5rem;
+ min-height: 2.5rem;
+}
+
+.toolbar-item:hover:not(.disabled) {
+ border-color: var(--regular6);
+}
+
+.toolbar-item.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ border-color: var(--bright0);
+}
+
+.toolbar-item-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.toolbar-item-content-panel {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 0.5rem 0.75rem;
+ background-color: var(--background);
+ border: 2px solid var(--regular6);
+ border-radius: 4px;
+ z-index: 1000;
+ animation: fadeIn 0.2s ease-in-out;
+}
+
+.toolbar-item-content-panel.fading {
+ animation: fadeOut 0.2s ease-in-out;
+}
+
+.toolbar-item-content-panel::after {
+ content: '';
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 0.375rem solid transparent;
+ border-bottom-color: var(--regular6);
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-0.25rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
+@keyframes fadeOut {
+ from {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-0.25rem);
+ }
+}
+/* </toolbar> */
diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts
index 551a365..e57c075 100644
--- a/src/utils/ansi.ts
+++ b/src/utils/ansi.ts
@@ -66,3 +66,15 @@ export const getStyleForAnsiColor = (color: AnsiTermColor): CSSProperties => {
color: foreground ? ansiRgbToHex(foreground) : undefined,
};
};
+
+export const parseAnsiColorCode = (code: number): AnsiRgb | null => {
+ // Only handle 216 color cube (16-231)
+ if (code < 16 || code > 231) return null;
+
+ const adjusted = code - 16;
+ const r = Math.floor(adjusted / 36) as AnsiColorVal;
+ const g = Math.floor((adjusted % 36) / 6) as AnsiColorVal;
+ const b = (adjusted % 6) as AnsiColorVal;
+
+ return { r, g, b };
+};
diff --git a/src/utils/grid.ts b/src/utils/grid.ts
index 71370da..1a2aa29 100644
--- a/src/utils/grid.ts
+++ b/src/utils/grid.ts
@@ -1,7 +1,88 @@
import type { AnsiTermColor, Grid } from '@/types/grid';
-import { getAnsiColorEscape, getAnsiEscapeCodeFromDiff } from './ansi';
+import { getAnsiColorEscape, getAnsiEscapeCodeFromDiff, parseAnsiColorCode } from './ansi';
const defaultColor: AnsiTermColor = { foreground: null, background: null };
+
+export const gridFromAnsi = (ansiText: string): Grid => {
+ const lines = ansiText.split('\n');
+ const grid: Grid = [];
+
+ for (let y = 0; y < lines.length; y++) {
+ const line = lines[y];
+ const row: Grid[0] = [];
+ let x = 0;
+ let currentColor: AnsiTermColor = { ...defaultColor };
+
+ // Regex to match ANSI escape codes
+ const ansiRegex = /\x1b\[([0-9;]+)m/g;
+ let lastIndex = 0;
+ let match;
+
+ while ((match = ansiRegex.exec(line)) !== null) {
+ // Add characters before this escape code
+ const text = line.slice(lastIndex, match.index);
+ for (const char of text) {
+ row.push({ char, color: { ...currentColor }, x: x++, y });
+ }
+
+ // Parse escape code
+ const codes = match[1].split(';').map(Number);
+ let i = 0;
+ while (i < codes.length) {
+ const code = codes[i];
+
+ if (code === 38 && codes[i + 1] === 5) {
+ // Foreground color: ESC[38;5;{code}m
+ const colorCode = codes[i + 2];
+ currentColor.foreground = parseAnsiColorCode(colorCode);
+ i += 3;
+ } else if (code === 48 && codes[i + 1] === 5) {
+ // Background color: ESC[48;5;{code}m
+ const colorCode = codes[i + 2];
+ currentColor.background = parseAnsiColorCode(colorCode);
+ i += 3;
+ } else if (code === 39) {
+ // Reset foreground
+ currentColor.foreground = null;
+ i++;
+ } else if (code === 49) {
+ // Reset background
+ currentColor.background = null;
+ i++;
+ } else {
+ i++;
+ }
+ }
+
+ lastIndex = ansiRegex.lastIndex;
+ }
+
+ // Add remaining characters
+ const remainingText = line.slice(lastIndex);
+ for (const char of remainingText) {
+ row.push({ char, color: { ...currentColor }, x: x++, y });
+ }
+
+ grid.push(row);
+ }
+
+ // Normalize grid width
+ const maxWidth = Math.max(...grid.map(row => row.length));
+ for (let y = 0; y < grid.length; y++) {
+ while (grid[y].length < maxWidth) {
+ const x = grid[y].length;
+ grid[y].push({
+ char: ' ',
+ color: { ...defaultColor },
+ x,
+ y,
+ });
+ }
+ }
+
+ return grid;
+};
+
export const gridFromAscii = (
ascii: string,
color: AnsiTermColor = defaultColor,
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
new file mode 100644
index 0000000..ee862ae
--- /dev/null
+++ b/src/utils/storage.ts
@@ -0,0 +1,42 @@
+import type { Grid } from '@/types/grid';
+
+export interface SavedArt {
+ id: string;
+ name: string;
+ grid: Grid;
+ timestamp: number;
+}
+
+const STORAGE_KEY = 'ansicolor-saved-art';
+const MAX_SAVES = 8;
+
+export const saveArt = (name: string, grid: Grid): void => {
+ const saves = getSavedArt();
+ const newSave: SavedArt = {
+ id: Date.now().toString(),
+ name,
+ grid,
+ timestamp: Date.now(),
+ };
+
+ const updatedSaves = [newSave, ...saves].slice(0, MAX_SAVES);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedSaves));
+};
+
+export const getSavedArt = (): SavedArt[] => {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (!saved) return [];
+
+ try {
+ return JSON.parse(saved);
+ } catch (e) {
+ console.error('Failed to parse saved art', e);
+ return [];
+ }
+};
+
+export const deleteSavedArt = (id: string): void => {
+ const saves = getSavedArt();
+ const filtered = saves.filter(save => save.id !== id);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
+};