diff --git a/apps/web/src/components/editor/common/color-swatch.tsx b/apps/web/src/components/editor/common/color-swatch.tsx new file mode 100644 index 00000000..1042bb80 --- /dev/null +++ b/apps/web/src/components/editor/common/color-swatch.tsx @@ -0,0 +1,63 @@ +// apps/web/src/components/editor/common/color-swatch.tsx + +import { cn } from "@/lib/utils"; + +type SwatchSize = "sm" | "md" | "lg"; + +const SIZE_CLASSES: Record = { + sm: "w-5 h-5", + md: "w-7 h-7", + lg: "w-9 h-9", +}; + +// Checkerboard pattern for transparent colors +const CHECKERBOARD = + "repeating-conic-gradient(rgba(128,128,128,0.3) 0% 25%, transparent 0% 50%) 0 0 / 8px 8px"; + +interface ColorSwatchProps { + color: string; + size?: SwatchSize; + active?: boolean; + showBorder?: boolean; + onClick?: () => void; + className?: string; + label?: string; + "data-testid"?: string; +} + +export function ColorSwatch({ + color, + size = "md", + active, + showBorder = true, + onClick, + className, + label, + ...dataProps +}: ColorSwatchProps) { + const isTransparent = color === "transparent" || color.length === 9; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/options/eyedropper-options.tsx b/apps/web/src/components/editor/options/eyedropper-options.tsx new file mode 100644 index 00000000..6c3a275d --- /dev/null +++ b/apps/web/src/components/editor/options/eyedropper-options.tsx @@ -0,0 +1,110 @@ +// apps/web/src/components/editor/options/eyedropper-options.tsx + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { ColorSwatch } from "../common/color-swatch"; + +export type SampleSize = 1 | 3 | 5; + +const SAMPLE_SIZES: { label: string; value: SampleSize }[] = [ + { label: "Point (1x1)", value: 1 }, + { label: "3x3 Average", value: 3 }, + { label: "5x5 Average", value: 5 }, +]; + +interface EyedropperOptionsProps { + sampleSize: SampleSize; + onSampleSizeChange: (size: SampleSize) => void; + sampledColor: string | null; +} + +export function EyedropperOptions({ + sampleSize, + onSampleSizeChange, + sampledColor, +}: EyedropperOptionsProps) { + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const [open, setOpen] = useState(false); + + const displayColor = sampledColor ?? foregroundColor; + + return ( +
+ {/* Sample size dropdown */} +
+ Sample: + + {open && ( + <> + {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop overlay for closing dropdown */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: escape handled via parent */} +
setOpen(false)} /> +
+ {SAMPLE_SIZES.map((s) => ( + + ))} +
+ + )} +
+ +
+ + {/* Current sampled color preview */} +
+ + {displayColor} +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/color-panel.tsx b/apps/web/src/components/editor/panels/color-panel.tsx new file mode 100644 index 00000000..4a990c31 --- /dev/null +++ b/apps/web/src/components/editor/panels/color-panel.tsx @@ -0,0 +1,445 @@ +// apps/web/src/components/editor/panels/color-panel.tsx + +import { ArrowLeftRight, RotateCcw } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HexColorPicker } from "react-colorful"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import { ColorSwatch } from "../common/color-swatch"; + +type ColorTarget = "fg" | "bg"; +type InputMode = "hex" | "rgb" | "hsl"; + +// --- Color conversion helpers --- + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const clean = hex.replace("#", ""); + const n = Number.parseInt(clean, 16); + return { + r: (n >> 16) & 255, + g: (n >> 8) & 255, + b: n & 255, + }; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`; +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const diff = max - min; + const sum = max + min; + const l = sum / 2; + + if (diff === 0) return { h: 0, s: 0, l: Math.round(l * 100) }; + + const s = l > 0.5 ? diff / (2 - sum) : diff / sum; + let h = 0; + if (max === rn) h = ((gn - bn) / diff + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / diff + 2) / 6; + else h = ((rn - gn) / diff + 4) / 6; + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +function hslToHex(h: number, s: number, l: number): string { + const sn = s / 100; + const ln = l / 100; + const c = (1 - Math.abs(2 * ln - 1)) * sn; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = ln - c / 2; + let rn = 0; + let gn = 0; + let bn = 0; + + if (h < 60) { + rn = c; + gn = x; + } else if (h < 120) { + rn = x; + gn = c; + } else if (h < 180) { + gn = c; + bn = x; + } else if (h < 240) { + gn = x; + bn = c; + } else if (h < 300) { + rn = c; + bn = x; + } else { + rn = x; + bn = c; + } + + const r = Math.round((rn + m) * 255); + const g = Math.round((gn + m) * 255); + const b = Math.round((bn + m) * 255); + return rgbToHex(r, g, b); +} + +function isValidHex(hex: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(hex); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +// --- Sub-components --- + +function ColorInputFields({ + mode, + color, + onColorChange, +}: { + mode: InputMode; + color: string; + onColorChange: (hex: string) => void; +}) { + const rgb = hexToRgb(color); + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + + if (mode === "hex") { + return ( +
+ HEX + { + let v = e.target.value; + if (!v.startsWith("#")) v = `#${v}`; + if (isValidHex(v)) onColorChange(v.toLowerCase()); + }} + className={cn( + "flex-1 px-1.5 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + maxLength={7} + spellCheck={false} + aria-label="Hex color value" + data-testid="color-hex-input" + /> +
+ ); + } + + if (mode === "rgb") { + const handleRgb = (channel: "r" | "g" | "b", raw: string) => { + const v = clamp(Number.parseInt(raw, 10) || 0, 0, 255); + const next = { ...rgb, [channel]: v }; + onColorChange(rgbToHex(next.r, next.g, next.b)); + }; + + return ( +
+ {(["r", "g", "b"] as const).map((ch) => ( +
+ {ch} + handleRgb(ch, e.target.value)} + className={cn( + "w-full px-1 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + aria-label={`${ch.toUpperCase()} color channel`} + data-testid={`color-${ch}-input`} + /> +
+ ))} +
+ ); + } + + // HSL mode + const handleHsl = (channel: "h" | "s" | "l", raw: string) => { + const maxVal = channel === "h" ? 360 : 100; + const v = clamp(Number.parseInt(raw, 10) || 0, 0, maxVal); + const next = { ...hsl, [channel]: v }; + onColorChange(hslToHex(next.h, next.s, next.l)); + }; + + return ( +
+ {(["h", "s", "l"] as const).map((ch) => ( +
+ {ch} + handleHsl(ch, e.target.value)} + className={cn( + "w-full px-1 py-0.5 text-xs font-mono rounded", + "bg-muted border border-border text-foreground", + "focus:outline-none focus:ring-1 focus:ring-primary", + )} + aria-label={`${ch.toUpperCase()} color channel`} + data-testid={`color-${ch}-input`} + /> +
+ ))} +
+ ); +} + +function ColorPickerPopover({ + color, + onColorChange, + onClose, + recentColors, + onRecentColorClick, +}: { + color: string; + onColorChange: (color: string) => void; + onClose: () => void; + recentColors: string[]; + onRecentColorClick: (color: string) => void; +}) { + const [inputMode, setInputMode] = useState("hex"); + const popoverRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); + + return ( +
+ {/* react-colorful picker */} +
+ +
+ + {/* Input mode tabs */} +
+ {(["hex", "rgb", "hsl"] as const).map((m) => ( + + ))} +
+ + {/* Color input fields */} + + + {/* Recent colors */} + {recentColors.length > 0 && ( +
+

Recent

+
+ {recentColors.map((c) => ( + onRecentColorClick(c)} + data-testid={`recent-color-${c}`} + /> + ))} +
+
+ )} +
+ ); +} + +// --- Main component --- + +export function ColorPanel() { + const foregroundColor = useEditorStore((s) => s.foregroundColor); + const backgroundColor = useEditorStore((s) => s.backgroundColor); + const recentColors = useEditorStore((s) => s.recentColors); + const setForegroundColor = useEditorStore((s) => s.setForegroundColor); + const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); + const swapColors = useEditorStore((s) => s.swapColors); + const resetColors = useEditorStore((s) => s.resetColors); + + const [pickerTarget, setPickerTarget] = useState(null); + const [hexInput, setHexInput] = useState(foregroundColor); + + // Keep hex input in sync with store + useEffect(() => { + setHexInput(foregroundColor); + }, [foregroundColor]); + + const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor; + const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor; + + const handlePickerChange = useCallback( + (color: string) => { + setActiveColor(color); + }, + [setActiveColor], + ); + + const handleRecentColorClick = useCallback( + (color: string) => { + setActiveColor(color); + }, + [setActiveColor], + ); + + const handleHexInputChange = useCallback( + (e: React.ChangeEvent) => { + let v = e.target.value; + setHexInput(v); + if (!v.startsWith("#")) v = `#${v}`; + if (isValidHex(v)) { + setForegroundColor(v.toLowerCase()); + } + }, + [setForegroundColor], + ); + + const handleHexInputBlur = useCallback(() => { + // Reset to current foreground if invalid + setHexInput(foregroundColor); + }, [foregroundColor]); + + return ( +
+
+ {/* Foreground/Background swatches */} +
+ {/* Background swatch (bottom-right) */} + + + {/* Reset icon (bottom-left) */} + +
+ + {/* Hex input for foreground color */} +
+ Foreground + +
+
+ + {/* Color picker popover */} + {pickerTarget !== null && ( +
+ setPickerTarget(null)} + recentColors={recentColors} + onRecentColorClick={handleRecentColorClick} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/tools/eyedropper-tool.tsx b/apps/web/src/components/editor/tools/eyedropper-tool.tsx new file mode 100644 index 00000000..d537ad14 --- /dev/null +++ b/apps/web/src/components/editor/tools/eyedropper-tool.tsx @@ -0,0 +1,151 @@ +// apps/web/src/components/editor/tools/eyedropper-tool.tsx + +import type Konva from "konva"; +import { useCallback, useRef, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { SampleSize } from "../options/eyedropper-options"; + +/** + * Sample a single pixel or averaged region from a canvas context at (x, y). + * Returns hex color string. + */ +function samplePixelColor( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + sampleSize: SampleSize, +): string { + const half = Math.floor(sampleSize / 2); + const startX = x - half; + const startY = y - half; + + const imageData = ctx.getImageData(startX, startY, sampleSize, sampleSize); + const data = imageData.data; + const pixelCount = sampleSize * sampleSize; + + let rSum = 0; + let gSum = 0; + let bSum = 0; + + for (let i = 0; i < pixelCount; i++) { + rSum += data[i * 4]; + gSum += data[i * 4 + 1]; + bSum += data[i * 4 + 2]; + } + + const r = Math.round(rSum / pixelCount); + const g = Math.round(gSum / pixelCount); + const b = Math.round(bSum / pixelCount); + + return rgbToHex(r, g, b); +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`; +} + +interface UseEyedropperToolOptions { + stageRef: React.RefObject; + sampleSize: SampleSize; +} + +export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) { + const setForegroundColor = useEditorStore((s) => s.setForegroundColor); + const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); + const [sampledColor, setSampledColor] = useState(null); + const canvasCache = useRef(null); + + /** + * Export the visible stage to a flat canvas for pixel sampling. + * Cached so repeated clicks during one drag don't re-export. + */ + const getStageCanvas = useCallback((): HTMLCanvasElement | null => { + const stage = stageRef.current; + if (!stage) return null; + + // Use toCanvas to get a composited view of all visible layers + const canvas = stage.toCanvas({ + pixelRatio: 1, + }); + canvasCache.current = canvas; + return canvas; + }, [stageRef]); + + /** + * Invalidate the cache (call on mousedown so we get fresh data). + */ + const invalidateCache = useCallback(() => { + canvasCache.current = null; + }, []); + + /** + * Sample color at stage pointer position. + * Returns the sampled hex color, or null if sampling failed. + */ + const sampleAtPointer = useCallback( + (e: Konva.KonvaEventObject): string | null => { + const stage = e.target.getStage(); + if (!stage) return null; + + const pointer = stage.getPointerPosition(); + if (!pointer) return null; + + const canvas = canvasCache.current ?? getStageCanvas(); + if (!canvas) return null; + + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + // pointer coordinates are already in stage pixel space + const x = Math.round(pointer.x); + const y = Math.round(pointer.y); + + // Bounds check + if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) { + return null; + } + + const color = samplePixelColor(ctx, x, y, sampleSize); + setSampledColor(color); + return color; + }, + [getStageCanvas, sampleSize], + ); + + /** + * Handle click/mousedown on the canvas for eyedropper sampling. + * Alt+click sets background color; normal click sets foreground. + */ + const handleEyedropperClick = useCallback( + (e: Konva.KonvaEventObject) => { + invalidateCache(); + + const color = sampleAtPointer(e); + if (!color) return; + + if (e.evt.altKey) { + setBackgroundColor(color); + } else { + setForegroundColor(color); + } + }, + [invalidateCache, sampleAtPointer, setForegroundColor, setBackgroundColor], + ); + + /** + * Handle mousemove while eyedropper is active (for live preview). + */ + const handleEyedropperMove = useCallback( + (e: Konva.KonvaEventObject) => { + sampleAtPointer(e); + }, + [sampleAtPointer], + ); + + return { + handleEyedropperClick, + handleEyedropperMove, + sampledColor, + invalidateCache, + }; +}