import type Konva from "konva"; import { useCallback, useEffect, useRef, useState } from "react"; import { Transformer } from "react-konva"; import { useEditorStore } from "@/stores/editor-store"; // --------------------------------------------------------------------------- // Transform state -- position/size/rotation for the options bar // --------------------------------------------------------------------------- export interface TransformValues { x: number; y: number; width: number; height: number; rotation: number; } // --------------------------------------------------------------------------- // Hook: useTransformTool // --------------------------------------------------------------------------- export interface TransformToolApi { transformerRef: React.RefObject; isTransforming: boolean; values: TransformValues; lockedAspect: boolean; setLockedAspect: (v: boolean) => void; activate: () => void; applyTransform: () => void; cancelTransform: () => void; setValues: (v: Partial) => void; flipHorizontal: () => void; flipVertical: () => void; } export function useTransformTool(): TransformToolApi { const transformerRef = useRef(null); const [isTransforming, setIsTransforming] = useState(false); const [lockedAspect, setLockedAspect] = useState(false); const [values, setValuesState] = useState({ x: 0, y: 0, width: 0, height: 0, rotation: 0, }); const preTransformRef = useRef(null); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const objects = useEditorStore((s) => s.objects); const updateObject = useEditorStore((s) => s.updateObject); const setTool = useEditorStore((s) => s.setTool); // Read values from selected object(s) useEffect(() => { if (!isTransforming || selectedObjectIds.length === 0) return; const obj = objects.find((o) => o.id === selectedObjectIds[0]); if (!obj) return; const a = obj.attrs as unknown as Record; const v: TransformValues = { x: (a.x as number) ?? 0, y: (a.y as number) ?? 0, width: (a.width as number) ?? 0, height: (a.height as number) ?? 0, rotation: (a.rotation as number) ?? 0, }; setValuesState(v); }, [isTransforming, selectedObjectIds, objects]); // Attach transformer useEffect(() => { const tr = transformerRef.current; if (!tr || !isTransforming) 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(); }, [isTransforming, selectedObjectIds]); const activate = useCallback(() => { if (selectedObjectIds.length === 0) return; setIsTransforming(true); // Store pre-transform state for cancel const obj = objects.find((o) => o.id === selectedObjectIds[0]); if (obj) { const a = obj.attrs as unknown as Record; preTransformRef.current = { x: (a.x as number) ?? 0, y: (a.y as number) ?? 0, width: (a.width as number) ?? 0, height: (a.height as number) ?? 0, rotation: (a.rotation as number) ?? 0, }; } }, [selectedObjectIds, objects]); const applyTransform = useCallback(() => { setIsTransforming(false); preTransformRef.current = null; setTool("move"); }, [setTool]); const cancelTransform = useCallback(() => { // Restore pre-transform state if (preTransformRef.current && selectedObjectIds.length > 0) { const prev = preTransformRef.current; for (const id of selectedObjectIds) { updateObject(id, { x: prev.x, y: prev.y, width: prev.width, height: prev.height, rotation: prev.rotation, }); } } setIsTransforming(false); preTransformRef.current = null; setTool("move"); }, [selectedObjectIds, updateObject, setTool]); const setValues = useCallback( (v: Partial) => { setValuesState((prev) => { const next = { ...prev, ...v }; // If aspect is locked, derive height from width ratio or vice versa if (lockedAspect && prev.width > 0 && prev.height > 0) { const ratio = prev.width / prev.height; if (v.width !== undefined && v.height === undefined) { next.height = next.width / ratio; } else if (v.height !== undefined && v.width === undefined) { next.width = next.height * ratio; } } // Apply to selected objects for (const id of selectedObjectIds) { updateObject(id, { x: next.x, y: next.y, width: next.width, height: next.height, rotation: next.rotation, }); } return next; }); }, [selectedObjectIds, updateObject, lockedAspect], ); const flipHorizontal = useCallback(() => { for (const id of selectedObjectIds) { const obj = objects.find((o) => o.id === id); if (!obj) continue; const a = obj.attrs as unknown as Record; const currentScale = (a.scaleX as number) ?? 1; updateObject(id, { scaleX: -currentScale } as Record); } }, [selectedObjectIds, objects, updateObject]); const flipVertical = useCallback(() => { for (const id of selectedObjectIds) { const obj = objects.find((o) => o.id === id); if (!obj) continue; const a = obj.attrs as unknown as Record; const currentScale = (a.scaleY as number) ?? 1; updateObject(id, { scaleY: -currentScale } as Record); } }, [selectedObjectIds, objects, updateObject]); return { transformerRef, isTransforming, values, lockedAspect, setLockedAspect, activate, applyTransform, cancelTransform, setValues, flipHorizontal, flipVertical, }; } // --------------------------------------------------------------------------- // TransformToolTransformer -- Konva Transformer for free transform mode // --------------------------------------------------------------------------- export function TransformToolTransformer({ transformerRef, onTransformEnd, }: { transformerRef: React.RefObject; onTransformEnd?: (e: Konva.KonvaEventObject) => void; }) { const handleTransformEnd = useCallback( (e: Konva.KonvaEventObject) => { const node = e.target; const id = node.id(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); const newW = Math.max(1, node.width() * scaleX); const newH = Math.max(1, node.height() * scaleY); // Reset scale on the Konva node node.scaleX(1); node.scaleY(1); node.width(newW); node.height(newH); // Directly update the store with the new attributes if (id) { 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 = newW / 2; newAttrs.radiusY = newH / 2; } else if (obj?.type === "polygon") { // Convert width/height to radius for polygons newAttrs.radius = (newW + newH) / 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 = newW; newAttrs.height = newH; } useEditorStore.getState().updateObject(id, newAttrs); } onTransformEnd?.(e); }, [onTransformEnd], ); return ( ); }