From 155783ca8695ca18c0f0f7d6cafc55a4fa515097 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:08:32 +0800 Subject: [PATCH] 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. --- .../editor/common/custom-cursor.tsx | 87 ++++++++++ .../components/editor/common/icon-button.tsx | 52 ++++++ .../src/components/editor/editor-canvas.tsx | 161 ++++++++++++++++++ apps/web/src/hooks/use-canvas-zoom.ts | 108 ++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 apps/web/src/components/editor/common/custom-cursor.tsx create mode 100644 apps/web/src/components/editor/common/icon-button.tsx create mode 100644 apps/web/src/components/editor/editor-canvas.tsx create mode 100644 apps/web/src/hooks/use-canvas-zoom.ts diff --git a/apps/web/src/components/editor/common/custom-cursor.tsx b/apps/web/src/components/editor/common/custom-cursor.tsx new file mode 100644 index 00000000..e6d364f5 --- /dev/null +++ b/apps/web/src/components/editor/common/custom-cursor.tsx @@ -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 = { + 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([ + "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; +} + +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 ( +
+ ); +} diff --git a/apps/web/src/components/editor/common/icon-button.tsx b/apps/web/src/components/editor/common/icon-button.tsx new file mode 100644 index 00000000..04e503a1 --- /dev/null +++ b/apps/web/src/components/editor/common/icon-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx new file mode 100644 index 00000000..1da80667 --- /dev/null +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -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(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) => { + 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 ( +
+ + {/* Canvas objects are rendered here by tool components */} + {/* Grid overlay layer (Feature 49) - non-interactive */} + {(gridVisible || zoom >= 8) && ( + + = 8} + /> + + )} + + +
+ ); +} + +function GridOverlay({ + canvasWidth, + canvasHeight, + zoom, + showGrid, + showPixelGrid, +}: { + canvasWidth: number; + canvasHeight: number; + zoom: number; + showGrid: boolean; + showPixelGrid: boolean; +}) { + return ( + { + 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); + }} + /> + ); +} diff --git a/apps/web/src/hooks/use-canvas-zoom.ts b/apps/web/src/hooks/use-canvas-zoom.ts new file mode 100644 index 00000000..91ac3e79 --- /dev/null +++ b/apps/web/src/hooks/use-canvas-zoom.ts @@ -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(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(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) => { + 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 }; +}