Merge branch 'worktree-agent-af61ea3e' into feat/image-editor

This commit is contained in:
SnapOtter
2026-05-06 23:23:24 +08:00
4 changed files with 769 additions and 0 deletions
@@ -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<SwatchSize, string> = {
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 (
<button
type="button"
title={label ?? color}
aria-label={label ?? `Color ${color}`}
onClick={onClick}
className={cn(
"relative rounded transition-shadow shrink-0",
SIZE_CLASSES[size],
showBorder && "border border-border",
active && "ring-2 ring-primary ring-offset-1 ring-offset-card",
onClick && "cursor-pointer hover:ring-1 hover:ring-primary/50",
!onClick && "cursor-default",
className,
)}
style={{
background: isTransparent ? CHECKERBOARD : undefined,
}}
{...dataProps}
>
<span className="absolute inset-0 rounded" style={{ backgroundColor: color }} />
</button>
);
}
@@ -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 (
<div className="flex items-center gap-3">
{/* Sample size dropdown */}
<div className="relative">
<span className="text-xs text-muted-foreground mr-1.5">Sample:</span>
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 text-xs rounded",
"bg-muted border border-border text-foreground",
"hover:bg-muted/80 transition-colors",
)}
data-testid="sample-size-dropdown"
>
{SAMPLE_SIZES.find((s) => s.value === sampleSize)?.label}
<svg
width="10"
height="6"
viewBox="0 0 10 6"
className="text-muted-foreground"
aria-hidden="true"
role="img"
>
<title>Toggle dropdown</title>
<path
d="M1 1l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop overlay for closing dropdown */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: escape handled via parent */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div
className={cn(
"absolute top-full left-0 mt-1 z-50 py-1 rounded-md shadow-lg",
"bg-card border border-border min-w-[140px]",
)}
>
{SAMPLE_SIZES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
onSampleSizeChange(s.value);
setOpen(false);
}}
className={cn(
"w-full text-left px-3 py-1.5 text-xs transition-colors",
s.value === sampleSize
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
)}
>
{s.label}
</button>
))}
</div>
</>
)}
</div>
<div className="h-4 w-px bg-border" />
{/* Current sampled color preview */}
<div className="flex items-center gap-2">
<ColorSwatch color={displayColor} size="sm" data-testid="eyedropper-sampled-color" />
<span className="text-xs font-mono text-muted-foreground uppercase">{displayColor}</span>
</div>
</div>
);
}
@@ -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 (
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground w-6">HEX</span>
<input
type="text"
value={color.toUpperCase()}
onChange={(e) => {
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"
/>
</div>
);
}
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 (
<div className="flex items-center gap-1">
{(["r", "g", "b"] as const).map((ch) => (
<div key={ch} className="flex items-center gap-0.5 flex-1">
<span className="text-[10px] text-muted-foreground uppercase">{ch}</span>
<input
type="number"
min={0}
max={255}
value={rgb[ch]}
onChange={(e) => 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`}
/>
</div>
))}
</div>
);
}
// 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 (
<div className="flex items-center gap-1">
{(["h", "s", "l"] as const).map((ch) => (
<div key={ch} className="flex items-center gap-0.5 flex-1">
<span className="text-[10px] text-muted-foreground uppercase">{ch}</span>
<input
type="number"
min={0}
max={ch === "h" ? 360 : 100}
value={hsl[ch]}
onChange={(e) => 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`}
/>
</div>
))}
</div>
);
}
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<InputMode>("hex");
const popoverRef = useRef<HTMLDivElement>(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 (
<div
ref={popoverRef}
className={cn(
"absolute bottom-full left-0 mb-2 z-50 p-3 rounded-lg shadow-xl",
"bg-card border border-border w-[220px]",
)}
data-testid="color-picker-popover"
>
{/* react-colorful picker */}
<div className="[&_.react-colorful]:!w-full [&_.react-colorful]:!h-[150px] rounded overflow-hidden">
<HexColorPicker color={color} onChange={onColorChange} />
</div>
{/* Input mode tabs */}
<div className="flex items-center gap-0.5 mt-2.5 mb-1.5">
{(["hex", "rgb", "hsl"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setInputMode(m)}
className={cn(
"px-2 py-0.5 text-[10px] font-medium uppercase rounded transition-colors",
inputMode === m
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{m}
</button>
))}
</div>
{/* Color input fields */}
<ColorInputFields mode={inputMode} color={color} onColorChange={onColorChange} />
{/* Recent colors */}
{recentColors.length > 0 && (
<div className="mt-2.5 pt-2 border-t border-border">
<p className="text-[10px] text-muted-foreground mb-1.5">Recent</p>
<div className="flex flex-wrap gap-1">
{recentColors.map((c) => (
<ColorSwatch
key={c}
color={c}
size="sm"
onClick={() => onRecentColorClick(c)}
data-testid={`recent-color-${c}`}
/>
))}
</div>
</div>
)}
</div>
);
}
// --- 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<ColorTarget | null>(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<HTMLInputElement>) => {
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 (
<div className="p-3" data-testid="color-panel">
<div className="flex items-start gap-3">
{/* Foreground/Background swatches */}
<div className="relative w-[46px] h-[46px] shrink-0">
{/* Background swatch (bottom-right) */}
<button
type="button"
onClick={() => setPickerTarget(pickerTarget === "bg" ? null : "bg")}
className={cn(
"absolute right-0 bottom-0 w-7 h-7 rounded border-2 transition-shadow",
pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border",
)}
style={{ backgroundColor: backgroundColor }}
aria-label="Background color"
data-testid="bg-color-swatch"
/>
{/* Foreground swatch (top-left, overlapping) */}
<button
type="button"
onClick={() => setPickerTarget(pickerTarget === "fg" ? null : "fg")}
className={cn(
"absolute left-0 top-0 w-7 h-7 rounded border-2 z-10 transition-shadow",
pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border",
)}
style={{ backgroundColor: foregroundColor }}
aria-label="Foreground color"
data-testid="fg-color-swatch"
/>
{/* Swap icon (top-right) */}
<button
type="button"
onClick={swapColors}
className={cn(
"absolute -right-0.5 -top-0.5 z-20 p-0.5 rounded",
"text-muted-foreground hover:text-foreground transition-colors",
"bg-card/80 hover:bg-muted",
)}
title="Swap colors (X)"
aria-label="Swap foreground and background colors"
data-testid="swap-colors"
>
<ArrowLeftRight size={10} />
</button>
{/* Reset icon (bottom-left) */}
<button
type="button"
onClick={resetColors}
className={cn(
"absolute -left-0.5 -bottom-0.5 z-20 p-0.5 rounded",
"text-muted-foreground hover:text-foreground transition-colors",
"bg-card/80 hover:bg-muted",
)}
title="Reset colors (D)"
aria-label="Reset to default black and white"
data-testid="reset-colors"
>
<RotateCcw size={10} />
</button>
</div>
{/* Hex input for foreground color */}
<div className="flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground block mb-1">Foreground</span>
<input
type="text"
value={hexInput.toUpperCase()}
onChange={handleHexInputChange}
onBlur={handleHexInputBlur}
className={cn(
"w-full px-2 py-1 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="Foreground color hex value"
data-testid="foreground-hex-input"
/>
</div>
</div>
{/* Color picker popover */}
{pickerTarget !== null && (
<div className="relative mt-2">
<ColorPickerPopover
color={activeColor}
onColorChange={handlePickerChange}
onClose={() => setPickerTarget(null)}
recentColors={recentColors}
onRecentColorClick={handleRecentColorClick}
/>
</div>
)}
</div>
);
}
@@ -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<Konva.Stage | null>;
sampleSize: SampleSize;
}
export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) {
const setForegroundColor = useEditorStore((s) => s.setForegroundColor);
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
const [sampledColor, setSampledColor] = useState<string | null>(null);
const canvasCache = useRef<HTMLCanvasElement | null>(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<MouseEvent>): 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<MouseEvent>) => {
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<MouseEvent>) => {
sampleAtPointer(e);
},
[sampleAtPointer],
);
return {
handleEyedropperClick,
handleEyedropperMove,
sampledColor,
invalidateCache,
};
}