From 791bc2d3ce669e455c9167f1b81ea05959d69bc0 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 09:12:03 +0800 Subject: [PATCH] feat: wire editor options bar, right panel, and canvas rendering --- .../src/components/editor/editor-canvas.tsx | 301 +++++++++++++++++- .../components/editor/editor-options-bar.tsx | 67 +++- .../components/editor/editor-right-panel.tsx | 21 +- 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx index 1da80667..bbf86fbf 100644 --- a/apps/web/src/components/editor/editor-canvas.tsx +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -1,11 +1,32 @@ // 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 type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Arrow, + Ellipse, + Group, + Layer, + Line, + Rect, + RegularPolygon, + Shape, + Stage, + Star, + Text, +} from "react-konva"; import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; +import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts"; import { useEditorStore } from "@/stores/editor-store"; +import type { CanvasObject } from "@/types/editor"; import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; +import { useBrushTool } from "./tools/brush-tool"; +import { useEraserTool } from "./tools/eraser-tool"; +import { useFillTool } from "./tools/fill-tool"; +import { useGradientTool } from "./tools/gradient-tool"; +import { MoveToolTransformer, useMoveTool } from "./tools/move-tool"; +import { useShapeTool } from "./tools/shape-tool"; const CHECKERBOARD_SIZE = 20; const CHECKERBOARD_CSS = ` @@ -15,6 +36,203 @@ const CHECKERBOARD_CSS = ` ) `; +// --------------------------------------------------------------------------- +// Canvas Object Renderer +// --------------------------------------------------------------------------- + +function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) { + switch (obj.type) { + case "line": { + const a = obj.attrs; + return ( + + ); + } + case "rect": { + const a = obj.attrs; + return ( + + ); + } + case "ellipse": { + const a = obj.attrs; + return ( + + ); + } + case "text": { + const a = obj.attrs; + return ( + + ); + } + case "arrow": { + const a = obj.attrs; + return ( + + ); + } + case "polygon": { + const a = obj.attrs; + return ( + + ); + } + case "star": { + const a = obj.attrs; + return ( + + ); + } + case "image": + // Skip image objects for now (complex: requires Konva.Image with useImage) + return null; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Tool handler dispatcher +// --------------------------------------------------------------------------- + +function useActiveToolHandlers(stageRef: React.RefObject) { + const activeTool = useEditorStore((s) => s.activeTool); + + const brushTool = useBrushTool(); + const eraserTool = useEraserTool(); + const shapeTool = useShapeTool(); + const fillTool = useFillTool(stageRef); + const gradientTool = useGradientTool(); + const moveTool = useMoveTool(); + + const handlers = useMemo(() => { + const toolMap: Record< + string, + { + handleMouseDown: (e: Konva.KonvaEventObject) => void; + handleMouseMove: (e: Konva.KonvaEventObject) => void; + handleMouseUp: (e: Konva.KonvaEventObject) => void; + } + > = { + brush: brushTool, + pencil: brushTool, + eraser: eraserTool, + "shape-rect": shapeTool, + "shape-ellipse": shapeTool, + "shape-line": shapeTool, + "shape-arrow": shapeTool, + "shape-polygon": shapeTool, + "shape-star": shapeTool, + fill: fillTool, + gradient: gradientTool, + }; + + return toolMap[activeTool] ?? null; + }, [activeTool, brushTool, eraserTool, shapeTool, fillTool, gradientTool]); + + return { handlers, moveTool }; +} + +// --------------------------------------------------------------------------- +// Main Canvas Component +// --------------------------------------------------------------------------- + export function EditorCanvas() { const containerRef = useRef(null); const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); @@ -25,11 +243,18 @@ export function EditorCanvas() { const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); const setCursorPosition = useEditorStore((s) => s.setCursorPosition); const gridVisible = useEditorStore((s) => s.gridVisible); + const objects = useEditorStore((s) => s.objects); + const layers = useEditorStore((s) => s.layers); + const activeLayerId = useEditorStore((s) => s.activeLayerId); + const activeTool = useEditorStore((s) => s.activeTool); const cursor = useEditorCursor(); + useEditorShortcuts(); - const [stageWidth, setStageWidth] = React.useState(800); - const [stageHeight, setStageHeight] = React.useState(600); + const { handlers, moveTool } = useActiveToolHandlers(stageRef); + + const [stageWidth, setStageWidth] = useState(800); + const [stageHeight, setStageHeight] = useState(600); useEffect(() => { const container = containerRef.current; @@ -50,6 +275,21 @@ export function EditorCanvas() { } }, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]); + // Group objects by layer + const objectsByLayer = useMemo(() => { + const grouped = new Map(); + for (const layer of layers) { + grouped.set(layer.id, []); + } + for (const obj of objects) { + const existing = grouped.get(obj.layerId); + if (existing) { + existing.push(obj); + } + } + return grouped; + }, [objects, layers]); + const handleMouseMove = useCallback( (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); @@ -59,8 +299,36 @@ export function EditorCanvas() { const x = Math.round((pointer.x - panOffset.x) / zoom); const y = Math.round((pointer.y - panOffset.y) / zoom); setCursorPosition({ x, y }); + + // Forward to active tool + if (handlers) { + handlers.handleMouseMove(e); + } }, - [zoom, panOffset, setCursorPosition], + [zoom, panOffset, setCursorPosition, handlers], + ); + + const handleMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // For move tool, handle stage click to deselect + if (activeTool === "move") { + moveTool.onStageClick(e); + } + + if (handlers) { + handlers.handleMouseDown(e); + } + }, + [handlers, activeTool, moveTool], + ); + + const handleMouseUp = useCallback( + (e: Konva.KonvaEventObject) => { + if (handlers) { + handlers.handleMouseUp(e); + } + }, + [handlers], ); const checkerboardSize = CHECKERBOARD_SIZE * zoom; @@ -86,8 +354,29 @@ export function EditorCanvas() { y={panOffset.y} onWheel={handleWheel} onMouseMove={handleMouseMove} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} > - {/* Canvas objects are rendered here by tool components */} + {/* Render objects grouped by layer */} + + {layers.map((layer) => { + const layerObjects = objectsByLayer.get(layer.id) ?? []; + if (!layer.visible) return null; + return ( + + {layerObjects.map((obj) => ( + + ))} + + ); + })} + + {/* Move tool transformer */} + {activeTool === "move" && ( + + )} + + {/* Grid overlay layer (Feature 49) - non-interactive */} {(gridVisible || zoom >= 8) && ( diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx index f02288ff..0f0c451f 100644 --- a/apps/web/src/components/editor/editor-options-bar.tsx +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -1,17 +1,80 @@ // apps/web/src/components/editor/editor-options-bar.tsx + 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 { FillOptions } from "./options/fill-options"; +import { GradientOptions } from "./options/gradient-options"; +import { MoveOptions } from "./options/move-options"; +import { PixelBrushOptions } from "./options/pixel-brush-options"; +import { SelectionOptions } from "./options/selection-options"; +import { ShapeOptions } from "./options/shape-options"; + +function getOptionsComponent(tool: ToolType): React.ComponentType | null { + switch (tool) { + case "move": + return MoveOptions; + case "marquee-rect": + case "marquee-ellipse": + case "lasso-free": + case "lasso-poly": + case "magic-wand": + return SelectionOptions; + case "crop": + return CropOptions; + case "brush": + case "eraser": + case "pencil": + return BrushOptions; + case "clone-stamp": + return CloneStampOptions; + case "dodge": + case "burn": + case "sponge": + return DodgeBurnOptions; + case "blur-brush": + case "sharpen-brush": + case "smudge": + return PixelBrushOptions; + case "fill": + return FillOptions; + case "gradient": + return GradientOptions; + case "shape-rect": + case "shape-ellipse": + case "shape-line": + case "shape-arrow": + case "shape-polygon": + case "shape-star": + return ShapeOptions; + case "hand": + case "zoom": + case "eyedropper": + case "text": + case "transform": + return null; + default: + return null; + } +} export function EditorOptionsBar() { const activeTool = useEditorStore((s) => s.activeTool); + const OptionsComponent = getOptionsComponent(activeTool); + return (
{activeTool.replace(/-/g, " ").replace(/^shape /, "")}
- {/* Tool-specific option components are rendered here by each agent */} -
+
+ {OptionsComponent && } +
); } diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx index 827d094f..1d36c51d 100644 --- a/apps/web/src/components/editor/editor-right-panel.tsx +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -2,7 +2,11 @@ import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; +import { AdjustmentsPanel } from "./panels/adjustments-panel"; +import { ColorPanel } from "./panels/color-panel"; +import { HistoryPanel } from "./panels/history-panel"; import { LayersPanel } from "./panels/layers-panel"; +import { NavigatorPanel } from "./panels/navigator-panel"; const TABS = [ { id: "layers" as const, label: "Layers" }, @@ -15,6 +19,7 @@ export function EditorRightPanel() { const activeTab = useEditorStore((s) => s.rightPanelTab); const setTab = useEditorStore((s) => s.setRightPanelTab); const togglePanel = useEditorStore((s) => s.toggleRightPanel); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); if (!visible) { return ( @@ -31,6 +36,10 @@ export function EditorRightPanel() { return (
+ {/* Navigator always visible when image loaded */} + {sourceImageUrl && } + + {/* Tabs */}
{TABS.map((tab) => (
+ + {/* Tab content */}
{activeTab === "layers" && } - {activeTab !== "layers" &&
} + {activeTab === "adjustments" && } + {activeTab === "history" && } +
+ + {/* Color panel always visible at bottom */} +
+
- {/* Color panel always visible at bottom (Agent 5) */} -
); }