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 }; +}