summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-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
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>
+ );
+};