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:
SnapOtter
2026-05-06 23:08:32 +08:00
parent eb6f3c33ac
commit 155783ca86
4 changed files with 408 additions and 0 deletions
@@ -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>
);
}