diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 62 | ||||
| -rw-r--r-- | src/components/LoadScreen.tsx | 164 | ||||
| -rw-r--r-- | src/components/SaveModal.tsx | 79 | ||||
| -rw-r--r-- | src/components/grid/Cell.tsx | 11 | ||||
| -rw-r--r-- | src/components/grid/GridComponent.tsx | 37 | ||||
| -rw-r--r-- | src/components/toolbar/ColorSwatch.tsx | 227 | ||||
| -rw-r--r-- | src/components/toolbar/Toolbar.tsx | 9 | ||||
| -rw-r--r-- | src/components/toolbar/ToolbarItem.tsx | 57 | ||||
| -rw-r--r-- | src/pages/Paint.tsx | 156 | ||||
| -rw-r--r-- | src/styles/styles.css | 107 | ||||
| -rw-r--r-- | src/utils/ansi.ts | 12 | ||||
| -rw-r--r-- | src/utils/grid.ts | 83 | ||||
| -rw-r--r-- | src/utils/storage.ts | 42 |
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)); +}; |
