diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-10-05 16:42:02 -0700 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-10-05 23:11:41 -0700 |
| commit | de43eb05d2e43ab31effce3dcca62ad91a556b26 (patch) | |
| tree | 47a62b61bfc97dda639dea70627ecf3005ba7b02 /src/components | |
| parent | 35add63ec4dce39710095f17abd86777de9e5b49 (diff) | |
| download | ansicolor-de43eb05d2e43ab31effce3dcca62ad91a556b26.tar.gz ansicolor-de43eb05d2e43ab31effce3dcca62ad91a556b26.zip | |
Diffstat (limited to 'src/components')
| -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 |
7 files changed, 579 insertions, 5 deletions
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> + ); +}; |
