import type Konva from "konva"; import { useCallback, useEffect, useRef } from "react"; import { Transformer } from "react-konva"; import type { SmartGuide } from "@/components/editor/common/smart-guides"; import { useEditorStore } from "@/stores/editor-store"; // --------------------------------------------------------------------------- // Smart guide calculation // --------------------------------------------------------------------------- function findSmartGuides( node: Konva.Node, allNodes: Konva.Node[], canvasWidth: number, canvasHeight: number, threshold: number, ): SmartGuide[] { const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined }); const guides: SmartGuide[] = []; const dragEdges = { left: box.x, right: box.x + box.width, centerX: box.x + box.width / 2, top: box.y, bottom: box.y + box.height, centerY: box.y + box.height / 2, }; // Canvas edges + center const canvasSnaps = [ { pos: 0, type: "canvas" as const, orient: "vertical" as const }, { pos: canvasWidth / 2, type: "canvas" as const, orient: "vertical" as const, }, { pos: canvasWidth, type: "canvas" as const, orient: "vertical" as const }, { pos: 0, type: "canvas" as const, orient: "horizontal" as const }, { pos: canvasHeight / 2, type: "canvas" as const, orient: "horizontal" as const, }, { pos: canvasHeight, type: "canvas" as const, orient: "horizontal" as const, }, ]; for (const snap of canvasSnaps) { if (snap.orient === "vertical") { for (const edge of [dragEdges.left, dragEdges.centerX, dragEdges.right]) { if (Math.abs(edge - snap.pos) < threshold) { guides.push({ orientation: "vertical", position: snap.pos, type: snap.type, }); } } } else { for (const edge of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) { if (Math.abs(edge - snap.pos) < threshold) { guides.push({ orientation: "horizontal", position: snap.pos, type: snap.type, }); } } } } // Other objects for (const other of allNodes) { if (other === node) continue; const ob = other.getClientRect({ relativeTo: other.getParent() ?? undefined, }); const targetEdges = { left: ob.x, right: ob.x + ob.width, centerX: ob.x + ob.width / 2, top: ob.y, bottom: ob.y + ob.height, centerY: ob.y + ob.height / 2, }; for (const edgeVal of [targetEdges.left, targetEdges.centerX, targetEdges.right]) { for (const dragVal of [dragEdges.left, dragEdges.centerX, dragEdges.right]) { if (Math.abs(dragVal - edgeVal) < threshold) { guides.push({ orientation: "vertical", position: edgeVal, type: dragVal === dragEdges.centerX ? "center" : "edge", }); } } } for (const edgeVal of [targetEdges.top, targetEdges.centerY, targetEdges.bottom]) { for (const dragVal of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) { if (Math.abs(dragVal - edgeVal) < threshold) { guides.push({ orientation: "horizontal", position: edgeVal, type: dragVal === dragEdges.centerY ? "center" : "edge", }); } } } } return guides; } function snapPosition( pos: { x: number; y: number }, guides: SmartGuide[], box: { width: number; height: number }, threshold: number, ): { x: number; y: number } { let { x, y } = pos; for (const g of guides) { if (g.orientation === "vertical") { if (Math.abs(x - g.position) < threshold) x = g.position; else if (Math.abs(x + box.width / 2 - g.position) < threshold) x = g.position - box.width / 2; else if (Math.abs(x + box.width - g.position) < threshold) x = g.position - box.width; } else { if (Math.abs(y - g.position) < threshold) y = g.position; else if (Math.abs(y + box.height / 2 - g.position) < threshold) y = g.position - box.height / 2; else if (Math.abs(y + box.height - g.position) < threshold) y = g.position - box.height; } } return { x, y }; } // --------------------------------------------------------------------------- // Exported helpers for alignment // --------------------------------------------------------------------------- export function alignObjects( direction: | "left" | "center-h" | "right" | "top" | "center-v" | "bottom" | "distribute-h" | "distribute-v", objectIds: string[], objects: { id: string; attrs: Record }[], updateObject: (id: string, attrs: Record) => void, canvasSize?: { width: number; height: number }, ): void { const selected = objects.filter((o) => objectIds.includes(o.id)); if (selected.length === 0) return; if (selected.length < 3 && direction.startsWith("distribute")) return; const bounds = selected.map((o) => ({ id: o.id, x: (o.attrs.x as number) ?? 0, y: (o.attrs.y as number) ?? 0, w: (o.attrs.width as number) ?? 0, h: (o.attrs.height as number) ?? 0, })); // Single object: align relative to canvas bounds if (selected.length === 1 && canvasSize) { const b = bounds[0]; switch (direction) { case "left": updateObject(b.id, { x: 0 }); break; case "center-h": updateObject(b.id, { x: canvasSize.width / 2 - b.w / 2 }); break; case "right": updateObject(b.id, { x: canvasSize.width - b.w }); break; case "top": updateObject(b.id, { y: 0 }); break; case "center-v": updateObject(b.id, { y: canvasSize.height / 2 - b.h / 2 }); break; case "bottom": updateObject(b.id, { y: canvasSize.height - b.h }); break; } return; } if (selected.length < 2) return; switch (direction) { case "left": { const minX = Math.min(...bounds.map((b) => b.x)); for (const b of bounds) updateObject(b.id, { x: minX }); break; } case "center-h": { const minX = Math.min(...bounds.map((b) => b.x)); const maxX = Math.max(...bounds.map((b) => b.x + b.w)); const center = (minX + maxX) / 2; for (const b of bounds) updateObject(b.id, { x: center - b.w / 2 }); break; } case "right": { const maxX = Math.max(...bounds.map((b) => b.x + b.w)); for (const b of bounds) updateObject(b.id, { x: maxX - b.w }); break; } case "top": { const minY = Math.min(...bounds.map((b) => b.y)); for (const b of bounds) updateObject(b.id, { y: minY }); break; } case "center-v": { const minY = Math.min(...bounds.map((b) => b.y)); const maxY = Math.max(...bounds.map((b) => b.y + b.h)); const center = (minY + maxY) / 2; for (const b of bounds) updateObject(b.id, { y: center - b.h / 2 }); break; } case "bottom": { const maxY = Math.max(...bounds.map((b) => b.y + b.h)); for (const b of bounds) updateObject(b.id, { y: maxY - b.h }); break; } case "distribute-h": { const sorted = [...bounds].sort((a, b) => a.x - b.x); const totalW = sorted.reduce((s, b) => s + b.w, 0); const minX = sorted[0].x; const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].w; const gap = (maxX - minX - totalW) / (sorted.length - 1); let cx = minX; for (const b of sorted) { updateObject(b.id, { x: cx }); cx += b.w + gap; } break; } case "distribute-v": { const sorted = [...bounds].sort((a, b) => a.y - b.y); const totalH = sorted.reduce((s, b) => s + b.h, 0); const minY = sorted[0].y; const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].h; const gap = (maxY - minY - totalH) / (sorted.length - 1); let cy = minY; for (const b of sorted) { updateObject(b.id, { y: cy }); cy += b.h + gap; } break; } } } // --------------------------------------------------------------------------- // Hook: useMoveTool // --------------------------------------------------------------------------- export interface MoveToolApi { transformerRef: React.RefObject; smartGuides: SmartGuide[]; onSelect: (e: Konva.KonvaEventObject) => void; onStageClick: (e: Konva.KonvaEventObject) => void; onDragStart: (e: Konva.KonvaEventObject) => void; onDragMove: (e: Konva.KonvaEventObject) => void; onDragEnd: (e: Konva.KonvaEventObject) => void; onTransformEnd: (e: Konva.KonvaEventObject) => void; nudge: (dx: number, dy: number) => void; } export function useMoveTool(): MoveToolApi { const transformerRef = useRef(null); const smartGuidesRef = useRef([]); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects); const updateObject = useEditorStore((s) => s.updateObject); const canvasSize = useEditorStore((s) => s.canvasSize); const snapToGuides = useEditorStore((s) => s.snappingEnabled); // Attach transformer to selected nodes useEffect(() => { const tr = transformerRef.current; if (!tr) return; const stage = tr.getStage(); if (!stage) return; const nodes = selectedObjectIds .map((id) => stage.findOne(`#${id}`)) .filter(Boolean) as Konva.Node[]; tr.nodes(nodes); tr.getLayer()?.batchDraw(); }, [selectedObjectIds]); const onSelect = useCallback( (e: Konva.KonvaEventObject) => { const target = e.target; const id = target.id(); if (!id) return; if (e.evt.shiftKey) { // Toggle multi-select if (selectedObjectIds.includes(id)) { setSelectedObjects(selectedObjectIds.filter((i) => i !== id)); } else { setSelectedObjects([...selectedObjectIds, id]); } } else { setSelectedObjects([id]); } }, [selectedObjectIds, setSelectedObjects], ); const onStageClick = useCallback( (e: Konva.KonvaEventObject) => { // Clicked on stage background - deselect if (e.target === e.target.getStage()) { setSelectedObjects([]); } }, [setSelectedObjects], ); const onDragStart = useCallback((_e: Konva.KonvaEventObject) => { // No-op -- selection already handled by onClick }, []); const onDragMove = useCallback( (e: Konva.KonvaEventObject) => { const node = e.target; if (!snapToGuides) return; const stage = node.getStage(); if (!stage) return; const allNodes = stage .find("Rect, Ellipse, Text, Image, Line, Arrow, RegularPolygon, Star") .filter((n) => n.id() && n.id() !== node.id()); const guides = findSmartGuides(node, allNodes, canvasSize.width, canvasSize.height, 5); smartGuidesRef.current = guides; if (guides.length > 0) { const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined, }); const snapped = snapPosition( { x: node.x(), y: node.y() }, guides, { width: box.width, height: box.height }, 5, ); node.position(snapped); } }, [canvasSize, snapToGuides], ); const onDragEnd = useCallback( (e: Konva.KonvaEventObject) => { const node = e.target; smartGuidesRef.current = []; updateObject(node.id(), { x: node.x(), y: node.y(), }); }, [updateObject], ); const onTransformEnd = useCallback( (e: Konva.KonvaEventObject) => { const node = e.target; const id = node.id(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Normalize scale into absolute dimensions const newWidth = Math.max(1, node.width() * scaleX); const newHeight = Math.max(1, node.height() * scaleY); node.scaleX(1); node.scaleY(1); // Look up the object type to write the correct attributes const obj = useEditorStore.getState().objects.find((o) => o.id === id); const newAttrs: Record = { x: node.x(), y: node.y(), rotation: node.rotation(), }; if (obj?.type === "ellipse") { // Convert width/height to radiusX/radiusY for ellipses newAttrs.radiusX = newWidth / 2; newAttrs.radiusY = newHeight / 2; } else if (obj?.type === "polygon") { // Convert to radius for polygons (average of width/height / 2) newAttrs.radius = (newWidth + newHeight) / 4; } else if (obj?.type === "star") { // Scale outerRadius and innerRadius proportionally for stars const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number }; const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2; newAttrs.outerRadius = starAttrs.outerRadius * avgScale; newAttrs.innerRadius = starAttrs.innerRadius * avgScale; } else { // Rectangles, images, text: use width/height directly newAttrs.width = newWidth; newAttrs.height = newHeight; } updateObject(id, newAttrs); }, [updateObject], ); const nudge = useCallback( (dx: number, dy: number) => { for (const id of selectedObjectIds) { const obj = useEditorStore.getState().objects.find((o) => o.id === id); if (!obj) continue; const attrs = obj.attrs as unknown as Record; updateObject(id, { x: ((attrs.x as number) ?? 0) + dx, y: ((attrs.y as number) ?? 0) + dy, }); } }, [selectedObjectIds, updateObject], ); return { transformerRef, smartGuides: smartGuidesRef.current, onSelect, onStageClick, onDragStart, onDragMove, onDragEnd, onTransformEnd, nudge, }; } // --------------------------------------------------------------------------- // MoveToolTransformer -- Konva Transformer component // --------------------------------------------------------------------------- export function MoveToolTransformer({ transformerRef, }: { transformerRef: React.RefObject; }) { return ( ); }