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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user