feat: wire editor options bar, right panel, and canvas rendering

This commit is contained in:
SnapOtter
2026-05-07 09:12:03 +08:00
parent f0941e4aec
commit 791bc2d3ce
3 changed files with 378 additions and 11 deletions
@@ -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 (
<Line
id={obj.id}
points={a.points}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
tension={a.tension}
lineCap={a.lineCap}
lineJoin={a.lineJoin}
opacity={a.opacity}
globalCompositeOperation={
a.globalCompositeOperation as "source-over" | "destination-out" | undefined
}
shadowBlur={a.shadowBlur}
shadowColor={a.shadowColor}
shadowOffsetX={a.shadowOffsetX}
shadowOffsetY={a.shadowOffsetY}
/>
);
}
case "rect": {
const a = obj.attrs;
return (
<Rect
id={obj.id}
x={a.x}
y={a.y}
width={a.width}
height={a.height}
fill={a.fill}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
cornerRadius={a.cornerRadius}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
case "ellipse": {
const a = obj.attrs;
return (
<Ellipse
id={obj.id}
x={a.x}
y={a.y}
radiusX={a.radiusX}
radiusY={a.radiusY}
fill={a.fill}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
case "text": {
const a = obj.attrs;
return (
<Text
id={obj.id}
x={a.x}
y={a.y}
text={a.text}
fontFamily={a.fontFamily}
fontSize={a.fontSize}
fontStyle={a.fontStyle}
textDecoration={a.textDecoration}
align={a.align}
fill={a.fill}
lineHeight={a.lineHeight}
letterSpacing={a.letterSpacing}
width={a.width}
height={a.height}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
case "arrow": {
const a = obj.attrs;
return (
<Arrow
id={obj.id}
points={a.points}
fill={a.fill}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
pointerLength={a.pointerLength}
pointerWidth={a.pointerWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
case "polygon": {
const a = obj.attrs;
return (
<RegularPolygon
id={obj.id}
x={a.x}
y={a.y}
sides={a.sides}
radius={a.radius}
fill={a.fill}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
case "star": {
const a = obj.attrs;
return (
<Star
id={obj.id}
x={a.x}
y={a.y}
numPoints={a.numPoints}
innerRadius={a.innerRadius}
outerRadius={a.outerRadius}
fill={a.fill}
stroke={a.stroke}
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
/>
);
}
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<Konva.Stage | null>) {
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<MouseEvent>) => void;
handleMouseMove: (e: Konva.KonvaEventObject<MouseEvent>) => void;
handleMouseUp: (e: Konva.KonvaEventObject<MouseEvent>) => 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<HTMLDivElement>(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<string, CanvasObject[]>();
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<MouseEvent>) => {
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<MouseEvent>) => {
// 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<MouseEvent>) => {
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}
>
<Layer>{/* Canvas objects are rendered here by tool components */}</Layer>
{/* Render objects grouped by layer */}
<Layer>
{layers.map((layer) => {
const layerObjects = objectsByLayer.get(layer.id) ?? [];
if (!layer.visible) return null;
return (
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
{layerObjects.map((obj) => (
<CanvasObjectRenderer key={obj.id} obj={obj} />
))}
</Group>
);
})}
{/* Move tool transformer */}
{activeTool === "move" && (
<MoveToolTransformer transformerRef={moveTool.transformerRef} />
)}
</Layer>
{/* Grid overlay layer (Feature 49) - non-interactive */}
{(gridVisible || zoom >= 8) && (
<Layer listening={false}>
@@ -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 (
<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 capitalize">
{activeTool.replace(/-/g, " ").replace(/^shape /, "")}
</span>
<div className="h-4 w-px bg-border" />
{/* Tool-specific option components are rendered here by each agent */}
<div id="editor-options-content" className="flex items-center gap-2 flex-1" />
<div className="flex items-center gap-2 flex-1">
{OptionsComponent && <OptionsComponent />}
</div>
</div>
);
}
@@ -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 (
<div className="flex flex-col w-[280px] bg-card border-l border-border">
{/* Navigator always visible when image loaded */}
{sourceImageUrl && <NavigatorPanel />}
{/* Tabs */}
<div className="flex items-center border-b border-border">
{TABS.map((tab) => (
<button
@@ -57,12 +66,18 @@ export function EditorRightPanel() {
<ChevronRight size={14} />
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-2">
{activeTab === "layers" && <LayersPanel />}
{activeTab !== "layers" && <div id={`editor-panel-${activeTab}`} />}
{activeTab === "adjustments" && <AdjustmentsPanel />}
{activeTab === "history" && <HistoryPanel />}
</div>
{/* Color panel always visible at bottom */}
<div className="border-t border-border">
<ColorPanel />
</div>
{/* Color panel always visible at bottom (Agent 5) */}
<div id="editor-color-panel" className="border-t border-border" />
</div>
);
}