mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: wire editor options bar, right panel, and canvas rendering
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user