feat: add canvas system and utility components for image editor

Create canvas zoom hook with smooth Konva.Tween transitions, icon button
component, cursor management with brush overlay, and editor canvas with
checkerboard background, ResizeObserver sizing, and grid overlay.
This commit is contained in:
SnapOtter
2026-05-06 23:08:32 +08:00
parent eb6f3c33ac
commit 155783ca86
4 changed files with 408 additions and 0 deletions
@@ -0,0 +1,87 @@
// apps/web/src/components/editor/common/custom-cursor.tsx
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
const TOOL_CURSORS: Record<ToolType, string> = {
move: "default",
"marquee-rect": "crosshair",
"marquee-ellipse": "crosshair",
"lasso-free": "crosshair",
"lasso-poly": "crosshair",
"magic-wand": "crosshair",
crop: "crosshair",
eyedropper: "crosshair",
brush: "none",
eraser: "none",
pencil: "none",
"clone-stamp": "none",
dodge: "none",
burn: "none",
sponge: "none",
"blur-brush": "none",
"sharpen-brush": "none",
smudge: "none",
fill: "crosshair",
gradient: "crosshair",
"shape-rect": "crosshair",
"shape-ellipse": "crosshair",
"shape-line": "crosshair",
"shape-arrow": "crosshair",
"shape-polygon": "crosshair",
"shape-star": "crosshair",
text: "text",
hand: "grab",
zoom: "zoom-in",
transform: "default",
};
const BRUSH_CURSOR_TOOLS = new Set<ToolType>([
"brush",
"eraser",
"pencil",
"clone-stamp",
"dodge",
"burn",
"sponge",
"blur-brush",
"sharpen-brush",
"smudge",
]);
export function useEditorCursor(): string {
const activeTool = useEditorStore((s) => s.activeTool);
const isSpaceHeld = useEditorStore((s) => s.isSpaceHeld);
if (isSpaceHeld) return "grab";
return TOOL_CURSORS[activeTool] || "default";
}
interface BrushCursorOverlayProps {
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorOverlayProps) {
const activeTool = useEditorStore((s) => s.activeTool);
const brushSize = useEditorStore((s) => s.brushSize);
const zoom = useEditorStore((s) => s.zoom);
const cursorPosition = useEditorStore((s) => s.cursorPosition);
if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null;
const displaySize = brushSize * zoom;
const isEraser = activeTool === "eraser";
return (
<div
className="pointer-events-none absolute z-50"
style={{
left: cursorPosition.x - displaySize / 2,
top: cursorPosition.y - displaySize / 2,
width: displaySize,
height: displaySize,
borderRadius: "50%",
border: isEraser ? "2px dashed currentColor" : "1.5px solid currentColor",
opacity: 0.7,
}}
/>
);
}
@@ -0,0 +1,52 @@
// apps/web/src/components/editor/common/icon-button.tsx
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface IconButtonProps {
icon: LucideIcon;
label: string;
shortcut?: string;
active?: boolean;
disabled?: boolean;
size?: number;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
className?: string;
"data-testid"?: string;
"data-tool"?: string;
"data-tool-active"?: string;
}
export function IconButton({
icon: Icon,
label,
shortcut,
active,
disabled,
size = 18,
onClick,
onContextMenu,
className,
...dataProps
}: IconButtonProps) {
return (
<button
type="button"
title={shortcut ? `${label} (${shortcut})` : label}
aria-label={label}
disabled={disabled}
onClick={onClick}
onContextMenu={onContextMenu}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-md transition-colors",
"hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed",
active && "bg-primary text-primary-foreground hover:bg-primary/90",
!active && "text-muted-foreground",
className,
)}
{...dataProps}
>
<Icon size={size} />
</button>
);
}
@@ -0,0 +1,161 @@
// apps/web/src/components/editor/editor-canvas.tsx
import type Konva from "konva";
import React, { useCallback, useEffect, useRef } from "react";
import { Layer, Shape, Stage } from "react-konva";
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
import { useEditorStore } from "@/stores/editor-store";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
const CHECKERBOARD_SIZE = 20;
const CHECKERBOARD_CSS = `
repeating-conic-gradient(
rgba(128, 128, 128, 0.15) 0% 25%,
transparent 0% 50%
)
`;
export function EditorCanvas() {
const containerRef = useRef<HTMLDivElement>(null);
const { stageRef, handleWheel, fitToScreen } = useCanvasZoom();
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const setCursorPosition = useEditorStore((s) => s.setCursorPosition);
const gridVisible = useEditorStore((s) => s.gridVisible);
const cursor = useEditorCursor();
const [stageWidth, setStageWidth] = React.useState(800);
const [stageHeight, setStageHeight] = React.useState(600);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
setStageWidth(width);
setStageHeight(height);
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (sourceImageUrl && stageWidth > 0 && stageHeight > 0) {
fitToScreen(stageWidth, stageHeight, canvasSize.width, canvasSize.height);
}
}, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]);
const handleMouseMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.round((pointer.x - panOffset.x) / zoom);
const y = Math.round((pointer.y - panOffset.y) / zoom);
setCursorPosition({ x, y });
},
[zoom, panOffset, setCursorPosition],
);
const checkerboardSize = CHECKERBOARD_SIZE * zoom;
return (
<div
ref={containerRef}
className="relative flex-1 overflow-hidden"
style={{
cursor,
background: sourceImageUrl ? CHECKERBOARD_CSS : undefined,
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
}}
data-testid="editor-canvas"
>
<Stage
ref={stageRef}
width={stageWidth}
height={stageHeight}
scaleX={zoom}
scaleY={zoom}
x={panOffset.x}
y={panOffset.y}
onWheel={handleWheel}
onMouseMove={handleMouseMove}
>
<Layer>{/* Canvas objects are rendered here by tool components */}</Layer>
{/* Grid overlay layer (Feature 49) - non-interactive */}
{(gridVisible || zoom >= 8) && (
<Layer listening={false}>
<GridOverlay
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
zoom={zoom}
showGrid={gridVisible}
showPixelGrid={zoom >= 8}
/>
</Layer>
)}
</Stage>
<BrushCursorOverlay containerRef={containerRef} />
</div>
);
}
function GridOverlay({
canvasWidth,
canvasHeight,
zoom,
showGrid,
showPixelGrid,
}: {
canvasWidth: number;
canvasHeight: number;
zoom: number;
showGrid: boolean;
showPixelGrid: boolean;
}) {
return (
<Shape
sceneFunc={(ctx, shape) => {
ctx.beginPath();
if (showGrid) {
const spacing = 50;
ctx.strokeStyle = "rgba(128, 128, 128, 0.15)";
ctx.lineWidth = 1 / zoom;
for (let x = spacing; x < canvasWidth; x += spacing) {
ctx.moveTo(x, 0);
ctx.lineTo(x, canvasHeight);
}
for (let y = spacing; y < canvasHeight; y += spacing) {
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
}
ctx.stroke();
}
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);
}
for (let y = 1; y < canvasHeight; y++) {
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
}
ctx.stroke();
}
ctx.fillStrokeShape(shape);
}}
/>
);
}
+108
View File
@@ -0,0 +1,108 @@
// apps/web/src/hooks/use-canvas-zoom.ts
import Konva from "konva";
import { useCallback, useRef } from "react";
import { useEditorStore } from "@/stores/editor-store";
const ZOOM_SENSITIVITY = 1.1;
const ZOOM_ANIMATION_DURATION = 0.15;
export function useCanvasZoom() {
const stageRef = useRef<Konva.Stage>(null);
const setZoom = useEditorStore((s) => s.setZoom);
const setPanOffset = useEditorStore((s) => s.setPanOffset);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const tweenRef = useRef<Konva.Tween | null>(null);
const animateZoom = useCallback(
(targetZoom: number, targetPos: { x: number; y: number }) => {
const stage = stageRef.current;
if (!stage) {
setZoom(targetZoom);
setPanOffset(targetPos);
return;
}
if (tweenRef.current) {
tweenRef.current.destroy();
}
tweenRef.current = new Konva.Tween({
node: stage,
scaleX: targetZoom,
scaleY: targetZoom,
x: targetPos.x,
y: targetPos.y,
duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut,
onFinish: () => {
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current = null;
},
});
tweenRef.current.play();
},
[setZoom, setPanOffset],
);
const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = stageRef.current;
if (!stage) return;
const isZoom = e.evt.ctrlKey || e.evt.metaKey;
if (isZoom) {
const pointer = stage.getPointerPosition();
if (!pointer) return;
const oldZoom = zoom;
const direction = e.evt.deltaY < 0 ? 1 : -1;
const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY;
const clampedZoom = Math.max(0.01, Math.min(64, newZoom));
const mousePointTo = {
x: (pointer.x - panOffset.x) / oldZoom,
y: (pointer.y - panOffset.y) / oldZoom,
};
const newPos = {
x: pointer.x - mousePointTo.x * clampedZoom,
y: pointer.y - mousePointTo.y * clampedZoom,
};
animateZoom(clampedZoom, newPos);
} else {
const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX;
const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY;
setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy });
}
},
[zoom, panOffset, setPanOffset, animateZoom],
);
const fitToScreen = useCallback(
(viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => {
const scaleX = viewportWidth / imageWidth;
const scaleY = viewportHeight / imageHeight;
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
const offsetX = (viewportWidth - imageWidth * fitZoom) / 2;
const offsetY = (viewportHeight - imageHeight * fitZoom) / 2;
animateZoom(fitZoom, { x: offsetX, y: offsetY });
},
[animateZoom],
);
const zoomTo = useCallback(
(targetZoom: number, viewportWidth: number, viewportHeight: number) => {
const canvasSize = useEditorStore.getState().canvasSize;
const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2;
const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2;
animateZoom(targetZoom, { x: offsetX, y: offsetY });
},
[animateZoom],
);
return { stageRef, handleWheel, fitToScreen, zoomTo };
}