mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -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<ToolType, string> = {
|
||||
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<ToolType>([
|
||||
"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<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50"
|
||||
style={{
|
||||
left: cursorPosition.x - displaySize / 2,
|
||||
top: cursorPosition.y - displaySize / 2,
|
||||
width: displaySize,
|
||||
height: displaySize,
|
||||
borderRadius: "50%",
|
||||
border: isEraser ? "2px dashed currentColor" : "1.5px solid currentColor",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-md transition-colors",
|
||||
"hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
active && "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
!active && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...dataProps}
|
||||
>
|
||||
<Icon size={size} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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<MouseEvent>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{
|
||||
cursor,
|
||||
background: sourceImageUrl ? CHECKERBOARD_CSS : undefined,
|
||||
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
|
||||
}}
|
||||
data-testid="editor-canvas"
|
||||
>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
scaleX={zoom}
|
||||
scaleY={zoom}
|
||||
x={panOffset.x}
|
||||
y={panOffset.y}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<Layer>{/* Canvas objects are rendered here by tool components */}</Layer>
|
||||
{/* Grid overlay layer (Feature 49) - non-interactive */}
|
||||
{(gridVisible || zoom >= 8) && (
|
||||
<Layer listening={false}>
|
||||
<GridOverlay
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
zoom={zoom}
|
||||
showGrid={gridVisible}
|
||||
showPixelGrid={zoom >= 8}
|
||||
/>
|
||||
</Layer>
|
||||
)}
|
||||
</Stage>
|
||||
<BrushCursorOverlay containerRef={containerRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridOverlay({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
zoom,
|
||||
showGrid,
|
||||
showPixelGrid,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
zoom: number;
|
||||
showGrid: boolean;
|
||||
showPixelGrid: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Shape
|
||||
sceneFunc={(ctx, shape) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Konva.Stage>(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<Konva.Tween | null>(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<WheelEvent>) => {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user