Merge branch 'testing/image-editor' into main

Merges all image editor work: RAW decode improvements, expanded format
export (AVIF/TIFF/GIF/JXL/PSD), Photoshop-style menu bar, custom Konva
filters, smart guides, clone stamp, dodge/burn, eyedropper, pixel brush,
selection and transform tool overlays, autosave blob URL persistence,
and 49+ bug fixes across editor canvas and E2E tests.

# Conflicts:
#	apps/api/src/routes/tool-factory.ts
#	apps/api/src/routes/tools/convert.ts
#	apps/web/src/components/editor/common/export-dialog.tsx
#	apps/web/src/components/editor/editor-canvas.tsx
This commit is contained in:
SnapOtter
2026-05-08 21:37:52 +08:00
36 changed files with 4648 additions and 289 deletions
+48 -4
View File
@@ -1,4 +1,9 @@
import { extname } from "node:path";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { promisify } from "node:util";
import { convert } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
@@ -8,6 +13,28 @@ import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
const execFileAsync = promisify(execFile);
let cachedMagickCmd: string | null = null;
async function findMagickCmd(): Promise<string> {
if (cachedMagickCmd) return cachedMagickCmd;
for (const cmd of ["magick", "convert"]) {
try {
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
cachedMagickCmd = cmd;
return cmd;
} catch {
// try next
}
}
throw new Error("No ImageMagick found. Install imagemagick (provides convert/magick).");
}
function magickArgs(cmd: string, args: string[]): string[] {
return cmd === "magick" ? ["convert", ...args] : args;
}
const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpg: "image/jpeg",
png: "image/png",
@@ -22,6 +49,7 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
ico: "image/x-icon",
jp2: "image/jp2",
qoi: "image/x-qoi",
psd: "image/vnd.adobe.photoshop",
};
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
@@ -46,6 +74,7 @@ const settingsSchema = z.object({
"ico",
"jp2",
"qoi",
"psd",
]),
quality: z.number().min(1).max(100).optional(),
});
@@ -73,12 +102,27 @@ export function registerConvert(app: FastifyInstance) {
const image = sharp(inputBuffer, sharpOpts);
let buffer: Buffer;
if (settings.format === "heic" || settings.format === "heif") {
// Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc.
if (settings.format === "psd") {
const pngBuffer = await image.png().toBuffer();
const id = randomUUID();
const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`);
try {
await writeFile(inputPath, pngBuffer);
const cmd = await findMagickCmd();
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), {
timeout: 120_000,
});
buffer = await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
} else if (settings.format === "heic" || settings.format === "heif") {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
} else {
const result = await convert(image, settings);
const result = await convert(image, settings as Parameters<typeof convert>[1]);
buffer = await result.toBuffer();
}
@@ -233,8 +233,8 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
ctx.drawImage(stageCanvas, 0, 0);
dataUrl = exportCanvas.toDataURL(
`image/${settings.format === "jpeg" ? "jpeg" : "png"}`,
settings.format === "jpeg" ? settings.quality / 100 : undefined,
getMimeType(settings.format),
settings.format === "png" ? undefined : settings.quality / 100,
);
} else {
dataUrl = stage.toDataURL({
@@ -248,23 +248,51 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
});
}
// Convert data URL to blob for download
fetch(dataUrl)
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `export.${settings.format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
markClean();
})
.catch((err) => {
console.error("Export failed:", err);
});
const formatOpt = FORMAT_OPTIONS.find((f) => f.value === settings.format);
if (formatOpt?.needsServerConvert) {
fetch(dataUrl)
.then((res) => res.blob())
.then(async (pngBlob) => {
const formData = new FormData();
formData.append("file", pngBlob, "export.png");
formData.append(
"settings",
JSON.stringify({ format: settings.format, quality: settings.quality }),
);
const res = await fetch("/api/v1/tools/convert", { method: "POST", body: formData });
if (!res.ok) throw new Error("Server conversion failed");
const json = await res.json();
if (json.downloadUrl) {
const a = document.createElement("a");
a.href = json.downloadUrl;
a.download = `export.${settings.format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
markClean();
}
})
.catch((err) => {
console.error("Export failed:", err);
});
} else {
fetch(dataUrl)
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `export.${settings.format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
markClean();
})
.catch((err) => {
console.error("Export failed:", err);
});
}
}, [settings, canvasSize, markClean]);
// Issue #6: Copy to clipboard using Konva stage
@@ -351,6 +379,10 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
sourceImageSize: data.sourceImageSize || null,
foregroundColor: data.foregroundColor || "#000000",
backgroundColor: data.backgroundColor || "#ffffff",
selection: null,
cropState: null,
selectedObjectIds: [],
clipboard: [],
isDirty: false,
lastAction: "Load Project",
_historyVersion: store._historyVersion + 1,
@@ -621,29 +653,68 @@ interface AutosaveData {
state: AutosaveState;
}
export function saveEditorState(): void {
/**
* Convert a blob: URL to a data: URL. Returns the original string
* if it is not a blob URL or if the fetch fails.
*/
async function blobUrlToDataUrl(url: string): Promise<string> {
if (!url.startsWith("blob:")) return url;
try {
const res = await fetch(url);
const blob = await res.blob();
return await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(url);
reader.readAsDataURL(blob);
});
} catch {
return url;
}
}
export async function saveEditorState(): Promise<void> {
try {
const s = useEditorStore.getState();
// Convert blob URLs to data URLs so they survive localStorage round-trip
const sourceImageUrl = s.sourceImageUrl ? await blobUrlToDataUrl(s.sourceImageUrl) : null;
// Also convert blob URLs inside image-type canvas objects
const objects = await Promise.all(
s.objects.map(async (obj) => {
if (obj.type === "image" && obj.attrs.src?.startsWith("blob:")) {
return { ...obj, attrs: { ...obj.attrs, src: await blobUrlToDataUrl(obj.attrs.src) } };
}
return obj;
}),
);
const data: AutosaveData = {
version: 1,
timestamp: Date.now(),
state: {
canvasSize: s.canvasSize,
layers: s.layers,
objects: s.objects,
objects,
adjustments: s.adjustments,
filters: s.filters,
guides: s.guides,
sourceImageUrl: s.sourceImageUrl,
sourceImageUrl,
sourceImageSize: s.sourceImageSize,
foregroundColor: s.foregroundColor,
backgroundColor: s.backgroundColor,
},
};
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
useEditorStore.setState({ lastAutoSave: Date.now() });
try {
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
useEditorStore.setState({ lastAutoSave: Date.now() });
} catch (storageErr) {
console.warn("[SnapOtter] Autosave failed (localStorage quota may be exceeded):", storageErr);
}
} catch {
// localStorage might be full or unavailable
// Serialization or fetch error
}
}
@@ -3,19 +3,6 @@ import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos";
// ---------------------------------------------------------------------------
// ImageResizeDialog -- modal with W/H, aspect lock, resampling method
// ---------------------------------------------------------------------------
const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [
{ value: "nearest", label: "Nearest Neighbor (fast)" },
{ value: "bilinear", label: "Bilinear" },
{ value: "bicubic", label: "Bicubic (smooth)" },
{ value: "lanczos", label: "Lanczos (sharp)" },
];
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage);
@@ -23,7 +10,6 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [lockAspect, setLockAspect] = useState(true);
const [resample, setResample] = useState<ResampleMethod>("bicubic");
const aspectRatio = canvasSize.width / canvasSize.height;
@@ -58,9 +44,9 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
);
const handleApply = useCallback(() => {
resizeImage(width, height, resample);
resizeImage(width, height);
onClose();
}, [width, height, resample, resizeImage, onClose]);
}, [width, height, resizeImage, onClose]);
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
const pctHeight =
@@ -147,27 +133,10 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
</div>
</div>
{/* Resampling method */}
<div>
<label htmlFor="resample" className="mb-1 block text-xs text-muted-foreground">
Resampling:
</label>
<select
id="resample"
value={resample}
onChange={(e) => setResample(e.target.value as ResampleMethod)}
className={cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
)}
>
{RESAMPLE_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* Info note */}
<p className="text-[10px] text-muted-foreground">
Scales all objects proportionally to the new dimensions.
</p>
</div>
{/* Footer */}
+320 -32
View File
@@ -21,21 +21,74 @@ import {
import useImage from "use-image";
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
import { useEditorStore } from "@/stores/editor-store";
import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor";
import type {
AdjustmentValues,
CanvasObject,
FilterConfig,
ImageAttrs,
ObjectEffects,
} from "@/types/editor";
import { ContextMenu, useContextMenu } from "./common/context-menu";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
import { LoadingOverlay } from "./common/loading-overlay";
import { SmartGuidesOverlay } from "./common/smart-guides";
import {
createExposureFilter,
createGrainFilter,
createMotionBlurFilter,
createRadialBlurFilter,
createSharpenFilter,
createSurfaceBlurFilter,
createVibranceFilter,
createVignetteFilter,
createWarmthFilter,
} from "./konva-filters";
import { useBrushTool } from "./tools/brush-tool";
import { useCloneStampTool } from "./tools/clone-stamp-tool";
import { CropOverlay } from "./tools/crop-tool";
import { useDodgeBurnTool } from "./tools/dodge-burn-tool";
import { useEraserTool } from "./tools/eraser-tool";
import { useEyedropperTool } from "./tools/eyedropper-tool";
import { useFillTool } from "./tools/fill-tool";
import { useGradientTool } from "./tools/gradient-tool";
import { MoveToolTransformer, useMoveTool } from "./tools/move-tool";
import { SelectionOverlay, useSelectionTool } from "./tools/selection-tool";
import { usePixelBrushTool } from "./tools/pixel-brush-tool";
import { ActiveSelectionPreview, SelectionOverlay, useSelectionTool } from "./tools/selection-tool";
import { useShapeTool } from "./tools/shape-tool";
import { useTextTool } from "./tools/text-tool";
import { TransformToolTransformer, useTransformTool } from "./tools/transform-tool";
// Konva's globalCompositeOperationType is not exported, so we define a compatible alias
type GlobalCompositeOperation =
| ""
| "source-over"
| "source-in"
| "source-out"
| "source-atop"
| "destination-over"
| "destination-in"
| "destination-out"
| "destination-atop"
| "lighter"
| "copy"
| "xor"
| "multiply"
| "screen"
| "overlay"
| "darken"
| "lighten"
| "color-dodge"
| "color-burn"
| "hard-light"
| "soft-light"
| "difference"
| "exclusion"
| "hue"
| "saturation"
| "color"
| "luminosity";
// Module-level stage ref for export dialog access (Issue #6)
export const editorStageRefHolder: { current: Konva.Stage | null } = {
current: null,
@@ -76,6 +129,7 @@ function SourceImage({
if (hasActiveAdjustments || hasActiveFilters) {
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
// Built-in Konva adjustment filters
if (adjustments.brightness !== 0) {
konvaFilters.push(KonvaFilters.Filters.Brighten);
node.brightness(adjustments.brightness / 100);
@@ -91,6 +145,17 @@ function SourceImage({
node.luminance(adjustments.luminance / 100);
}
// FIX 3: Custom adjustment filters for exposure, vibrance, warmth
if (adjustments.exposure !== 0) {
konvaFilters.push(createExposureFilter(adjustments.exposure / 100));
}
if (adjustments.vibrance !== 0) {
konvaFilters.push(createVibranceFilter(adjustments.vibrance));
}
if (adjustments.warmth !== 0) {
konvaFilters.push(createWarmthFilter(adjustments.warmth));
}
// Apply enabled filters
for (const f of filters) {
if (!f.enabled) continue;
@@ -138,12 +203,63 @@ function SourceImage({
node.kaleidoscopePower(f.params.power ?? 2);
node.kaleidoscopeAngle(f.params.angle ?? 0);
break;
// FIX 2: Custom filter types
case "motionBlur":
konvaFilters.push(
createMotionBlurFilter({
angle: f.params.angle ?? 0,
distance: f.params.distance ?? 10,
}),
);
break;
case "radialBlur":
konvaFilters.push(
createRadialBlurFilter({
amount: f.params.amount ?? 10,
centerX: f.params.centerX ?? 0.5,
centerY: f.params.centerY ?? 0.5,
}),
);
break;
case "surfaceBlur":
konvaFilters.push(
createSurfaceBlurFilter({
radius: f.params.radius ?? 5,
threshold: f.params.threshold ?? 25,
}),
);
break;
case "vignette":
konvaFilters.push(
createVignetteFilter({
amount: f.params.amount ?? 50,
midpoint: f.params.midpoint ?? 50,
}),
);
break;
case "grain":
konvaFilters.push(
createGrainFilter({
amount: f.params.amount ?? 25,
size: f.params.size ?? 25,
}),
);
break;
case "sharpen":
konvaFilters.push(
createSharpenFilter({
amount: f.params.amount ?? 0,
radius: f.params.radius ?? 1,
}),
);
break;
}
}
// FIX 1: Filters must be set BEFORE caching in Konva
node.clearCache();
node.cache();
node.filters(konvaFilters);
node.cache();
node.getLayer()?.batchDraw();
} else {
node.clearCache();
@@ -202,6 +318,59 @@ function ImageObject({
);
}
// ---------------------------------------------------------------------------
// Object Effects helpers (FIX 5)
// ---------------------------------------------------------------------------
/**
* Compute Konva-compatible props from an object's effects configuration.
* Returns props for drop shadow, outer glow, and stroke that can be spread
* onto any Konva shape node.
*/
function computeEffectProps(effects?: ObjectEffects): Record<string, unknown> {
if (!effects) return {};
const props: Record<string, unknown> = {};
// Drop shadow: uses Konva's built-in shadow support
if (effects.dropShadow?.enabled) {
const ds = effects.dropShadow;
const rad = (ds.angle * Math.PI) / 180;
props.shadowColor = ds.color;
props.shadowBlur = ds.blur;
props.shadowOffsetX = Math.cos(rad) * ds.distance;
props.shadowOffsetY = Math.sin(rad) * ds.distance;
props.shadowOpacity = ds.opacity;
props.shadowEnabled = true;
}
// Outer glow: like drop shadow but with zero offset
// Only apply if drop shadow is not already active (Konva has one shadow per node)
if (effects.outerGlow?.enabled && !effects.dropShadow?.enabled) {
const og = effects.outerGlow;
props.shadowColor = og.color;
props.shadowBlur = og.blur + og.spread;
props.shadowOffsetX = 0;
props.shadowOffsetY = 0;
props.shadowOpacity = og.opacity;
props.shadowEnabled = true;
}
// Stroke effect -- Konva draws strokes centered by default
if (effects.stroke?.enabled) {
const s = effects.stroke;
props.stroke = s.color;
props.strokeEnabled = true;
if (s.position === "inside" || s.position === "outside") {
props.strokeWidth = s.width * 2;
props.strokeScaleEnabled = false;
} else {
props.strokeWidth = s.width;
}
}
return props;
}
// ---------------------------------------------------------------------------
// Canvas Object Renderer (Issue #3: wire move tool handlers)
// ---------------------------------------------------------------------------
@@ -224,6 +393,8 @@ function CanvasObjectRenderer({
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
}) {
const draggable = isMoveTool;
// FIX 5: Compute effect props (drop shadow, outer glow, stroke) for all shapes
const fx = computeEffectProps(obj.effects);
switch (obj.type) {
case "line": {
@@ -241,10 +412,11 @@ function CanvasObjectRenderer({
globalCompositeOperation={
a.globalCompositeOperation as "source-over" | "destination-out" | undefined
}
shadowBlur={a.shadowBlur}
shadowColor={a.shadowColor}
shadowOffsetX={a.shadowOffsetX}
shadowOffsetY={a.shadowOffsetY}
{...fx}
shadowBlur={a.shadowBlur ?? (fx.shadowBlur as number | undefined)}
shadowColor={a.shadowColor ?? (fx.shadowColor as string | undefined)}
shadowOffsetX={a.shadowOffsetX ?? (fx.shadowOffsetX as number | undefined)}
shadowOffsetY={a.shadowOffsetY ?? (fx.shadowOffsetY as number | undefined)}
/>
);
}
@@ -269,6 +441,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -292,6 +465,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -321,6 +495,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -343,6 +518,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -366,6 +542,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -390,6 +567,7 @@ function CanvasObjectRenderer({
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
{...fx}
/>
);
}
@@ -418,9 +596,14 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
const activeTool = useEditorStore((s) => s.activeTool);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
const fillContiguous = useEditorStore((s) => s.fillContiguous);
const brushTool = useBrushTool();
const eraserTool = useEraserTool();
const cloneStampTool = useCloneStampTool(stageRef);
const dodgeBurnTool = useDodgeBurnTool(stageRef);
const pixelBrushTool = usePixelBrushTool(stageRef);
const shapeTool = useShapeTool();
const textTool = useTextTool();
const fillTool = useFillTool(stageRef);
@@ -428,6 +611,20 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
const moveTool = useMoveTool();
const selectionTool = useSelectionTool();
const transformTool = useTransformTool();
const eyedropperTool = useEyedropperTool({ stageRef, sampleSize: 1 });
useEffect(() => {
const typeMap: Record<string, "rect" | "ellipse" | "lasso"> = {
"marquee-rect": "rect",
"marquee-ellipse": "ellipse",
"lasso-free": "lasso",
"lasso-poly": "lasso",
};
const mapped = typeMap[activeTool];
if (mapped) {
selectionTool.setSelectionType(mapped);
}
}, [activeTool, selectionTool.setSelectionType]);
const selectionHandlers = useMemo(
() => ({
@@ -451,18 +648,66 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
[selectionTool, zoom, panOffset],
);
const magicWandHandlers = useMemo(
() => ({
handleMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
const pointer = stage?.getPointerPosition();
if (!pointer || !stage) return;
const pos = { x: (pointer.x - panOffset.x) / zoom, y: (pointer.y - panOffset.y) / zoom };
selectionTool.magicWandSelect(stage, pos.x, pos.y, magicWandTolerance, fillContiguous);
},
handleMouseMove: () => {},
handleMouseUp: () => {},
}),
[selectionTool, zoom, panOffset, magicWandTolerance, fillContiguous],
);
const eyedropperHandlers = useMemo(
() => ({
handleMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => {
eyedropperTool.handleEyedropperClick(e);
},
handleMouseMove: (e: Konva.KonvaEventObject<MouseEvent>) => {
eyedropperTool.handleEyedropperMove(e);
},
handleMouseUp: () => {},
}),
[eyedropperTool],
);
const zoomHandlers = useMemo(
() => ({
handleMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => {
const isAlt = e.evt.altKey;
const state = useEditorStore.getState();
const factor = isAlt ? 1 / 1.5 : 1.5;
state.setZoom(state.zoom * factor);
},
handleMouseMove: () => {},
handleMouseUp: () => {},
}),
[],
);
type ToolHandlers = {
handleMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => void;
handleMouseMove: (e: Konva.KonvaEventObject<MouseEvent>) => void;
handleMouseUp: (e: Konva.KonvaEventObject<MouseEvent>) => void;
};
const handlers = useMemo(() => {
const toolMap: Record<
string,
{
handleMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => void;
handleMouseMove: (e: Konva.KonvaEventObject<MouseEvent>) => void;
handleMouseUp: (e: Konva.KonvaEventObject<MouseEvent>) => void;
}
> = {
const toolMap: Record<string, ToolHandlers> = {
brush: brushTool,
pencil: brushTool,
eraser: eraserTool,
"clone-stamp": cloneStampTool,
dodge: dodgeBurnTool,
burn: dodgeBurnTool,
sponge: dodgeBurnTool,
"blur-brush": pixelBrushTool,
"sharpen-brush": pixelBrushTool,
smudge: pixelBrushTool,
"shape-rect": shapeTool,
"shape-ellipse": shapeTool,
"shape-line": shapeTool,
@@ -472,11 +717,13 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
text: textTool,
fill: fillTool,
gradient: gradientTool,
eyedropper: eyedropperHandlers,
zoom: zoomHandlers,
"marquee-rect": selectionHandlers,
"marquee-ellipse": selectionHandlers,
"lasso-free": selectionHandlers,
"lasso-poly": selectionHandlers,
"magic-wand": selectionHandlers,
"magic-wand": magicWandHandlers,
};
return toolMap[activeTool] ?? null;
@@ -484,11 +731,17 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
activeTool,
brushTool,
eraserTool,
cloneStampTool,
dodgeBurnTool,
pixelBrushTool,
shapeTool,
textTool,
fillTool,
gradientTool,
eyedropperHandlers,
zoomHandlers,
selectionHandlers,
magicWandHandlers,
]);
return { handlers, moveTool, selectionTool, transformTool };
@@ -680,7 +933,16 @@ export function EditorCanvas({
const layerObjects = objectsByLayer.get(layer.id) ?? [];
if (!layer.visible) return null;
return (
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
<Group
key={layer.id}
opacity={layer.opacity}
globalCompositeOperation={
layer.blendMode !== "normal"
? (layer.blendMode as GlobalCompositeOperation)
: undefined
}
listening={layer.id === activeLayerId}
>
{layerObjects.map((obj) => (
<CanvasObjectRenderer
key={obj.id}
@@ -702,6 +964,8 @@ export function EditorCanvas({
<MoveToolTransformer transformerRef={moveTool.transformerRef} />
)}
{activeTool === "move" && <SmartGuidesOverlay guides={moveTool.smartGuides} />}
{/* Transform tool transformer */}
{activeTool === "transform" && (
<TransformToolTransformer transformerRef={transformTool.transformerRef} />
@@ -712,15 +976,9 @@ export function EditorCanvas({
{/* Active selection preview (drawn while dragging) */}
{selectionTool.isDrawing && selectionTool.currentPoints.length >= 4 && (
<Rect
x={Math.min(selectionTool.currentPoints[0], selectionTool.currentPoints[2])}
y={Math.min(selectionTool.currentPoints[1], selectionTool.currentPoints[3])}
width={Math.abs(selectionTool.currentPoints[2] - selectionTool.currentPoints[0])}
height={Math.abs(selectionTool.currentPoints[3] - selectionTool.currentPoints[1])}
stroke="#3b82f6"
strokeWidth={1}
dash={[4, 4]}
listening={false}
<ActiveSelectionPreview
type={selectionTool.selectionType}
points={selectionTool.currentPoints}
/>
)}
</Layer>
@@ -739,6 +997,9 @@ export function EditorCanvas({
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
zoom={zoom}
panOffset={panOffset}
stageWidth={stageWidth}
stageHeight={stageHeight}
showGrid={gridVisible}
showPixelGrid={zoom >= 8}
/>
@@ -764,12 +1025,18 @@ function GridOverlay({
canvasWidth,
canvasHeight,
zoom,
panOffset,
stageWidth,
stageHeight,
showGrid,
showPixelGrid,
}: {
canvasWidth: number;
canvasHeight: number;
zoom: number;
panOffset: { x: number; y: number };
stageWidth: number;
stageHeight: number;
showGrid: boolean;
showPixelGrid: boolean;
}) {
@@ -793,17 +1060,38 @@ function GridOverlay({
ctx.stroke();
}
// FIX 7: Clip pixel grid to the visible viewport to avoid drawing
// thousands of lines for large images. Cap at 200 lines per axis.
if (showPixelGrid) {
ctx.beginPath();
ctx.strokeStyle = "rgba(128, 128, 128, 0.1)";
ctx.lineWidth = 1 / zoom;
for (let x = 1; x < canvasWidth; x++) {
ctx.moveTo(x, 0);
ctx.lineTo(x, canvasHeight);
// Calculate visible region in canvas coordinates from pan/zoom
const visMinX = Math.max(0, Math.floor(-panOffset.x / zoom));
const visMinY = Math.max(0, Math.floor(-panOffset.y / zoom));
const visMaxX = Math.min(canvasWidth, Math.ceil((stageWidth - panOffset.x) / zoom));
const visMaxY = Math.min(canvasHeight, Math.ceil((stageHeight - panOffset.y) / zoom));
const MAX_LINES = 200;
// Determine step: if visible range exceeds max lines, skip pixels
const xRange = visMaxX - visMinX;
const yRange = visMaxY - visMinY;
const xStep = xRange > MAX_LINES ? Math.ceil(xRange / MAX_LINES) : 1;
const yStep = yRange > MAX_LINES ? Math.ceil(yRange / MAX_LINES) : 1;
// Align start to step boundary
const startX = Math.max(1, visMinX - (visMinX % xStep) + xStep);
const startY = Math.max(1, visMinY - (visMinY % yStep) + yStep);
for (let x = startX; x < visMaxX; x += xStep) {
ctx.moveTo(x, visMinY);
ctx.lineTo(x, visMaxY);
}
for (let y = 1; y < canvasHeight; y++) {
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
for (let y = startY; y < visMaxY; y += yStep) {
ctx.moveTo(visMinX, y);
ctx.lineTo(visMaxX, y);
}
ctx.stroke();
}
@@ -0,0 +1,504 @@
// apps/web/src/components/editor/editor-menu-bar.tsx
import { Check, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
function mod(label: string): string {
return IS_MAC ? label.replace("Ctrl+", "⌘").replace("Shift+", "⇧") : label;
}
export interface MenuBarCallbacks {
onNewDocument: () => void;
onOpenImage: () => void;
onExport: () => void;
onSave: () => void;
onCanvasResize: () => void;
onImageResize: () => void;
}
interface MenuItem {
label: string;
shortcut?: string;
action?: () => void;
disabled?: boolean;
checked?: boolean;
submenu?: MenuItem[];
dividerAfter?: boolean;
}
interface MenuDef {
label: string;
testId: string;
items: MenuItem[];
}
function useMenuDefinitions(callbacks: MenuBarCallbacks): MenuDef[] {
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const layers = useEditorStore((s) => s.layers);
const activeLayerId = useEditorStore((s) => s.activeLayerId);
const rulersVisible = useEditorStore((s) => s.rulersVisible);
const gridVisible = useEditorStore((s) => s.gridVisible);
const guidesVisible = useEditorStore((s) => s.guidesVisible);
const snappingEnabled = useEditorStore((s) => s.snappingEnabled);
const rightPanelVisible = useEditorStore((s) => s.rightPanelVisible);
const setTool = useEditorStore((s) => s.setTool);
const setZoom = useEditorStore((s) => s.setZoom);
const zoom = useEditorStore((s) => s.zoom);
const addLayer = useEditorStore((s) => s.addLayer);
const removeLayer = useEditorStore((s) => s.removeLayer);
const duplicateLayer = useEditorStore((s) => s.duplicateLayer);
const mergeDown = useEditorStore((s) => s.mergeDown);
const flattenAll = useEditorStore((s) => s.flattenAll);
const setSelection = useEditorStore((s) => s.setSelection);
const invertSelection = useEditorStore((s) => s.invertSelection);
const canvasSize = useEditorStore((s) => s.canvasSize);
const rotateCanvas = useEditorStore((s) => s.rotateCanvas);
const flipCanvasHorizontal = useEditorStore((s) => s.flipCanvasHorizontal);
const flipCanvasVertical = useEditorStore((s) => s.flipCanvasVertical);
const trimCanvas = useEditorStore((s) => s.trimCanvas);
const toggleFilter = useEditorStore((s) => s.toggleFilter);
const toggleRulers = useEditorStore((s) => s.toggleRulers);
const toggleGrid = useEditorStore((s) => s.toggleGrid);
const toggleGuides = useEditorStore((s) => s.toggleGuides);
const toggleSnapping = useEditorStore((s) => s.toggleSnapping);
const toggleRightPanel = useEditorStore((s) => s.toggleRightPanel);
const copyObjects = useEditorStore((s) => s.copyObjects);
const cutObjects = useEditorStore((s) => s.cutObjects);
const pasteObjects = useEditorStore((s) => s.pasteObjects);
const pasteInPlace = useEditorStore((s) => s.pasteInPlace);
const removeObjects = useEditorStore((s) => s.removeObjects);
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const bringToFront = useEditorStore((s) => s.bringToFront);
const bringForward = useEditorStore((s) => s.bringForward);
const sendBackward = useEditorStore((s) => s.sendBackward);
const sendToBack = useEditorStore((s) => s.sendToBack);
const hasImage = !!sourceImageUrl;
const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
const singleLayer = layers.length <= 1;
const undo = useCallback(() => {
useEditorStore.temporal.getState().undo();
}, []);
const redo = useCallback(() => {
useEditorStore.temporal.getState().redo();
}, []);
return [
{
label: "File",
testId: "file",
items: [
{ label: "New", shortcut: mod("Ctrl+N"), action: callbacks.onNewDocument },
{ label: "Open", shortcut: mod("Ctrl+O"), action: callbacks.onOpenImage },
{ label: "Save", shortcut: mod("Ctrl+S"), action: callbacks.onSave, dividerAfter: true },
{ label: "Export As...", shortcut: mod("Ctrl+Shift+E"), action: callbacks.onExport },
{ label: "Quick Export as PNG", shortcut: mod("Ctrl+Shift+P"), action: callbacks.onExport },
{
label: "Close",
shortcut: mod("Ctrl+W"),
disabled: !hasImage,
action: () => {
if (hasImage) {
useEditorStore.setState({
sourceImageUrl: null,
sourceImageSize: null,
objects: [],
selectedObjectIds: [],
});
}
},
},
],
},
{
label: "Edit",
testId: "edit",
items: [
{ label: "Undo", shortcut: mod("Ctrl+Z"), action: undo },
{ label: "Redo", shortcut: mod("Ctrl+Shift+Z"), action: redo, dividerAfter: true },
{ label: "Cut", shortcut: mod("Ctrl+X"), action: cutObjects },
{ label: "Copy", shortcut: mod("Ctrl+C"), action: copyObjects },
{ label: "Copy Merged", shortcut: mod("Ctrl+Shift+C"), action: copyObjects },
{ label: "Paste", shortcut: mod("Ctrl+V"), action: pasteObjects },
{
label: "Paste in Place",
shortcut: mod("Ctrl+Shift+V"),
action: pasteInPlace,
dividerAfter: true,
},
{
label: "Delete",
shortcut: "Del",
action: () => removeObjects(selectedObjectIds),
disabled: selectedObjectIds.length === 0,
},
{
label: "Free Transform",
shortcut: mod("Ctrl+T"),
action: () => setTool("transform"),
dividerAfter: true,
},
{
label: "Transform",
submenu: [
{ label: "Scale", action: () => setTool("transform") },
{ label: "Rotate", action: () => setTool("transform") },
{ label: "Skew", action: () => setTool("transform") },
{ label: "Flip Horizontal", action: flipCanvasHorizontal },
{ label: "Flip Vertical", action: flipCanvasVertical },
],
},
],
},
{
label: "Image",
testId: "image",
items: [
{
label: "Image Size...",
shortcut: mod("Ctrl+Alt+I"),
action: callbacks.onImageResize,
dividerAfter: true,
},
{ label: "Canvas Size...", shortcut: mod("Ctrl+Alt+C"), action: callbacks.onCanvasResize },
{
label: "Image Rotation",
submenu: [
{ label: "90° CW", action: () => rotateCanvas(90) },
{ label: "90° CCW", action: () => rotateCanvas(270) },
{ label: "180°", action: () => rotateCanvas(180) },
{ label: "Flip Horizontal", action: flipCanvasHorizontal },
{ label: "Flip Vertical", action: flipCanvasVertical },
],
dividerAfter: true,
},
{ label: "Trim", action: trimCanvas },
{
label: "Adjustments",
submenu: [
{ label: "Brightness/Contrast" },
{ label: "Hue/Saturation" },
{ label: "Color Balance" },
{ label: "Levels" },
{ label: "Curves" },
],
},
],
},
{
label: "Layer",
testId: "layer",
items: [
{ label: "New Layer", shortcut: mod("Ctrl+Shift+N"), action: addLayer },
{ label: "Duplicate Layer", action: () => duplicateLayer(activeLayerId) },
{
label: "Delete Layer",
action: () => removeLayer(activeLayerId),
disabled: singleLayer,
dividerAfter: true,
},
{
label: "Arrange",
submenu: [
{
label: "Bring to Front",
action: () => {
if (selectedObjectIds[0]) bringToFront(selectedObjectIds[0]);
},
},
{
label: "Bring Forward",
action: () => {
if (selectedObjectIds[0]) bringForward(selectedObjectIds[0]);
},
},
{
label: "Send Backward",
action: () => {
if (selectedObjectIds[0]) sendBackward(selectedObjectIds[0]);
},
},
{
label: "Send to Back",
action: () => {
if (selectedObjectIds[0]) sendToBack(selectedObjectIds[0]);
},
},
],
dividerAfter: true,
},
{
label: "Merge Down",
shortcut: mod("Ctrl+E"),
action: () => mergeDown(activeLayerId),
disabled: activeIndex <= 0,
},
{ label: "Flatten Image", action: flattenAll },
],
},
{
label: "Select",
testId: "select",
items: [
{
label: "All",
shortcut: mod("Ctrl+A"),
action: () =>
setSelection({
type: "rect",
points: [
0,
0,
canvasSize.width,
0,
canvasSize.width,
canvasSize.height,
0,
canvasSize.height,
],
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
}),
},
{
label: "Deselect",
shortcut: mod("Ctrl+D"),
action: () => setSelection(null),
dividerAfter: true,
},
{ label: "Inverse", shortcut: mod("Ctrl+Shift+I"), action: invertSelection },
{ label: "Color Range..." },
],
},
{
label: "Filter",
testId: "filter",
items: [
{
label: "Blur",
submenu: [
{ label: "Gaussian Blur", action: () => toggleFilter("blur") },
{ label: "Motion Blur", action: () => toggleFilter("motionBlur") },
{ label: "Radial Blur", action: () => toggleFilter("radialBlur") },
{ label: "Surface Blur", action: () => toggleFilter("surfaceBlur") },
],
},
{
label: "Sharpen",
submenu: [
{ label: "Sharpen", action: () => toggleFilter("sharpen") },
{ label: "Unsharp Mask" },
],
},
{
label: "Noise",
submenu: [
{ label: "Add Noise", action: () => toggleFilter("noise") },
{ label: "Reduce Noise" },
],
},
{
label: "Pixelate",
submenu: [
{ label: "Pixelate", action: () => toggleFilter("pixelate") },
{ label: "Mosaic" },
],
},
{
label: "Stylize",
submenu: [
{ label: "Emboss", action: () => toggleFilter("emboss") },
{ label: "Solarize", action: () => toggleFilter("solarize") },
{ label: "Posterize", action: () => toggleFilter("posterize") },
],
dividerAfter: true,
},
{ label: "Grayscale", action: () => toggleFilter("grayscale") },
{ label: "Sepia", action: () => toggleFilter("sepia") },
{ label: "Invert", action: () => toggleFilter("invert") },
],
},
{
label: "View",
testId: "view",
items: [
{ label: "Zoom In", shortcut: mod("Ctrl+="), action: () => setZoom(zoom * 1.25) },
{ label: "Zoom Out", shortcut: mod("Ctrl+-"), action: () => setZoom(zoom / 1.25) },
{ label: "Fit on Screen", shortcut: mod("Ctrl+0"), action: () => setZoom(1) },
{
label: "Actual Pixels",
shortcut: mod("Ctrl+1"),
action: () => setZoom(1),
dividerAfter: true,
},
{ label: "Rulers", checked: rulersVisible, action: toggleRulers },
{ label: "Grid", checked: gridVisible, action: toggleGrid },
{ label: "Guides", checked: guidesVisible, action: toggleGuides },
{ label: "Snap", checked: snappingEnabled, action: toggleSnapping, dividerAfter: true },
{ label: "Panels", checked: rightPanelVisible, action: toggleRightPanel },
],
},
];
}
function toTestId(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void }) {
const [submenuOpen, setSubmenuOpen] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleEnter = () => {
if (item.submenu) {
clearTimeout(timerRef.current);
setSubmenuOpen(true);
}
};
const handleLeave = () => {
if (item.submenu) {
timerRef.current = setTimeout(() => setSubmenuOpen(false), 150);
}
};
useEffect(() => () => clearTimeout(timerRef.current), []);
if (item.submenu) {
return (
<div
className="relative"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
role="menuitem"
tabIndex={0}
>
<div
className={cn(
"flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm",
item.disabled
? "text-muted-foreground/50"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`menu-item-${toTestId(item.label)}`}
>
<span>{item.label}</span>
<ChevronRight size={12} className="ml-4 text-muted-foreground" />
</div>
{submenuOpen && (
<div
className="absolute left-full top-0 ml-0.5 min-w-[180px] bg-card border border-border rounded-md shadow-lg py-1 z-[60]"
role="menu"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{item.submenu.map((sub) => (
<MenuItemRow key={sub.label} item={sub} onClose={onClose} />
))}
</div>
)}
{item.dividerAfter && <div className="my-1 border-t border-border" />}
</div>
);
}
return (
<>
<button
type="button"
className={cn(
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-left",
item.disabled
? "text-muted-foreground/50 pointer-events-none"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
disabled={item.disabled}
onClick={() => {
item.action?.();
onClose();
}}
data-testid={`menu-item-${toTestId(item.label)}`}
>
<span className="flex items-center gap-2">
{item.checked !== undefined && (
<span className="w-3.5">{item.checked && <Check size={12} />}</span>
)}
{item.label}
</span>
{item.shortcut && (
<span className="ml-6 text-[10px] text-muted-foreground">{item.shortcut}</span>
)}
</button>
{item.dividerAfter && <div className="my-1 border-t border-border" />}
</>
);
}
export function EditorMenuBar(props: MenuBarCallbacks) {
const menus = useMenuDefinitions(props);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpenMenu(null), []);
useEffect(() => {
if (!openMenu) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [openMenu, close]);
useEffect(() => {
if (!openMenu) return;
const handleClick = (e: MouseEvent) => {
if (barRef.current && !barRef.current.contains(e.target as Node)) {
close();
}
};
window.addEventListener("mousedown", handleClick);
return () => window.removeEventListener("mousedown", handleClick);
}, [openMenu, close]);
return (
<div
ref={barRef}
className="flex items-center h-7 bg-card border-b border-border px-1 select-none shrink-0"
data-testid="editor-menu-bar"
>
{menus.map((menu) => (
<div key={menu.testId} className="relative">
<button
type="button"
className={cn(
"px-2.5 py-0.5 text-xs rounded-sm transition-colors",
openMenu === menu.testId
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50",
)}
data-testid={`menu-${menu.testId}`}
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
onMouseEnter={() => {
if (openMenu) setOpenMenu(menu.testId);
}}
>
{menu.label}
</button>
{openMenu === menu.testId && (
<div
className="absolute left-0 top-full mt-0.5 min-w-[220px] bg-card border border-border rounded-md shadow-lg py-1 z-50"
data-testid={`menu-dropdown-${menu.testId}`}
role="menu"
>
{menu.items.map((item) => (
<MenuItemRow key={item.label} item={item} onClose={close} />
))}
</div>
)}
</div>
))}
</div>
);
}
@@ -1,11 +1,13 @@
// apps/web/src/components/editor/editor-options-bar.tsx
import { useState } from "react";
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
import { BrushOptions } from "./options/brush-options";
import { CloneStampOptions } from "./options/clone-stamp-options";
import { CropOptions } from "./options/crop-options";
import { DodgeBurnOptions } from "./options/dodge-burn-options";
import { EyedropperOptions, type SampleSize } from "./options/eyedropper-options";
import { FillOptions } from "./options/fill-options";
import { GradientOptions } from "./options/gradient-options";
import { MoveOptions } from "./options/move-options";
@@ -13,6 +15,8 @@ import { PixelBrushOptions } from "./options/pixel-brush-options";
import { SelectionOptions } from "./options/selection-options";
import { ShapeOptions } from "./options/shape-options";
import { TextOptions } from "./options/text-options";
import { TransformOptions } from "./options/transform-options";
import { useTransformTool } from "./tools/transform-tool";
function getOptionsComponent(tool: ToolType): React.ComponentType | null {
switch (tool) {
@@ -53,6 +57,7 @@ function getOptionsComponent(tool: ToolType): React.ComponentType | null {
return ShapeOptions;
case "text":
return TextOptions;
// transform and eyedropper are handled separately in EditorOptionsBar
case "transform":
case "eyedropper":
case "hand":
@@ -68,6 +73,16 @@ export function EditorOptionsBar() {
const OptionsComponent = getOptionsComponent(activeTool);
// Eyedropper state (managed here since EyedropperOptions requires props)
const [eyedropperSampleSize, setEyedropperSampleSize] = useState<SampleSize>(1);
const [sampledColor, setSampledColor] = useState<string | null>(null);
// Transform tool API (managed here since TransformOptions requires props)
const transformApi = useTransformTool();
// Pick sampled color from store foreground when eyedropper is active
const foregroundColor = useEditorStore((s) => s.foregroundColor);
return (
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
<span className="text-xs font-medium text-muted-foreground">
@@ -79,6 +94,14 @@ export function EditorOptionsBar() {
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2 flex-1">
{OptionsComponent && <OptionsComponent />}
{activeTool === "eyedropper" && (
<EyedropperOptions
sampleSize={eyedropperSampleSize}
onSampleSizeChange={setEyedropperSampleSize}
sampledColor={sampledColor ?? foregroundColor}
/>
)}
{activeTool === "transform" && <TransformOptions api={transformApi} />}
</div>
</div>
);
@@ -0,0 +1,408 @@
// apps/web/src/components/editor/konva-filters.ts
// Custom Konva filter factories for the image editor.
// Each factory returns a function `(imageData: ImageData) => void` that mutates pixel data in place.
// ---------------------------------------------------------------------------
// Adjustment Filters
// ---------------------------------------------------------------------------
/**
* Exposure adjustment via gamma curve.
* exposure > 0 brightens, exposure < 0 darkens.
* Formula: pixel = 255 * pow(pixel/255, 1/(1 + exposure))
*/
export function createExposureFilter(exposure: number): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const d = imageData.data;
const gamma = 1 / (1 + exposure);
// Build a lookup table for performance
const lut = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
lut[i] = Math.min(255, Math.max(0, Math.round(255 * (i / 255) ** gamma)));
}
for (let i = 0; i < d.length; i += 4) {
d[i] = lut[d[i]];
d[i + 1] = lut[d[i + 1]];
d[i + 2] = lut[d[i + 2]];
}
};
}
/**
* Vibrance: selective saturation boost that increases saturation more
* for less-saturated pixels, preserving already-vivid colors.
* amount is in [-100, 100] range.
*/
export function createVibranceFilter(amount: number): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const d = imageData.data;
const amt = amount / 100;
for (let i = 0; i < d.length; i += 4) {
const r = d[i];
const g = d[i + 1];
const b = d[i + 2];
const maxC = Math.max(r, g, b);
const minC = Math.min(r, g, b);
// Saturation approximation (0..1)
const sat = maxC === 0 ? 0 : (maxC - minC) / maxC;
// Boost less-saturated pixels more
const boost = amt * (1 - sat);
const avg = (r + g + b) / 3;
d[i] = Math.min(255, Math.max(0, Math.round(r + (r - avg) * boost)));
d[i + 1] = Math.min(255, Math.max(0, Math.round(g + (g - avg) * boost)));
d[i + 2] = Math.min(255, Math.max(0, Math.round(b + (b - avg) * boost)));
}
};
}
/**
* Warmth / color temperature shift.
* Positive warmth: boost red, reduce blue (warmer).
* Negative warmth: boost blue, reduce red (cooler).
* amount is in [-100, 100] range.
*/
export function createWarmthFilter(amount: number): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const d = imageData.data;
// Scale to a reasonable pixel shift range
const shift = (amount / 100) * 30;
for (let i = 0; i < d.length; i += 4) {
d[i] = Math.min(255, Math.max(0, Math.round(d[i] + shift)));
d[i + 2] = Math.min(255, Math.max(0, Math.round(d[i + 2] - shift)));
}
};
}
// ---------------------------------------------------------------------------
// Creative Filters
// ---------------------------------------------------------------------------
/**
* Motion blur: directional box blur along a given angle.
* angle in degrees, distance is the blur length in pixels.
*/
export function createMotionBlurFilter(params: {
angle: number;
distance: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { angle, distance } = params;
if (distance <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const copy = new Uint8ClampedArray(d);
const rad = (angle * Math.PI) / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
const steps = Math.max(1, Math.round(distance));
const half = steps / 2;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let rSum = 0;
let gSum = 0;
let bSum = 0;
let aSum = 0;
let count = 0;
for (let s = -half; s <= half; s++) {
const sx = Math.round(x + dx * s);
const sy = Math.round(y + dy * s);
if (sx >= 0 && sx < w && sy >= 0 && sy < h) {
const idx = (sy * w + sx) * 4;
rSum += copy[idx];
gSum += copy[idx + 1];
bSum += copy[idx + 2];
aSum += copy[idx + 3];
count++;
}
}
if (count > 0) {
const idx = (y * w + x) * 4;
d[idx] = Math.round(rSum / count);
d[idx + 1] = Math.round(gSum / count);
d[idx + 2] = Math.round(bSum / count);
d[idx + 3] = Math.round(aSum / count);
}
}
}
};
}
/**
* Radial blur: concentric blur emanating from a center point.
* amount controls blur strength, centerX/centerY are 0..1 normalized.
*/
export function createRadialBlurFilter(params: {
amount: number;
centerX: number;
centerY: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { amount, centerX, centerY } = params;
if (amount <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const copy = new Uint8ClampedArray(d);
const cx = centerX * w;
const cy = centerY * h;
const maxDist = Math.sqrt(w * w + h * h) / 2;
const samples = Math.max(2, Math.min(32, Math.round(amount / 3)));
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = x - cx;
const dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Blur strength scales with distance from center
const strength = (dist / maxDist) * (amount / 100);
let rSum = 0;
let gSum = 0;
let bSum = 0;
let aSum = 0;
let count = 0;
for (let s = 0; s < samples; s++) {
const t = (s / (samples - 1)) * 2 - 1; // -1..1
const sx = Math.round(x + dx * t * strength);
const sy = Math.round(y + dy * t * strength);
if (sx >= 0 && sx < w && sy >= 0 && sy < h) {
const idx = (sy * w + sx) * 4;
rSum += copy[idx];
gSum += copy[idx + 1];
bSum += copy[idx + 2];
aSum += copy[idx + 3];
count++;
}
}
if (count > 0) {
const idx = (y * w + x) * 4;
d[idx] = Math.round(rSum / count);
d[idx + 1] = Math.round(gSum / count);
d[idx + 2] = Math.round(bSum / count);
d[idx + 3] = Math.round(aSum / count);
}
}
}
};
}
/**
* Surface blur: bilateral-like filter that blurs while preserving edges.
* radius controls the spatial extent, threshold controls the edge sensitivity.
*/
export function createSurfaceBlurFilter(params: {
radius: number;
threshold: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { radius, threshold } = params;
if (radius <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const copy = new Uint8ClampedArray(d);
const r = Math.min(radius, 10); // Cap radius for performance
const threshSq = threshold * threshold;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4;
const cR = copy[idx];
const cG = copy[idx + 1];
const cB = copy[idx + 2];
let rSum = 0;
let gSum = 0;
let bSum = 0;
let wSum = 0;
for (let ky = -r; ky <= r; ky++) {
const sy = y + ky;
if (sy < 0 || sy >= h) continue;
for (let kx = -r; kx <= r; kx++) {
const sx = x + kx;
if (sx < 0 || sx >= w) continue;
const sIdx = (sy * w + sx) * 4;
const dR = copy[sIdx] - cR;
const dG = copy[sIdx + 1] - cG;
const dB = copy[sIdx + 2] - cB;
const colorDist = dR * dR + dG * dG + dB * dB;
// Weight falls off as color difference increases
const weight = colorDist < threshSq ? 1 - colorDist / threshSq : 0;
if (weight > 0) {
rSum += copy[sIdx] * weight;
gSum += copy[sIdx + 1] * weight;
bSum += copy[sIdx + 2] * weight;
wSum += weight;
}
}
}
if (wSum > 0) {
d[idx] = Math.round(rSum / wSum);
d[idx + 1] = Math.round(gSum / wSum);
d[idx + 2] = Math.round(bSum / wSum);
}
}
}
};
}
/**
* Vignette: darken edges in a radial gradient pattern.
* amount controls darkness (0..100), midpoint controls where falloff begins (0..100).
*/
export function createVignetteFilter(params: {
amount: number;
midpoint: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { amount, midpoint } = params;
if (amount <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const cx = w / 2;
const cy = h / 2;
const strength = amount / 100;
const mid = midpoint / 100;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = (x - cx) / cx;
const dy = (y - cy) / cy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Smooth falloff: no darkening below midpoint, ramps up after
const factor = Math.max(0, (dist - mid) / (Math.SQRT2 - mid));
const darken = 1 - factor * factor * strength;
const idx = (y * w + x) * 4;
d[idx] = Math.round(d[idx] * darken);
d[idx + 1] = Math.round(d[idx + 1] * darken);
d[idx + 2] = Math.round(d[idx + 2] * darken);
}
}
};
}
/**
* Grain: add random noise overlay to simulate film grain.
* amount controls intensity (0..100), size controls grain size (1..100).
*/
export function createGrainFilter(params: {
amount: number;
size: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { amount, size } = params;
if (amount <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const intensity = (amount / 100) * 80; // Max noise amplitude in pixel values
const grainSize = Math.max(1, Math.round((size / 100) * 4)); // 1..4 pixel blocks
// Pre-generate noise grid at reduced resolution for grain size > 1
const nw = Math.ceil(w / grainSize);
const nh = Math.ceil(h / grainSize);
const noise = new Float32Array(nw * nh);
for (let i = 0; i < noise.length; i++) {
noise[i] = (Math.random() - 0.5) * 2 * intensity;
}
for (let y = 0; y < h; y++) {
const ny = Math.floor(y / grainSize);
for (let x = 0; x < w; x++) {
const nx = Math.floor(x / grainSize);
const n = noise[ny * nw + nx];
const idx = (y * w + x) * 4;
d[idx] = Math.min(255, Math.max(0, Math.round(d[idx] + n)));
d[idx + 1] = Math.min(255, Math.max(0, Math.round(d[idx + 1] + n)));
d[idx + 2] = Math.min(255, Math.max(0, Math.round(d[idx + 2] + n)));
}
}
};
}
/**
* Sharpen: unsharp mask convolution.
* amount controls sharpening strength (0..100), radius controls kernel size.
*/
export function createSharpenFilter(params: {
amount: number;
radius: number;
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const { amount, radius } = params;
if (amount <= 0) return;
const w = imageData.width;
const h = imageData.height;
const d = imageData.data;
const copy = new Uint8ClampedArray(d);
const r = Math.max(1, Math.min(Math.round(radius), 5));
const strength = amount / 100;
// Simple box blur for the "unsharp" step
const blurred = new Float32Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let rSum = 0;
let gSum = 0;
let bSum = 0;
let count = 0;
for (let ky = -r; ky <= r; ky++) {
const sy = Math.min(h - 1, Math.max(0, y + ky));
for (let kx = -r; kx <= r; kx++) {
const sx = Math.min(w - 1, Math.max(0, x + kx));
const sIdx = (sy * w + sx) * 4;
rSum += copy[sIdx];
gSum += copy[sIdx + 1];
bSum += copy[sIdx + 2];
count++;
}
}
const idx = (y * w + x) * 4;
blurred[idx] = rSum / count;
blurred[idx + 1] = gSum / count;
blurred[idx + 2] = bSum / count;
}
}
// Unsharp mask: original + strength * (original - blurred)
for (let i = 0; i < d.length; i += 4) {
d[i] = Math.min(255, Math.max(0, Math.round(copy[i] + strength * (copy[i] - blurred[i]))));
d[i + 1] = Math.min(
255,
Math.max(0, Math.round(copy[i + 1] + strength * (copy[i + 1] - blurred[i + 1]))),
);
d[i + 2] = Math.min(
255,
Math.max(0, Math.round(copy[i + 2] + strength * (copy[i + 2] - blurred[i + 2]))),
);
}
};
}
@@ -3,6 +3,7 @@
import { Wand2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SliderRow } from "@/components/editor/common/slider-row";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -1035,6 +1036,36 @@ export function AdjustmentsPanel() {
const adjustments = useEditorStore((s) => s.adjustments);
const filters = useEditorStore((s) => s.filters);
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Capture imageData from the Konva stage for the histogram
const [histogramData, setHistogramData] = useState<ImageData | null>(null);
useEffect(() => {
function captureImageData() {
const stage = editorStageRefHolder.current;
if (!stage) return;
try {
const canvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const ctx = canvas.getContext("2d");
if (!ctx) return;
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
setHistogramData(data);
} catch {
// Stage may not be ready yet
}
}
// Capture on mount and when adjustments/filters change
const timer = setTimeout(captureImageData, 100);
return () => clearTimeout(timer);
}, [adjustments, filters, canvasSize]);
const hasChanges = useMemo(() => {
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
@@ -1079,7 +1110,7 @@ export function AdjustmentsPanel() {
return (
<div className="flex flex-col gap-2 text-sm">
{/* Histogram */}
<HistogramPanel />
<HistogramPanel imageData={histogramData} />
{/* Auto Adjustments */}
<SectionHeader title="Auto" />
@@ -21,7 +21,7 @@ import {
Type,
Undo2,
} from "lucide-react";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -85,6 +85,18 @@ export function HistoryPanel() {
// Force re-render when history changes by subscribing to history version
useEditorStore((s) => s._historyVersion);
// Subscribe reactively to temporal state for undo/redo button disabled states
const pastLength = useSyncExternalStore(
(cb) => useEditorStore.temporal.subscribe(cb),
() => useEditorStore.temporal.getState().pastStates.length,
);
const futureLength = useSyncExternalStore(
(cb) => useEditorStore.temporal.subscribe(cb),
() => useEditorStore.temporal.getState().futureStates.length,
);
const canUndo = pastLength > 0;
const canRedo = futureLength > 0;
const undo = useCallback(() => {
useEditorStore.temporal.getState().undo();
}, []);
@@ -151,10 +163,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={undo}
disabled={useEditorStore.temporal.getState().pastStates.length === 0}
disabled={!canUndo}
className={cn(
"p-1 rounded transition-colors",
useEditorStore.temporal.getState().pastStates.length > 0
canUndo
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -166,10 +178,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={redo}
disabled={useEditorStore.temporal.getState().futureStates.length === 0}
disabled={!canRedo}
className={cn(
"p-1 rounded transition-colors",
useEditorStore.temporal.getState().futureStates.length > 0
canRedo
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -178,9 +190,7 @@ export function HistoryPanel() {
>
<Redo2 size={14} />
</button>
<span className="ml-auto text-[10px] text-muted-foreground">
{useEditorStore.temporal.getState().pastStates.length} / 50
</span>
<span className="ml-auto text-[10px] text-muted-foreground">{pastLength} / 50</span>
</div>
{/* History list */}
@@ -369,9 +369,31 @@ function LayerRow({
setEditing(false);
}, [editName, layer.name, onRename]);
// Timer-based single-click vs double-click differentiation for the name button
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleNameClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (clickTimerRef.current) {
// Second click within threshold: enter rename mode
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
setEditing(true);
} else {
// First click: start timer; if no second click, select the layer
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
onSelect();
}, 250);
}
},
[onSelect],
);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditing(true);
// Handled by the timer-based approach in handleNameClick
}, []);
const handleKeyDown = useCallback(
@@ -526,6 +548,7 @@ function LayerRow({
"block text-xs truncate text-left bg-transparent border-0 p-0 w-full cursor-pointer",
isActive ? "text-foreground font-medium" : "text-muted-foreground",
)}
onClick={handleNameClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`layer-name-${layer.id}`}
@@ -145,11 +145,25 @@ export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>)
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
// Use canvas element directly as image source during the stroke instead of
// converting to a data URL on every mouse move (major perf fix).
// The "image" property is a runtime-only hint for the renderer; cast to bypass strict attrs type.
useEditorStore
.getState()
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
}, []);
const handleMouseUp = useCallback(() => {
if (stampRef.current) {
const { canvas, objectId } = stampRef.current;
const dataUrl = canvas.toDataURL();
useEditorStore
.getState()
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
string,
unknown
>);
}
stampRef.current = null;
}, []);
@@ -179,11 +179,24 @@ export function useDodgeBurnTool(stageRef: React.RefObject<Konva.Stage | null>)
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
// Use canvas element directly as image source during the stroke instead of
// converting to a data URL on every mouse move (major perf fix).
useEditorStore
.getState()
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
}, []);
const handleMouseUp = useCallback(() => {
if (strokeRef.current) {
const { canvas, objectId } = strokeRef.current;
const dataUrl = canvas.toDataURL();
useEditorStore
.getState()
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
string,
unknown
>);
}
strokeRef.current = null;
}, []);
@@ -49,29 +49,41 @@ interface UseEyedropperToolOptions {
sampleSize: SampleSize;
}
export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) {
export function useEyedropperTool({
stageRef,
sampleSize: _sampleSizeProp,
}: UseEyedropperToolOptions) {
const setForegroundColor = useEditorStore((s) => s.setForegroundColor);
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Use the sampleSize prop directly (caller wires it from options state)
const sampleSize: SampleSize = _sampleSizeProp;
const [sampledColor, setSampledColor] = useState<string | null>(null);
const canvasCache = useRef<HTMLCanvasElement | null>(null);
/**
* Export the visible stage to a flat canvas for pixel sampling.
* Uses explicit viewport options to get a consistent unzoomed canvas,
* excluding zoom/pan transforms and device pixel ratio.
* 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
// Specify explicit viewport to exclude zoom/pan transforms
const canvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
canvasCache.current = canvas;
return canvas;
}, [stageRef]);
}, [stageRef, canvasSize]);
/**
* Invalidate the cache (call on mousedown so we get fresh data).
@@ -102,8 +114,8 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
const x = Math.round((pointer.x - panOffset.x) / zoom);
const y = Math.round((pointer.y - panOffset.y) / zoom);
// Bounds check
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
// Bounds check against the actual canvas dimensions (unzoomed)
if (x < 0 || y < 0 || x >= canvasSize.width || y >= canvasSize.height) {
return null;
}
@@ -111,7 +123,7 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
setSampledColor(color);
return color;
},
[getStageCanvas, sampleSize, zoom, panOffset],
[getStageCanvas, sampleSize, zoom, panOffset, canvasSize],
);
/**
@@ -1,7 +1,7 @@
// apps/web/src/components/editor/tools/gradient-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
@@ -11,8 +11,17 @@ interface DragState {
startY: number;
}
export interface GradientPreview {
startX: number;
startY: number;
endX: number;
endY: number;
gradientType: "linear" | "radial";
}
export function useGradientTool() {
const dragRef = useRef<DragState | null>(null);
const [preview, setPreview] = useState<GradientPreview | null>(null);
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, zoom, panOffset } = useEditorStore.getState();
@@ -29,15 +38,34 @@ export function useGradientTool() {
const y = (pointer.y - panOffset.y) / zoom;
dragRef.current = { startX: x, startY: y };
const { gradientType } = useEditorStore.getState();
setPreview({ startX: x, startY: y, endX: x, endY: y, gradientType });
}, []);
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
// Could show a preview line/circle here; keeping simple for now
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!dragRef.current) return;
const { zoom, panOffset, gradientType } = useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const endX = (pointer.x - panOffset.x) / zoom;
const endY = (pointer.y - panOffset.y) / zoom;
const { startX, startY } = dragRef.current;
setPreview({ startX, startY, endX, endY, gradientType });
}, []);
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!dragRef.current) return;
setPreview(null);
const {
foregroundColor,
backgroundColor,
@@ -115,5 +143,5 @@ export function useGradientTool() {
dragRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
return { handleMouseDown, handleMouseMove, handleMouseUp, preview };
}
@@ -383,22 +383,44 @@ export function useMoveTool(): MoveToolApi {
const onTransformEnd = useCallback(
(e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const id = node.id();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Normalize scale into width/height
// Normalize scale into absolute dimensions
const newWidth = Math.max(1, node.width() * scaleX);
const newHeight = Math.max(1, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
updateObject(node.id(), {
// Look up the object type to write the correct attributes
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
const newAttrs: Record<string, unknown> = {
x: node.x(),
y: node.y(),
width: newWidth,
height: newHeight,
rotation: node.rotation(),
});
};
if (obj?.type === "ellipse") {
// Convert width/height to radiusX/radiusY for ellipses
newAttrs.radiusX = newWidth / 2;
newAttrs.radiusY = newHeight / 2;
} else if (obj?.type === "polygon") {
// Convert to radius for polygons (average of width/height / 2)
newAttrs.radius = (newWidth + newHeight) / 4;
} else if (obj?.type === "star") {
// Scale outerRadius and innerRadius proportionally for stars
const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number };
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
newAttrs.outerRadius = starAttrs.outerRadius * avgScale;
newAttrs.innerRadius = starAttrs.innerRadius * avgScale;
} else {
// Rectangles, images, text: use width/height directly
newAttrs.width = newWidth;
newAttrs.height = newHeight;
}
updateObject(id, newAttrs);
},
[updateObject],
);
@@ -113,11 +113,24 @@ export function usePixelBrushTool(stageRef: React.RefObject<Konva.Stage | null>)
strokeRef.current.lastX = x;
strokeRef.current.lastY = y;
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
// Use canvas element directly as image source during the stroke instead of
// converting to a data URL on every mouse move (major perf fix).
useEditorStore
.getState()
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
}, []);
const handleMouseUp = useCallback(() => {
if (strokeRef.current) {
const { canvas, objectId } = strokeRef.current;
const dataUrl = canvas.toDataURL();
useEditorStore
.getState()
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
string,
unknown
>);
}
strokeRef.current = null;
}, []);
@@ -203,12 +216,7 @@ function applyBoxBlur(
for (let kx = -kernelSize; kx <= kernelSize; kx++) {
const sx = startX + px + kx;
const sy = startY + py + ky;
if (
sx < 0 ||
sx >= sourceWidth ||
sy < 0 ||
sy >= source.height / (source.width / sourceWidth)
) {
if (sx < 0 || sx >= sourceWidth || sy < 0 || sy >= source.height) {
continue;
}
const si = (sy * sourceWidth + sx) * 4;
@@ -238,6 +238,9 @@ export function useSelectionTool(): SelectionToolApi {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPoints, setCurrentPoints] = useState<number[]>([]);
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const isDrawingRef = useRef(false);
// Polygon vertices for lasso-poly mode
const polyVerticesRef = useRef<number[]>([]);
const selectionMode = useEditorStore((s) => s.selectionMode);
@@ -256,71 +259,227 @@ export function useSelectionTool(): SelectionToolApi {
const nb = newSel.bounds;
if (mode === "add") {
// Union of the two bounding boxes
const x = Math.min(eb.x, nb.x);
const y = Math.min(eb.y, nb.y);
setSelection({
...newSel,
bounds: {
x,
y,
width: Math.max(eb.x + eb.width, nb.x + nb.width) - x,
height: Math.max(eb.y + eb.height, nb.y + nb.height) - y,
},
});
const right = Math.max(eb.x + eb.width, nb.x + nb.width);
const bottom = Math.max(eb.y + eb.height, nb.y + nb.height);
// For mask-based selections (wand, lasso), merge masks
if (existingSelection.mask && newSel.mask) {
const unionW = right - x;
const unionH = bottom - y;
const merged = new Uint8Array(unionW * unionH);
// Copy existing mask into merged
for (let row = 0; row < eb.height; row++) {
for (let col = 0; col < eb.width; col++) {
const si = row * eb.width + col;
const di = (row + eb.y - y) * unionW + (col + eb.x - x);
if (existingSelection.mask[si]) merged[di] = 1;
}
}
// OR new mask into merged
for (let row = 0; row < nb.height; row++) {
for (let col = 0; col < nb.width; col++) {
const si = row * nb.width + col;
const di = (row + nb.y - y) * unionW + (col + nb.x - x);
if (newSel.mask?.[si]) merged[di] = 1;
}
}
setSelection({
type: newSel.type,
points: [],
bounds: { x, y, width: unionW, height: unionH },
mask: merged,
});
} else {
setSelection({
...newSel,
bounds: { x, y, width: right - x, height: bottom - y },
});
}
} else {
// subtract: use the new bounds minus overlap (simplified)
setSelection(newSel);
// Subtract mode: remove the intersection of the new selection from the existing one
if (existingSelection.mask && newSel.mask) {
// Mask-based subtract: AND inverse of new mask with old mask
const result = new Uint8Array(eb.width * eb.height);
for (let row = 0; row < eb.height; row++) {
for (let col = 0; col < eb.width; col++) {
const absX = eb.x + col;
const absY = eb.y + row;
const ei = row * eb.width + col;
// Check if this pixel is in the new selection's mask
const relX = absX - nb.x;
const relY = absY - nb.y;
let inNew = false;
if (relX >= 0 && relX < nb.width && relY >= 0 && relY < nb.height) {
inNew = newSel.mask?.[relY * nb.width + relX] === 1;
}
result[ei] = existingSelection.mask[ei] && !inNew ? 1 : 0;
}
}
setSelection({
type: existingSelection.type,
points: existingSelection.points,
bounds: eb,
mask: result,
});
} else {
// Geometric subtract for rectangular selections
// If the new selection fully contains the old one, deselect
if (
nb.x <= eb.x &&
nb.y <= eb.y &&
nb.x + nb.width >= eb.x + eb.width &&
nb.y + nb.height >= eb.y + eb.height
) {
setSelection(null);
return;
}
// Otherwise keep the existing selection with its bounds reduced where they overlap
// This is a simplified approach: we keep the existing bounds but trim from the side
// with the largest overlap
const overlapX1 = Math.max(eb.x, nb.x);
const overlapY1 = Math.max(eb.y, nb.y);
const overlapX2 = Math.min(eb.x + eb.width, nb.x + nb.width);
const overlapY2 = Math.min(eb.y + eb.height, nb.y + nb.height);
// No overlap means nothing to subtract
if (overlapX2 <= overlapX1 || overlapY2 <= overlapY1) {
return;
}
// Determine which side the overlap is on and trim accordingly
const trimLeft = overlapX1 === eb.x ? overlapX2 - eb.x : 0;
const trimRight = overlapX2 === eb.x + eb.width ? eb.x + eb.width - overlapX1 : 0;
const trimTop = overlapY1 === eb.y ? overlapY2 - eb.y : 0;
const trimBottom = overlapY2 === eb.y + eb.height ? eb.y + eb.height - overlapY1 : 0;
const maxTrim = Math.max(trimLeft, trimRight, trimTop, trimBottom);
let newBounds = { ...eb };
if (maxTrim === trimLeft && trimLeft > 0) {
newBounds = {
x: eb.x + trimLeft,
y: eb.y,
width: eb.width - trimLeft,
height: eb.height,
};
} else if (maxTrim === trimRight && trimRight > 0) {
newBounds = { x: eb.x, y: eb.y, width: eb.width - trimRight, height: eb.height };
} else if (maxTrim === trimTop && trimTop > 0) {
newBounds = {
x: eb.x,
y: eb.y + trimTop,
width: eb.width,
height: eb.height - trimTop,
};
} else if (maxTrim === trimBottom && trimBottom > 0) {
newBounds = { x: eb.x, y: eb.y, width: eb.width, height: eb.height - trimBottom };
}
if (newBounds.width <= 0 || newBounds.height <= 0) {
setSelection(null);
} else {
setSelection({
type: existingSelection.type,
points: existingSelection.points,
bounds: newBounds,
});
}
}
}
},
[existingSelection, setSelection],
);
const isPolyLasso = useCallback(() => {
return useEditorStore.getState().activeTool === "lasso-poly";
}, []);
const onMouseDown = useCallback(
(pos: { x: number; y: number }, _stage?: Konva.Stage) => {
setIsDrawing(true);
startRef.current = pos;
if (selectionType === "lasso") {
setCurrentPoints([pos.x, pos.y]);
if (selectionType === "lasso" && isPolyLasso()) {
// Polygonal lasso: each click adds a vertex
if (!isDrawingRef.current) {
// Start a new polygon
setIsDrawing(true);
isDrawingRef.current = true;
polyVerticesRef.current = [pos.x, pos.y];
setCurrentPoints([pos.x, pos.y]);
} else {
// Add another vertex
polyVerticesRef.current = [...polyVerticesRef.current, pos.x, pos.y];
setCurrentPoints([...polyVerticesRef.current]);
}
} else {
setCurrentPoints([]);
// Freehand lasso, rect, or ellipse
setIsDrawing(true);
isDrawingRef.current = true;
startRef.current = pos;
if (selectionType === "lasso") {
setCurrentPoints([pos.x, pos.y]);
} else {
setCurrentPoints([]);
}
}
},
[selectionType],
[selectionType, isPolyLasso],
);
const onMouseMove = useCallback(
(pos: { x: number; y: number }) => {
if (!isDrawing) return;
if (!isDrawingRef.current) return;
if (selectionType === "lasso") {
if (selectionType === "lasso" && isPolyLasso()) {
// Polygonal lasso: show rubber band line from last vertex to cursor
const verts = polyVerticesRef.current;
setCurrentPoints([...verts, pos.x, pos.y]);
} else if (selectionType === "lasso") {
// Freehand lasso
setCurrentPoints((prev) => [...prev, pos.x, pos.y]);
} else {
const s = startRef.current;
setCurrentPoints([s.x, s.y, pos.x, pos.y]);
}
},
[isDrawing, selectionType],
[selectionType, isPolyLasso],
);
const onMouseUp = useCallback(() => {
if (!isDrawing) return;
setIsDrawing(false);
if (selectionType === "lasso") {
if (currentPoints.length < 6) {
const finalizeLasso = useCallback(
(points: number[]) => {
if (points.length < 6) {
setSelection(null);
setCurrentPoints([]);
return;
}
const xs = currentPoints.filter((_, i) => i % 2 === 0);
const ys = currentPoints.filter((_, i) => i % 2 === 1);
const xs = points.filter((_, i) => i % 2 === 0);
const ys = points.filter((_, i) => i % 2 === 1);
const bounds = {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
};
mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode);
mergeSelection({ type: "lasso", points, bounds }, selectionMode);
setCurrentPoints([]);
},
[selectionMode, mergeSelection, setSelection],
);
const onMouseUp = useCallback(() => {
if (!isDrawingRef.current) return;
if (selectionType === "lasso" && isPolyLasso()) {
// Polygonal lasso: mouseUp does NOT close the polygon, only dblclick does.
// The vertex was already added in onMouseDown, so nothing to do here.
return;
}
setIsDrawing(false);
isDrawingRef.current = false;
if (selectionType === "lasso") {
finalizeLasso(currentPoints);
} else {
if (currentPoints.length < 4) {
setCurrentPoints([]);
@@ -346,30 +505,26 @@ export function useSelectionTool(): SelectionToolApi {
);
}
setCurrentPoints([]);
}, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]);
}, [
currentPoints,
selectionType,
selectionMode,
mergeSelection,
setSelection,
finalizeLasso,
isPolyLasso,
]);
const onDoubleClick = useCallback(() => {
// Close polygonal lasso
if (selectionType === "lasso" && currentPoints.length >= 6) {
if (selectionType === "lasso" && isDrawingRef.current) {
setIsDrawing(false);
const xs = currentPoints.filter((_, i) => i % 2 === 0);
const ys = currentPoints.filter((_, i) => i % 2 === 1);
mergeSelection(
{
type: "lasso",
points: currentPoints,
bounds: {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
},
},
selectionMode,
);
setCurrentPoints([]);
isDrawingRef.current = false;
const verts = polyVerticesRef.current;
finalizeLasso(verts);
polyVerticesRef.current = [];
}
}, [selectionType, currentPoints, selectionMode, mergeSelection]);
}, [selectionType, finalizeLasso]);
const selectAll = useCallback(() => {
setSelection({
@@ -385,16 +540,33 @@ export function useSelectionTool(): SelectionToolApi {
const magicWandSelect = useCallback(
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
const canvas = stage.toCanvas();
// Use explicit viewport options to get a consistent unzoomed canvas,
// ignoring zoom/pan transforms and device pixel ratio
const canvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const ctx = canvas.getContext("2d");
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
const mask = floodFillMask(imageData, x, y, tolerance, contiguous);
const bounds = maskToBounds(mask);
if (!bounds) return;
mergeSelection({ type: "rect", points: [], bounds }, selectionMode);
// Convert the boolean[][] mask to a flat Uint8Array within the bounds region
const flatMask = new Uint8Array(bounds.width * bounds.height);
for (let row = 0; row < bounds.height; row++) {
for (let col = 0; col < bounds.width; col++) {
flatMask[row * bounds.width + col] = mask[bounds.y + row][bounds.x + col] ? 1 : 0;
}
}
mergeSelection({ type: "wand", points: [], bounds, mask: flatMask }, selectionMode);
},
[selectionMode, mergeSelection],
[selectionMode, mergeSelection, canvasSize],
);
return {
@@ -204,14 +204,49 @@ export function TransformToolTransformer({
const handleTransformEnd = useCallback(
(e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const id = node.id();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newW = Math.max(1, node.width() * scaleX);
const newH = Math.max(1, node.height() * scaleY);
// Reset scale on the Konva node
node.scaleX(1);
node.scaleY(1);
node.width(newW);
node.height(newH);
// Directly update the store with the new attributes
if (id) {
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
const newAttrs: Record<string, unknown> = {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
};
if (obj?.type === "ellipse") {
// Convert width/height to radiusX/radiusY for ellipses
newAttrs.radiusX = newW / 2;
newAttrs.radiusY = newH / 2;
} else if (obj?.type === "polygon") {
// Convert width/height to radius for polygons
newAttrs.radius = (newW + newH) / 4;
} else if (obj?.type === "star") {
// Scale outerRadius and innerRadius proportionally for stars
const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number };
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
newAttrs.outerRadius = starAttrs.outerRadius * avgScale;
newAttrs.innerRadius = starAttrs.innerRadius * avgScale;
} else {
// Rectangles, images, text: use width/height directly
newAttrs.width = newW;
newAttrs.height = newH;
}
useEditorStore.getState().updateObject(id, newAttrs);
}
onTransformEnd?.(e);
},
[onTransformEnd],
+11 -4
View File
@@ -26,10 +26,6 @@ export function useCanvasZoom() {
if (tweenRef.current) {
tweenRef.current.destroy();
}
// Update store immediately so all tools get correct coordinates
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current = new Konva.Tween({
node: stage,
scaleX: targetZoom,
@@ -38,7 +34,18 @@ export function useCanvasZoom() {
y: targetPos.y,
duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut,
onUpdate: () => {
// Progressively sync store with the stage's current animated values
// so tools always have accurate coordinates during the tween.
if (stage) {
setZoom(stage.scaleX());
setPanOffset({ x: stage.x(), y: stage.y() });
}
},
onFinish: () => {
// Ensure final values are exact (no floating-point drift)
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current?.destroy();
tweenRef.current = null;
},
+53 -19
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
@@ -55,7 +56,11 @@ function cycleSubtool(current: ToolType, cycle: ToolType[]): ToolType {
*
* @param callbacks Optional callbacks for save/export dialogs
*/
export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?: () => void }) {
export function useEditorShortcuts(callbacks?: {
onSave?: () => void;
onExport?: () => void;
onFillDialog?: () => void;
}) {
const previousToolRef = useRef<ToolType | null>(null);
const isSpaceHeldRef = useRef(false);
@@ -164,6 +169,16 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
{ preventDefault: true },
);
// N - Pencil tool
useHotkeys(
"n",
() => {
if (isInputFocused()) return;
useEditorStore.getState().setTool("pencil");
},
{ preventDefault: true },
);
// E - Eraser tool
useHotkeys(
"e",
@@ -435,24 +450,34 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
{ preventDefault: false },
);
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged (use Konva stage ref for proper composite)
useHotkeys(
"mod+shift+c",
(e) => {
e.preventDefault();
// Export visible layers to clipboard as PNG
const stageCanvas = document.querySelector(
"[data-testid='editor-canvas'] canvas",
) as HTMLCanvasElement | null;
if (!stageCanvas) return;
stageCanvas.toBlob(async (blob) => {
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch {
// Clipboard API not available
}
}, "image/png");
const stage = editorStageRefHolder.current;
if (!stage) return;
const { canvasSize } = useEditorStore.getState();
const dataUrl = stage.toDataURL({
pixelRatio: 1,
mimeType: "image/png",
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
fetch(dataUrl)
.then((res) => res.blob())
.then(async (blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch {
// Clipboard API not available
}
})
.catch(() => {
// Export failed silently
});
},
{ preventDefault: true },
);
@@ -806,15 +831,18 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
{ preventDefault: true },
);
// Shift+Backspace - Fill dialog (trigger callback or use fill tool)
// Shift+Backspace - Open fill dialog
useHotkeys(
"shift+backspace",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
// Fill dialog would be handled by Agent 1's fill-dialog component
// For now, switch to fill tool as a fallback
useEditorStore.getState().setTool("fill");
if (callbacks?.onFillDialog) {
callbacks.onFillDialog();
} else {
// Fallback: dispatch custom event for FillDialog listener
window.dispatchEvent(new CustomEvent("snapotter:open-fill-dialog"));
}
},
{ preventDefault: true },
);
@@ -863,6 +891,7 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
/** Nudge all selected objects by (dx, dy) pixels. */
function nudgeSelected(dx: number, dy: number): void {
const state = useEditorStore.getState();
if (state.selectedObjectIds.length === 0) return;
for (const id of state.selectedObjectIds) {
const obj = state.objects.find((o) => o.id === id);
if (!obj) continue;
@@ -874,4 +903,9 @@ function nudgeSelected(dx: number, dy: number): void {
});
}
}
// Create a history entry so the nudge is undoable
useEditorStore.setState((s) => ({
_historyVersion: s._historyVersion + 1,
lastAction: "Nudge",
}));
}
+85 -1
View File
@@ -2,10 +2,19 @@
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog";
import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog";
import {
AutosaveRecoveryBanner,
ExportDialog,
saveEditorState,
useAutosave,
} from "@/components/editor/common/export-dialog";
import { FillDialog } from "@/components/editor/common/fill-dialog";
import { ImageResizeDialog } from "@/components/editor/common/image-resize-dialog";
import { NewDocumentDialog } from "@/components/editor/common/new-document-dialog";
import { HorizontalRuler, VerticalRuler } from "@/components/editor/common/rulers";
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas";
import { EditorMenuBar } from "@/components/editor/editor-menu-bar";
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
@@ -14,21 +23,37 @@ import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/stores/editor-store";
const SERVER_DECODED_EXTS = new Set(["psd", "tga", "exr", "hdr"]);
export function EditorPage() {
const isMobile = useMobile();
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage);
const rulersVisible = useEditorStore((s) => s.rulersVisible);
const [showExport, setShowExport] = useState(false);
const [showCanvasResize, setShowCanvasResize] = useState(false);
const [showImageResize, setShowImageResize] = useState(false);
const [fillDialogOpen, setFillDialogOpen] = useState(false);
const [showNewDocument, setShowNewDocument] = useState(false);
// Autosave recovery
const { recoveryData, dismissRecovery, restoreRecovery } = useAutosave();
// Issue #10: Shortcuts belong at page level, not canvas level
useEditorShortcuts({
onSave: () => saveEditorState(),
onExport: () => setShowExport(true),
onFillDialog: () => setFillDialogOpen(true),
});
// Listen for fill-dialog custom event (dispatched from Shift+Backspace shortcut)
useEffect(() => {
const handler = () => setFillDialogOpen(true);
window.addEventListener("snapotter:open-fill-dialog", handler);
return () => window.removeEventListener("snapotter:open-fill-dialog", handler);
}, []);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
@@ -64,6 +89,43 @@ export function EditorPage() {
return () => document.removeEventListener("paste", handlePaste);
}, [handlePaste]);
const handleOpenImage = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.psd,.tga,.exr,.hdr";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (SERVER_DECODED_EXTS.has(ext)) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("settings", JSON.stringify({ format: "png" }));
const res = await fetch("/api/v1/tools/convert", { method: "POST", body: formData });
if (!res.ok) throw new Error("Server decode failed");
const json = await res.json();
if (json.downloadUrl) {
const imgRes = await fetch(json.downloadUrl);
const blob = await imgRes.blob();
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
img.src = url;
}
} catch (err) {
console.error("Failed to decode file via server:", err);
}
return;
}
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
img.src = url;
};
input.click();
}, [loadImage]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const url = params.get("url");
@@ -90,9 +152,29 @@ export function EditorPage() {
return (
<div className="flex flex-col h-full overflow-hidden">
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage}
onExport={() => setShowExport(true)}
onSave={() => saveEditorState()}
onCanvasResize={() => setShowCanvasResize(true)}
onImageResize={() => setShowImageResize(true)}
/>
{/* Autosave recovery banner */}
{recoveryData && (
<AutosaveRecoveryBanner
data={recoveryData}
onRestore={restoreRecovery}
onDiscard={dismissRecovery}
/>
)}
<EditorOptionsBar />
{/* Horizontal ruler along the top edge */}
{rulersVisible && <HorizontalRuler />}
<div className="flex flex-1 overflow-hidden">
<EditorToolbar />
{/* Vertical ruler along the left edge */}
{rulersVisible && <VerticalRuler />}
<div className="relative flex-1 overflow-hidden bg-muted/30">
<EditorCanvas
onCanvasResize={() => setShowCanvasResize(true)}
@@ -106,6 +188,8 @@ export function EditorPage() {
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
<CanvasResizeDialog open={showCanvasResize} onClose={() => setShowCanvasResize(false)} />
<ImageResizeDialog open={showImageResize} onClose={() => setShowImageResize(false)} />
<FillDialog open={fillDialogOpen} onClose={() => setFillDialogOpen(false)} />
<NewDocumentDialog open={showNewDocument} onClose={() => setShowNewDocument(false)} />
</div>
);
}
+334 -70
View File
@@ -13,6 +13,23 @@ import type {
ToolType,
} from "@/types/editor";
// Helpers for objects that use points arrays (line, arrow) vs positioned objects
function hasPointsArray(obj: CanvasObject): obj is CanvasObject & { attrs: { points: number[] } } {
return "points" in obj.attrs && Array.isArray((obj.attrs as { points: number[] }).points);
}
function isCenterBased(obj: CanvasObject): boolean {
return obj.type === "ellipse" || obj.type === "polygon" || obj.type === "star";
}
// Extended store state with additional fields/methods not yet in the shared interface
interface EditorStateExtensions {
canvasBackground: string;
commitHistory: (action: string) => void;
batchNudge: (objectIds: string[], dx: number, dy: number) => void;
updateLayerThumbnail: (layerId: string, thumbnailDataUrl: string) => void;
}
const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 };
const DEFAULT_LAYER_ID = "layer-1";
const MAX_RECENT_COLORS = 12;
@@ -89,7 +106,7 @@ function nextLayerNumber(layers: EditorLayer[]): number {
return max + 1;
}
export const useEditorStore = create<EditorState>()(
export const useEditorStore = create<EditorState & EditorStateExtensions>()(
temporal(
(set, get) => ({
// --- Canvas ---
@@ -98,6 +115,9 @@ export const useEditorStore = create<EditorState>()(
panOffset: { x: 0, y: 0 },
cursorPosition: { x: 0, y: 0 },
// --- Canvas background (used when canvas is resized larger) ---
canvasBackground: "#ffffff",
// --- Image ---
sourceImageUrl: null,
sourceImageSize: null,
@@ -260,7 +280,7 @@ export const useEditorStore = create<EditorState>()(
});
},
resizeCanvas: (width, height, anchor, _fill) => {
resizeCanvas: (width, height, anchor, fill) => {
const { canvasSize, objects } = get();
const dw = width - canvasSize.width;
const dh = height - canvasSize.height;
@@ -283,15 +303,25 @@ export const useEditorStore = create<EditorState>()(
}
set({
canvasSize: { width, height },
...(fill ? { canvasBackground: fill } : {}),
objects:
offsetX !== 0 || offsetY !== 0
? objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
(attrs as { x: number }).x += offsetX;
}
if ("y" in attrs) {
(attrs as { y: number }).y += offsetY;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] += offsetX;
pts[i + 1] += offsetY;
}
(attrs as { points: number[] }).points = pts;
} else {
if ("x" in attrs) {
(attrs as { x: number }).x += offsetX;
}
if ("y" in attrs) {
(attrs as { y: number }).y += offsetY;
}
}
return { ...obj, attrs } as CanvasObject;
})
@@ -302,10 +332,40 @@ export const useEditorStore = create<EditorState>()(
});
},
resizeImage: (width, height, _resample) => {
resizeImage: (width, height, resample) => {
void resample; // accepted for future server-side resize; client-side scales objects only
const { canvasSize, objects } = get();
const scaleX = width / canvasSize.width;
const scaleY = height / canvasSize.height;
set({
canvasSize: { width, height },
sourceImageSize: { width, height },
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
const a = attrs as unknown as Record<string, number>;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] *= scaleX;
pts[i + 1] *= scaleY;
}
(attrs as { points: number[] }).points = pts;
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
} else {
if ("x" in attrs) a.x *= scaleX;
if ("y" in attrs) a.y *= scaleY;
if ("width" in attrs) a.width *= scaleX;
if ("height" in attrs) a.height *= scaleY;
if ("radius" in attrs) a.radius *= Math.min(scaleX, scaleY);
if ("radiusX" in attrs) a.radiusX *= scaleX;
if ("radiusY" in attrs) a.radiusY *= scaleY;
if ("innerRadius" in attrs) a.innerRadius *= Math.min(scaleX, scaleY);
if ("outerRadius" in attrs) a.outerRadius *= Math.min(scaleX, scaleY);
if ("fontSize" in attrs) a.fontSize *= Math.min(scaleX, scaleY);
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
lastAction: "Resize Image",
_historyVersion: get()._historyVersion + 1,
@@ -321,34 +381,60 @@ export const useEditorStore = create<EditorState>()(
sourceImageSize: newSize,
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
const hasPos = "x" in attrs && "y" in attrs;
const hasSize = "width" in attrs && "height" in attrs;
const a = attrs as unknown as Record<string, number>;
if (hasPos) {
if (degrees === 90) {
const newX = canvasSize.height - a.y - (hasSize ? a.height : 0);
const newY = a.x;
a.x = newX;
a.y = newY;
} else if (degrees === 270) {
const newX = a.y;
const newY = canvasSize.width - a.x - (hasSize ? a.width : 0);
a.x = newX;
a.y = newY;
} else {
a.x = canvasSize.width - a.x - (hasSize ? a.width : 0);
a.y = canvasSize.height - a.y - (hasSize ? a.height : 0);
// Handle points-based objects (line, arrow)
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
const px = pts[i];
const py = pts[i + 1];
if (degrees === 90) {
pts[i] = canvasSize.height - py;
pts[i + 1] = px;
} else if (degrees === 270) {
pts[i] = py;
pts[i + 1] = canvasSize.width - px;
} else {
pts[i] = canvasSize.width - px;
pts[i + 1] = canvasSize.height - py;
}
}
(attrs as { points: number[] }).points = pts;
} else {
const centerBased = isCenterBased(obj);
const hasPos = "x" in attrs && "y" in attrs;
const hasSize = "width" in attrs && "height" in attrs;
if (hasPos) {
if (degrees === 90) {
const newX = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
const newY = a.x;
a.x = newX;
a.y = newY;
} else if (degrees === 270) {
const newX = a.y;
const newY = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
a.x = newX;
a.y = newY;
} else {
a.x = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
a.y = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
}
}
if (hasSize && degrees !== 180) {
const oldW = a.width;
a.width = a.height;
a.height = oldW;
}
if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) {
const oldRx = a.radiusX;
a.radiusX = a.radiusY;
a.radiusY = oldRx;
}
}
if (hasSize && degrees !== 180) {
const oldW = a.width;
a.width = a.height;
a.height = oldW;
}
if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) {
const oldRx = a.radiusX;
a.radiusX = a.radiusY;
a.radiusY = oldRx;
if ("rotation" in attrs) {
a.rotation = ((a.rotation || 0) + degrees) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
@@ -363,11 +449,21 @@ export const useEditorStore = create<EditorState>()(
set({
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
const a = attrs as unknown as Record<string, number>;
const w = "width" in attrs ? a.width : 0;
const a = attrs as unknown as Record<string, number>;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] = canvasSize.width - pts[i];
}
(attrs as { points: number[] }).points = pts;
} else if ("x" in attrs) {
const centerBased = isCenterBased(obj);
const w = centerBased ? 0 : "width" in attrs ? a.width : 0;
a.x = canvasSize.width - a.x - w;
}
if ("rotation" in attrs) {
a.rotation = (360 - (a.rotation || 0)) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
@@ -381,11 +477,21 @@ export const useEditorStore = create<EditorState>()(
set({
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("y" in attrs) {
const a = attrs as unknown as Record<string, number>;
const h = "height" in attrs ? a.height : 0;
const a = attrs as unknown as Record<string, number>;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 1; i < pts.length; i += 2) {
pts[i] = canvasSize.height - pts[i];
}
(attrs as { points: number[] }).points = pts;
} else if ("y" in attrs) {
const centerBased = isCenterBased(obj);
const h = centerBased ? 0 : "height" in attrs ? a.height : 0;
a.y = canvasSize.height - a.y - h;
}
if ("rotation" in attrs) {
a.rotation = (360 - (a.rotation || 0)) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
@@ -403,14 +509,48 @@ export const useEditorStore = create<EditorState>()(
let maxY = 0;
for (const obj of objects) {
const a = obj.attrs as unknown as Record<string, number>;
const x = "x" in obj.attrs ? a.x : 0;
const y = "y" in obj.attrs ? a.y : 0;
const w = "width" in obj.attrs ? a.width : "radiusX" in obj.attrs ? a.radiusX * 2 : 0;
const h = "height" in obj.attrs ? a.height : "radiusY" in obj.attrs ? a.radiusY * 2 : 0;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
const sw = "strokeWidth" in obj.attrs ? a.strokeWidth / 2 : 0;
if (hasPointsArray(obj)) {
const pts = (obj.attrs as { points: number[] }).points;
for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i] - sw);
minY = Math.min(minY, pts[i + 1] - sw);
maxX = Math.max(maxX, pts[i] + sw);
maxY = Math.max(maxY, pts[i + 1] + sw);
}
} else if (isCenterBased(obj)) {
const cx = "x" in obj.attrs ? a.x : 0;
const cy = "y" in obj.attrs ? a.y : 0;
const rx =
"radiusX" in obj.attrs
? a.radiusX
: "radius" in obj.attrs
? a.radius
: "outerRadius" in obj.attrs
? a.outerRadius
: 0;
const ry =
"radiusY" in obj.attrs
? a.radiusY
: "radius" in obj.attrs
? a.radius
: "outerRadius" in obj.attrs
? a.outerRadius
: 0;
minX = Math.min(minX, cx - rx - sw);
minY = Math.min(minY, cy - ry - sw);
maxX = Math.max(maxX, cx + rx + sw);
maxY = Math.max(maxY, cy + ry + sw);
} else {
const x = "x" in obj.attrs ? a.x : 0;
const y = "y" in obj.attrs ? a.y : 0;
const w = "width" in obj.attrs ? a.width : 0;
const h = "height" in obj.attrs ? a.height : 0;
minX = Math.min(minX, x - sw);
minY = Math.min(minY, y - sw);
maxX = Math.max(maxX, x + w + sw);
maxY = Math.max(maxY, y + h + sw);
}
}
minX = Math.max(0, Math.floor(minX));
minY = Math.max(0, Math.floor(minY));
@@ -431,11 +571,20 @@ export const useEditorStore = create<EditorState>()(
sourceImageSize: { width: newWidth, height: newHeight },
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
(attrs as unknown as Record<string, number>).x -= minX;
}
if ("y" in attrs) {
(attrs as unknown as Record<string, number>).y -= minY;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] -= minX;
pts[i + 1] -= minY;
}
(attrs as { points: number[] }).points = pts;
} else {
if ("x" in attrs) {
(attrs as unknown as Record<string, number>).x -= minX;
}
if ("y" in attrs) {
(attrs as unknown as Record<string, number>).y -= minY;
}
}
return { ...obj, attrs } as CanvasObject;
}),
@@ -574,17 +723,30 @@ export const useEditorStore = create<EditorState>()(
},
sendToBack: (objectId) => {
const { objects } = get();
const { objects, layers } = get();
const obj = objects.find((o) => o.id === objectId);
if (!obj) return;
const newObjects = objects.filter((o) => o.id !== objectId);
let insertIdx = 0;
// Find the first object on the same layer
let insertIdx = -1;
for (let i = 0; i < newObjects.length; i++) {
if (newObjects[i].layerId === obj.layerId) {
insertIdx = i;
break;
}
}
// If no other objects on the same layer, find the correct position
// based on layer ordering (after all objects from earlier layers)
if (insertIdx === -1) {
const layerIdx = layers.findIndex((l) => l.id === obj.layerId);
insertIdx = 0;
for (let i = 0; i < newObjects.length; i++) {
const objLayerIdx = layers.findIndex((l) => l.id === newObjects[i].layerId);
if (objLayerIdx < layerIdx) {
insertIdx = i + 1;
}
}
}
newObjects.splice(insertIdx, 0, obj);
set({
objects: newObjects,
@@ -767,33 +929,79 @@ export const useEditorStore = create<EditorState>()(
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
invertSelection: () => {
const { selection } = get();
const { selection, canvasSize } = get();
if (!selection) return;
if (selection.mask) {
const inverted = new Uint8Array(selection.mask.length);
for (let i = 0; i < selection.mask.length; i++) {
inverted[i] = 255 - selection.mask[i];
let mask = selection.mask;
// If no mask but bounds exist, create a mask from the bounds
if (!mask && selection.bounds) {
const totalPixels = canvasSize.width * canvasSize.height;
mask = new Uint8Array(totalPixels);
const { x, y, width, height } = selection.bounds;
const x0 = Math.max(0, Math.floor(x));
const y0 = Math.max(0, Math.floor(y));
const x1 = Math.min(canvasSize.width, Math.ceil(x + width));
const y1 = Math.min(canvasSize.height, Math.ceil(y + height));
for (let row = y0; row < y1; row++) {
for (let col = x0; col < x1; col++) {
mask[row * canvasSize.width + col] = 255;
}
}
set({ selection: { ...selection, mask: inverted } });
}
if (!mask) return;
const inverted = new Uint8Array(mask.length);
for (let i = 0; i < mask.length; i++) {
inverted[i] = 255 - mask[i];
}
set({ selection: { ...selection, mask: inverted } });
},
// Crop
setCropState: (state) => set({ cropState: state, isCropping: state !== null }),
applyCrop: () => {
const { cropState, objects } = get();
const { cropState, objects, sourceImageUrl } = get();
if (!cropState) return;
// Crop the source image via an offscreen canvas
if (sourceImageUrl) {
const img = new Image();
img.onload = () => {
const offscreen = document.createElement("canvas");
offscreen.width = cropState.width;
offscreen.height = cropState.height;
const ctx = offscreen.getContext("2d");
if (ctx) {
ctx.drawImage(img, -cropState.x, -cropState.y);
const croppedUrl = offscreen.toDataURL("image/png");
const oldUrl = get().sourceImageUrl;
if (oldUrl?.startsWith("blob:")) {
URL.revokeObjectURL(oldUrl);
}
set({ sourceImageUrl: croppedUrl });
}
};
img.src = sourceImageUrl;
}
set({
canvasSize: { width: cropState.width, height: cropState.height },
sourceImageSize: { width: cropState.width, height: cropState.height },
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
(attrs as { x: number }).x -= cropState.x;
}
if ("y" in attrs) {
(attrs as { y: number }).y -= cropState.y;
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] -= cropState.x;
pts[i + 1] -= cropState.y;
}
(attrs as { points: number[] }).points = pts;
} else {
if ("x" in attrs) {
(attrs as { x: number }).x -= cropState.x;
}
if ("y" in attrs) {
(attrs as { y: number }).y -= cropState.y;
}
}
return { ...obj, attrs } as CanvasObject;
}),
@@ -818,10 +1026,18 @@ export const useEditorStore = create<EditorState>()(
},
cutObjects: () => {
const { objects, selectedObjectIds } = get();
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
set({ clipboard: selected });
get().removeObjects(selectedObjectIds);
const state = get();
const selected = state.objects.filter((o) => state.selectedObjectIds.includes(o.id));
if (!selected.length) return;
const idSet = new Set(state.selectedObjectIds);
set({
clipboard: selected,
objects: state.objects.filter((o) => !idSet.has(o.id)),
selectedObjectIds: [],
isDirty: true,
lastAction: "Cut",
_historyVersion: state._historyVersion + 1,
});
},
pasteObjects: () => {
@@ -931,6 +1147,54 @@ export const useEditorStore = create<EditorState>()(
setPixelBrushStrength: (strength) =>
set({ pixelBrushStrength: Math.max(1, Math.min(100, strength)) }),
// History commit (for operations like nudge that use updateObject
// but still need an undo point)
commitHistory: (action) => {
set({
lastAction: action,
_historyVersion: get()._historyVersion + 1,
});
},
// Batch nudge: move multiple objects and create a history entry
batchNudge: (objectIds, dx, dy) => {
const idSet = new Set(objectIds);
set({
objects: get().objects.map((obj) => {
if (!idSet.has(obj.id)) return obj;
const attrs = { ...obj.attrs };
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
for (let i = 0; i < pts.length; i += 2) {
pts[i] += dx;
pts[i + 1] += dy;
}
(attrs as { points: number[] }).points = pts;
} else {
if ("x" in attrs) {
(attrs as unknown as Record<string, number>).x += dx;
}
if ("y" in attrs) {
(attrs as unknown as Record<string, number>).y += dy;
}
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
lastAction: "Nudge",
_historyVersion: get()._historyVersion + 1,
});
},
// Layer thumbnail update (actual generation happens in the canvas component)
updateLayerThumbnail: (layerId, thumbnailDataUrl) => {
set({
layers: get().layers.map((l) =>
l.id === layerId ? { ...l, thumbnail: thumbnailDataUrl } : l,
),
});
},
// Right panel
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),