UNPKG

@deckedout/visual-editor

Version:

A flexible visual editor for building interactive canvases with drag-and-drop elements

1 lines 261 kB
{"version":3,"sources":["../src/core/VisualEditor.tsx","../src/core/useEditorState.ts","../src/utils/editorUtils.ts","../src/core/ElementRegistry.ts","../src/elements/TextElement.tsx","../src/utils/snapping.ts","../src/elements/ImageElement.tsx","../src/elements/index.ts","../src/components/Inspector.tsx","../src/ui/input.tsx","../src/lib/utils.ts","../src/ui/label.tsx","../src/ui/slider.tsx","../src/ui/separator.tsx","../src/ui/select.tsx","../src/ui/textarea.tsx","../src/ui/checkbox.tsx","../src/components/LayersPanel.tsx","../src/ui/badge.tsx","../src/ui/scroll-area.tsx","../src/ui/general/TooltipButton.tsx","../src/ui/button.tsx","../src/ui/tooltip.tsx","../src/ui/general/TooltipWrapper.tsx","../src/components/VisualEditorWorkspace.tsx","../src/components/Topbar.tsx","../src/ui/switch.tsx","../src/components/CustomActionRenderer.tsx","../src/components/Canvas.tsx","../src/components/SnapGuides.tsx","../src/components/CentralizedTransformer.tsx","../src/components/Toolbar.tsx","../src/components/AssetPicker.tsx"],"sourcesContent":["/**\n * Visual Editor - Main Editor Component\n *\n * The core editor component that integrates all functionality:\n * - Canvas rendering with Konva\n * - Element management\n * - Selection and transformation\n * - Mode switching\n */\n\nimport React, { useEffect, useCallback, useRef } from \"react\";\nimport { Stage, Layer } from \"react-konva\";\nimport { VisualEditorProps, EditorElement, EditorMode } from \"../types\";\nimport { useEditorState } from \"./useEditorState\";\nimport { ElementRegistry, useElementRegistry } from \"./ElementRegistry\";\nimport { defaultElements } from \"../elements\";\n\n/**\n * Main Visual Editor Component\n */\nexport const VisualEditor: React.FC<VisualEditorProps> = ({\n mode,\n initialData,\n width: propWidth,\n height: propHeight,\n readonly = false,\n onChange,\n onSelectionChange,\n onExport,\n customElements = [],\n showToolbar = true,\n showInspector = true,\n className = \"\",\n style = {},\n}) => {\n const containerRef = useRef<HTMLDivElement>(null);\n const stageRef = useRef<any>(null);\n\n // Initialize editor state\n const {\n state,\n api,\n setCanvasSize,\n setMode: setEditorMode,\n undo,\n redo,\n canUndo,\n canRedo,\n } = useEditorState(mode || null);\n\n // Initialize element registry\n const registry = useElementRegistry([\n ...defaultElements,\n ...(mode?.registeredElements || []),\n ...customElements,\n ]);\n\n // Determine canvas size\n const canvasWidth = propWidth || state.canvasSize.width || mode?.defaultCanvasSize.width || 800;\n const canvasHeight =\n propHeight || state.canvasSize.height || mode?.defaultCanvasSize.height || 600;\n\n // Update canvas size when props change\n useEffect(() => {\n if (propWidth && propHeight) {\n setCanvasSize(propWidth, propHeight);\n }\n }, [propWidth, propHeight, setCanvasSize]);\n\n // Load initial data\n useEffect(() => {\n if (initialData) {\n api.importJSON(initialData);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [initialData]);\n\n // Update mode when it changes\n useEffect(() => {\n if (mode) {\n setEditorMode(mode);\n // Register mode elements\n if (mode.registeredElements) {\n mode.registeredElements.forEach((el) => registry.register(el));\n }\n // Call mode activation hook\n if (mode.onModeActivate) {\n mode.onModeActivate(api);\n }\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [mode]);\n\n // Notify parent of changes\n useEffect(() => {\n if (onChange) {\n const data = api.exportJSON();\n onChange(data);\n }\n }, [state.elements, onChange, api]);\n\n // Notify parent of selection changes\n useEffect(() => {\n if (onSelectionChange) {\n const selected = api.getSelectedElement();\n onSelectionChange(selected);\n }\n }, [state.selectedElementId, onSelectionChange, api]);\n\n // Handle keyboard shortcuts\n useEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n // Undo: Ctrl/Cmd + Z\n if ((e.ctrlKey || e.metaKey) && e.key === \"z\" && !e.shiftKey) {\n e.preventDefault();\n undo();\n }\n // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y\n if (\n ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === \"z\") ||\n ((e.ctrlKey || e.metaKey) && e.key === \"y\")\n ) {\n e.preventDefault();\n redo();\n }\n // Delete: Delete or Backspace\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && !readonly) {\n e.preventDefault();\n const selected = api.getSelectedElement();\n if (selected) {\n api.removeElement(selected.id);\n }\n }\n // Deselect: Escape\n if (e.key === \"Escape\") {\n e.preventDefault();\n api.selectElement(null);\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [undo, redo, readonly, api]);\n\n // Render an element using its registered renderer\n const renderElement = useCallback(\n (element: EditorElement) => {\n const renderer = registry.get(element.type);\n if (!renderer) {\n console.warn(`No renderer found for element type: ${element.type}`);\n return null;\n }\n\n // Get the renderer component\n const RendererComponent = renderer.renderComponent;\n if (!RendererComponent) {\n console.warn(`No renderComponent found for element type: ${element.type}`);\n return null;\n }\n\n const isSelected = state.selectedElementId === element.id;\n\n return (\n <RendererComponent\n key={element.id}\n element={element}\n isSelected={isSelected}\n onSelect={() => !readonly && api.selectElement(element.id)}\n onTransform={(updates) => !readonly && api.updateElement(element.id, updates)}\n />\n );\n },\n [registry, state.selectedElementId, readonly, api]\n );\n\n // Handle click on canvas background (deselect)\n const handleStageClick = useCallback(\n (e: any) => {\n // Clicked on stage - deselect\n if (e.target === e.target.getStage()) {\n api.selectElement(null);\n }\n },\n [api]\n );\n\n // Sort elements by z-index for proper rendering order\n const sortedElements = [...state.elements].sort((a, b) => a.zIndex - b.zIndex);\n\n return (\n <div\n ref={containerRef}\n className={`visual-editor ${className}`}\n style={{\n width: \"100%\",\n height: \"100%\",\n display: \"flex\",\n flexDirection: \"column\",\n overflow: \"hidden\",\n backgroundColor: mode?.backgroundColor || \"#f0f0f0\",\n ...style,\n }}\n >\n {/* Canvas */}\n <div\n style={{\n flex: 1,\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n overflow: \"auto\",\n }}\n >\n <Stage\n ref={stageRef}\n width={canvasWidth}\n height={canvasHeight}\n onClick={handleStageClick}\n onTap={handleStageClick}\n style={{\n backgroundColor: \"#ffffff\",\n boxShadow: \"0 2px 8px rgba(0,0,0,0.1)\",\n }}\n >\n <Layer>{sortedElements.map(renderElement)}</Layer>\n </Stage>\n </div>\n </div>\n );\n};\n","/**\n * Visual Editor - Core State Management\n *\n * Provides the state management logic for the visual editor using useReducer.\n * This hook encapsulates all state mutations and provides a clean API.\n */\n\nimport { useReducer, useCallback, useMemo } from \"react\";\nimport {\n EditorState,\n EditorAction,\n EditorElement,\n EditorAPI,\n EditorMode,\n CanvasExport,\n} from \"../types\";\nimport { \n duplicateElement as duplicateElementUtil,\n generateElementId \n} from \"../utils/editorUtils\";\n\n/**\n * Initial state for the editor\n */\nconst createInitialState = (mode: EditorMode | null): EditorState => ({\n elements: [],\n selectedElementId: null,\n canvasSize: mode?.defaultCanvasSize || { width: 800, height: 600 },\n zoom: 1,\n pan: { x: 0, y: 0 },\n mode,\n history: {\n past: [],\n future: [],\n },\n});\n\n/**\n * State reducer for the editor\n */\nconst editorReducer = (state: EditorState, action: EditorAction): EditorState => {\n switch (action.type) {\n case \"ADD_ELEMENT\": {\n // Normalize element to ensure it has visible and locked properties\n const normalizedElement = {\n ...action.element,\n visible: action.element.visible ?? true,\n locked: action.element.locked ?? false,\n };\n const newElements = [...state.elements, normalizedElement];\n return {\n ...state,\n elements: newElements,\n selectedElementId: normalizedElement.id,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n }\n\n case \"UPDATE_ELEMENT\": {\n const newElements = state.elements.map((el) =>\n el.id === action.id ? { ...el, ...action.updates } : el\n );\n return {\n ...state,\n elements: newElements,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n }\n\n case \"REMOVE_ELEMENT\": {\n const newElements = state.elements.filter((el) => el.id !== action.id);\n return {\n ...state,\n elements: newElements,\n selectedElementId: state.selectedElementId === action.id ? null : state.selectedElementId,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n }\n\n case \"SELECT_ELEMENT\":\n return {\n ...state,\n selectedElementId: action.id,\n };\n\n case \"SET_ELEMENTS\":\n return {\n ...state,\n elements: action.elements,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n\n case \"LOAD_ELEMENTS\":\n // Load elements without recording history (for initial load)\n return {\n ...state,\n elements: action.elements.map((el) => ({\n ...el,\n visible: el.visible ?? true,\n locked: el.locked ?? false,\n })),\n };\n\n case \"REORDER_ELEMENT\": {\n const currentIndex = state.elements.findIndex((el) => el.id === action.elementId);\n if (currentIndex === -1 || currentIndex === action.newIndex) return state;\n\n const newElements = [...state.elements];\n const [element] = newElements.splice(currentIndex, 1);\n newElements.splice(action.newIndex, 0, element);\n\n // Update z-indices to match array indices\n newElements.forEach((el, index) => {\n el.zIndex = index;\n });\n\n return {\n ...state,\n elements: newElements,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n }\n\n case \"SET_CANVAS_SIZE\":\n return {\n ...state,\n canvasSize: {\n width: action.width,\n height: action.height,\n },\n };\n\n case \"SET_ZOOM\":\n return {\n ...state,\n zoom: action.zoom,\n };\n\n case \"SET_PAN\":\n return {\n ...state,\n pan: {\n x: action.x,\n y: action.y,\n },\n };\n\n case \"SET_MODE\":\n return {\n ...state,\n mode: action.mode,\n canvasSize: action.mode.defaultCanvasSize,\n };\n\n case \"CLEAR\":\n return {\n ...state,\n elements: [],\n selectedElementId: null,\n history: {\n past: [...state.history.past, state.elements],\n future: [],\n },\n };\n\n case \"UNDO\": {\n if (state.history.past.length === 0) return state;\n const previous = state.history.past[state.history.past.length - 1];\n const newPast = state.history.past.slice(0, -1);\n return {\n ...state,\n elements: previous,\n history: {\n past: newPast,\n future: [state.elements, ...state.history.future],\n },\n };\n }\n\n case \"REDO\": {\n if (state.history.future.length === 0) return state;\n const next = state.history.future[0];\n const newFuture = state.history.future.slice(1);\n return {\n ...state,\n elements: next,\n history: {\n past: [...state.history.past, state.elements],\n future: newFuture,\n },\n };\n }\n\n default:\n return state;\n }\n};\n\n/**\n * Custom hook for managing editor state\n */\nexport const useEditorState = (initialMode: EditorMode | null = null) => {\n const [state, dispatch] = useReducer(editorReducer, createInitialState(initialMode));\n\n // ============================================================================\n // API Methods\n // ============================================================================\n\n const addElement = useCallback((element: EditorElement) => {\n dispatch({ type: \"ADD_ELEMENT\", element });\n }, []);\n\n const updateElement = useCallback((id: string, updates: Partial<EditorElement>) => {\n dispatch({ type: \"UPDATE_ELEMENT\", id, updates });\n }, []);\n\n const removeElement = useCallback((id: string) => {\n dispatch({ type: \"REMOVE_ELEMENT\", id });\n }, []);\n\n const selectElement = useCallback((id: string | null) => {\n dispatch({ type: \"SELECT_ELEMENT\", id });\n }, []);\n\n const getSelectedElement = useCallback((): EditorElement | null => {\n if (!state.selectedElementId) return null;\n return state.elements.find((el) => el.id === state.selectedElementId) || null;\n }, [state.selectedElementId, state.elements]);\n\n const getAllElements = useCallback((): EditorElement[] => {\n return state.elements;\n }, [state.elements]);\n\n const moveElement = useCallback(\n (id: string, deltaX: number, deltaY: number) => {\n const element = state.elements.find((el) => el.id === id);\n if (!element) return;\n\n updateElement(id, {\n position: {\n x: element.position.x + deltaX,\n y: element.position.y + deltaY,\n },\n });\n },\n [state.elements, updateElement]\n );\n\n const rotateElement = useCallback(\n (id: string, angle: number) => {\n updateElement(id, { rotation: angle });\n },\n [updateElement]\n );\n\n const resizeElement = useCallback(\n (id: string, width: number, height: number) => {\n updateElement(id, {\n size: { width, height },\n });\n },\n [updateElement]\n );\n\n const updateZIndex = useCallback(\n (id: string, zIndex: number) => {\n updateElement(id, { zIndex });\n },\n [updateElement]\n );\n\n const reorderElement = useCallback((id: string, newIndex: number) => {\n dispatch({ type: \"REORDER_ELEMENT\", elementId: id, newIndex });\n }, []);\n\n const exportJSON = useCallback((): CanvasExport => {\n return {\n width: state.canvasSize.width,\n height: state.canvasSize.height,\n elements: state.elements,\n metadata: {\n version: \"1.0.0\",\n mode: state.mode?.name,\n created: new Date().toISOString(),\n },\n };\n }, [state.canvasSize, state.elements, state.mode]);\n\n const importJSON = useCallback((data: CanvasExport) => {\n dispatch({\n type: \"SET_CANVAS_SIZE\",\n width: data.width,\n height: data.height,\n });\n dispatch({ type: \"SET_ELEMENTS\", elements: data.elements });\n }, []);\n\n const clear = useCallback(() => {\n dispatch({ type: \"CLEAR\" });\n }, []);\n\n const setCanvasSize = useCallback((width: number, height: number) => {\n dispatch({ type: \"SET_CANVAS_SIZE\", width, height });\n }, []);\n\n const setMode = useCallback((mode: EditorMode) => {\n dispatch({ type: \"SET_MODE\", mode });\n }, []);\n\n const undo = useCallback(() => {\n dispatch({ type: \"UNDO\" });\n }, []);\n\n const redo = useCallback(() => {\n dispatch({ type: \"REDO\" });\n }, []);\n\n // ============================================================================\n // Copy/Paste/Duplicate Operations\n // ============================================================================\n\n const copyElement = useCallback(\n (id: string | null = null): EditorElement | null => {\n const elementToCopy = id ? state.elements.find((el) => el.id === id) : getSelectedElement();\n\n if (!elementToCopy) return null;\n\n // Return a deep copy for clipboard\n return {\n ...elementToCopy,\n props: { ...elementToCopy.props },\n position: { ...elementToCopy.position },\n size: { ...elementToCopy.size },\n };\n },\n [state.elements, getSelectedElement]\n );\n\n const duplicateElement = useCallback(\n (id: string | null = null, offset = { x: 20, y: 20 }) => {\n const elementToDuplicate = id\n ? state.elements.find((el) => el.id === id)\n : getSelectedElement();\n\n if (!elementToDuplicate) return;\n\n const duplicated = duplicateElementUtil(elementToDuplicate, offset);\n\n addElement(duplicated);\n },\n [state.elements, getSelectedElement, addElement]\n );\n\n const pasteElement = useCallback(\n (copiedElement: EditorElement, offset = { x: 20, y: 20 }) => {\n if (!copiedElement) return;\n\n // Create new element with new ID and offset position\n const pasted: EditorElement = {\n ...copiedElement,\n id: generateElementId(),\n props: { ...copiedElement.props },\n position: {\n x: copiedElement.position.x + offset.x,\n y: copiedElement.position.y + offset.y,\n },\n size: { ...copiedElement.size },\n zIndex: Math.max(...state.elements.map((el) => el.zIndex), 0) + 1,\n };\n\n addElement(pasted);\n },\n [state.elements, addElement]\n );\n\n // ============================================================================\n // Clear state history\n // ============================================================================\n\n const clearHistory = useCallback(() => {\n state.history.past = [];\n state.history.future = [];\n }, []);\n\n // ============================================================================\n // Load elements without history\n // ============================================================================\n\n const loadElements = useCallback((elements: EditorElement[]) => {\n dispatch({ type: \"LOAD_ELEMENTS\", elements });\n }, []);\n\n // ============================================================================\n // Build API Object\n // ============================================================================\n\n const api: EditorAPI = useMemo(\n () => ({\n addElement,\n updateElement,\n removeElement,\n selectElement,\n getSelectedElement,\n getAllElements,\n moveElement,\n rotateElement,\n resizeElement,\n updateZIndex,\n reorderElement,\n exportJSON,\n importJSON,\n clear,\n copyElement,\n duplicateElement,\n pasteElement,\n clearHistory,\n loadElements,\n }),\n [\n addElement,\n updateElement,\n removeElement,\n selectElement,\n getSelectedElement,\n getAllElements,\n moveElement,\n rotateElement,\n resizeElement,\n updateZIndex,\n reorderElement,\n exportJSON,\n importJSON,\n clear,\n copyElement,\n duplicateElement,\n pasteElement,\n clearHistory,\n loadElements,\n ]\n );\n\n return {\n state,\n api,\n // Additional helpers\n setCanvasSize,\n setMode,\n undo,\n redo,\n canUndo: state.history.past.length > 0,\n canRedo: state.history.future.length > 0,\n };\n};\n","/**\n * Visual Editor - Utility Functions\n *\n * Common utility functions used throughout the visual editor.\n */\n\nimport { EditorElement, CanvasExport } from \"../types\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n/**\n * Generate a unique ID for an element\n */\nexport const generateElementId = (): string => {\n return `element-${uuidv4()}`;\n};\n\n/**\n * Create a new element with default values\n */\nexport const createElement = <TProps = any>(\n type: string,\n props: TProps,\n options?: {\n position?: { x: number; y: number };\n size?: { width: number; height: number };\n rotation?: number;\n opacity?: number;\n zIndex?: number;\n visible?: boolean;\n locked?: boolean;\n displayName?: string;\n }\n): EditorElement<TProps> => {\n return {\n id: generateElementId(),\n type,\n position: options?.position || { x: 0, y: 0 },\n size: options?.size || { width: 100, height: 100 },\n rotation: options?.rotation || 0,\n opacity: options?.opacity ?? 1,\n zIndex: options?.zIndex || 0,\n visible: options?.visible ?? true,\n locked: options?.locked ?? false,\n displayName: options?.displayName,\n props,\n };\n};\n\n/**\n * Deep clone an element\n */\nexport const cloneElement = <TProps = any>(\n element: EditorElement<TProps>\n): EditorElement<TProps> => {\n return {\n ...element,\n id: generateElementId(), // New ID for the clone\n props: { ...element.props },\n position: { ...element.position },\n size: { ...element.size },\n };\n};\n\n/**\n * Duplicate an element with a slight offset\n */\nexport const duplicateElement = <TProps = any>(\n element: EditorElement<TProps>,\n offset: { x: number; y: number } = { x: 20, y: 20 }\n): EditorElement<TProps> => {\n const cloned = cloneElement(element);\n return {\n ...cloned,\n position: {\n x: element.position.x + offset.x,\n y: element.position.y + offset.y,\n },\n zIndex: element.zIndex + 1, // Place on top\n };\n};\n\n/**\n * Sort elements by z-index (ascending)\n */\nexport const sortByZIndex = (elements: EditorElement[]): EditorElement[] => {\n return [...elements].sort((a, b) => a.zIndex - b.zIndex);\n};\n\n/**\n * Get the highest z-index among elements\n */\nexport const getMaxZIndex = (elements: EditorElement[]): number => {\n if (elements.length === 0) return 0;\n return Math.max(...elements.map((el) => el.zIndex));\n};\n\n/**\n * Bring an element to front\n */\nexport const bringToFront = (elements: EditorElement[], elementId: string): EditorElement[] => {\n const maxZ = getMaxZIndex(elements);\n return elements.map((el) => (el.id === elementId ? { ...el, zIndex: maxZ + 1 } : el));\n};\n\n/**\n * Send an element to back\n */\nexport const sendToBack = (elements: EditorElement[], elementId: string): EditorElement[] => {\n const minZ = Math.min(...elements.map((el) => el.zIndex));\n return elements.map((el) => (el.id === elementId ? { ...el, zIndex: minZ - 1 } : el));\n};\n\n/**\n * Check if two rectangles overlap\n */\nexport const checkOverlap = (\n rect1: { x: number; y: number; width: number; height: number },\n rect2: { x: number; y: number; width: number; height: number }\n): boolean => {\n return !(\n rect1.x + rect1.width < rect2.x ||\n rect2.x + rect2.width < rect1.x ||\n rect1.y + rect1.height < rect2.y ||\n rect2.y + rect2.height < rect1.y\n );\n};\n\n/**\n * Check if a point is inside a rectangle\n */\nexport const pointInRect = (\n point: { x: number; y: number },\n rect: { x: number; y: number; width: number; height: number }\n): boolean => {\n return (\n point.x >= rect.x &&\n point.x <= rect.x + rect.width &&\n point.y >= rect.y &&\n point.y <= rect.y + rect.height\n );\n};\n\n/**\n * Snap a value to grid\n */\nexport const snapToGrid = (value: number, gridSize: number): number => {\n return Math.round(value / gridSize) * gridSize;\n};\n\n/**\n * Snap position to grid\n */\nexport const snapPositionToGrid = (\n position: { x: number; y: number },\n gridSize: number\n): { x: number; y: number } => {\n return {\n x: snapToGrid(position.x, gridSize),\n y: snapToGrid(position.y, gridSize),\n };\n};\n\n/**\n * Constrain a value between min and max\n */\nexport const clamp = (value: number, min: number, max: number): number => {\n return Math.min(Math.max(value, min), max);\n};\n\n/**\n * Constrain position within canvas bounds\n */\nexport const constrainToCanvas = (\n position: { x: number; y: number },\n size: { width: number; height: number },\n canvasSize: { width: number; height: number }\n): { x: number; y: number } => {\n return {\n x: clamp(position.x, 0, canvasSize.width - size.width),\n y: clamp(position.y, 0, canvasSize.height - size.height),\n };\n};\n\n/**\n * Calculate bounding box for rotated rectangle\n */\nexport const getRotatedBoundingBox = (\n x: number,\n y: number,\n width: number,\n height: number,\n rotation: number\n): { x: number; y: number; width: number; height: number } => {\n const rad = (rotation * Math.PI) / 180;\n const cos = Math.abs(Math.cos(rad));\n const sin = Math.abs(Math.sin(rad));\n\n const newWidth = width * cos + height * sin;\n const newHeight = width * sin + height * cos;\n\n return {\n x: x - (newWidth - width) / 2,\n y: y - (newHeight - height) / 2,\n width: newWidth,\n height: newHeight,\n };\n};\n\n/**\n * Export canvas to JSON string\n */\nexport const exportToJSON = (data: CanvasExport): string => {\n return JSON.stringify(data, null, 2);\n};\n\n/**\n * Import canvas from JSON string\n */\nexport const importFromJSON = (json: string): CanvasExport => {\n try {\n const data = JSON.parse(json);\n // Validate basic structure\n if (!data.width || !data.height || !Array.isArray(data.elements)) {\n throw new Error(\"Invalid canvas data structure\");\n }\n\n // Normalize elements to ensure they have visible and locked properties\n const normalizedElements = data.elements.map((element: EditorElement) => ({\n ...element,\n visible: element.visible ?? true,\n locked: element.locked ?? false,\n }));\n\n return {\n ...data,\n elements: normalizedElements,\n };\n } catch (error) {\n throw new Error(`Failed to parse canvas data: ${(error as Error).message}`);\n }\n};\n\n/**\n * Calculate center point of an element\n */\nexport const getElementCenter = (element: EditorElement): { x: number; y: number } => {\n return {\n x: element.position.x + element.size.width / 2,\n y: element.position.y + element.size.height / 2,\n };\n};\n\n/**\n * Calculate distance between two points\n */\nexport const distance = (p1: { x: number; y: number }, p2: { x: number; y: number }): number => {\n const dx = p2.x - p1.x;\n const dy = p2.y - p1.y;\n return Math.sqrt(dx * dx + dy * dy);\n};\n\n/**\n * Degrees to radians\n */\nexport const degToRad = (degrees: number): number => {\n return (degrees * Math.PI) / 180;\n};\n\n/**\n * Radians to degrees\n */\nexport const radToDeg = (radians: number): number => {\n return (radians * 180) / Math.PI;\n};\n\n/**\n * Validate element data\n */\nexport const isValidElement = (element: any): element is EditorElement => {\n return (\n element &&\n typeof element.id === \"string\" &&\n typeof element.type === \"string\" &&\n element.position &&\n typeof element.position.x === \"number\" &&\n typeof element.position.y === \"number\" &&\n element.size &&\n typeof element.size.width === \"number\" &&\n typeof element.size.height === \"number\" &&\n typeof element.rotation === \"number\" &&\n typeof element.zIndex === \"number\" &&\n element.props !== undefined\n );\n};\n\n/**\n * Validate canvas export data\n */\nexport const isValidCanvasExport = (data: any): data is CanvasExport => {\n return (\n data &&\n typeof data.width === \"number\" &&\n typeof data.height === \"number\" &&\n Array.isArray(data.elements) &&\n data.elements.every(isValidElement)\n );\n};\n","/**\n * Visual Editor - Element Registry\n *\n * Manages registration and retrieval of element renderers.\n * This is what makes the editor extensible - new element types\n * can be registered dynamically.\n */\n\nimport { ElementRenderer } from \"../types\";\n\n/**\n * Registry class for managing element renderers\n */\nexport class ElementRegistry {\n private renderers: Map<string, ElementRenderer> = new Map();\n\n /**\n * Register a new element renderer\n */\n register(renderer: ElementRenderer): void {\n if (this.renderers.has(renderer.type)) {\n console.warn(\n `Element renderer with type \"${renderer.type}\" is already registered. Skipping.`\n );\n return;\n }\n this.renderers.set(renderer.type, renderer);\n }\n\n /**\n * Register multiple element renderers at once\n */\n registerMany(renderers: ElementRenderer[]): void {\n renderers.forEach((renderer) => this.register(renderer));\n }\n\n /**\n * Get a renderer by type\n */\n get(type: string): ElementRenderer | undefined {\n return this.renderers.get(type);\n }\n\n /**\n * Check if a renderer exists for a type\n */\n has(type: string): boolean {\n return this.renderers.has(type);\n }\n\n /**\n * Get all registered renderers\n */\n getAll(): ElementRenderer[] {\n return Array.from(this.renderers.values());\n }\n\n /**\n * Get the internal renderers map\n */\n getMap(): Map<string, ElementRenderer> {\n return this.renderers;\n }\n\n /**\n * Get all registered types\n */\n getAllTypes(): string[] {\n return Array.from(this.renderers.keys());\n }\n\n /**\n * Unregister a renderer\n */\n unregister(type: string): boolean {\n return this.renderers.delete(type);\n }\n\n /**\n * Clear all registered renderers\n */\n clear(): void {\n this.renderers.clear();\n }\n\n /**\n * Get the number of registered renderers\n */\n get size(): number {\n return this.renderers.size;\n }\n}\n\n/**\n * Create a global singleton registry (can be used across the app)\n */\nexport const globalElementRegistry = new ElementRegistry();\n\n/**\n * Hook to use the element registry in React components\n */\nimport { useMemo } from \"react\";\n\nexport const useElementRegistry = (initialRenderers?: ElementRenderer[]): ElementRegistry => {\n return useMemo(() => {\n const registry = new ElementRegistry();\n if (initialRenderers) {\n registry.registerMany(initialRenderers);\n }\n return registry;\n }, [initialRenderers]);\n};\n","/**\n * Visual Editor - Text Element Renderer\n *\n * Built-in text element renderer using Konva.\n */\n\nimport React from \"react\";\nimport { Text } from \"react-konva\";\nimport { ElementRenderer, EditorElement, TextElementProps } from \"../types\";\nimport { Type } from \"lucide-react\";\nimport { getSnappingPosition, SnapGuide } from \"../utils/snapping\";\nimport { KonvaEventObject, Node, NodeConfig } from \"konva/lib/Node\";\n\n/**\n * Text element renderer component\n */\nexport const TextElementRenderer: React.FC<{\n element: EditorElement<TextElementProps>;\n isSelected: boolean;\n onSelect: () => void;\n onTransform: (updates: Partial<EditorElement>) => void;\n // Snapping props\n allElements?: EditorElement[];\n canvasSize?: { width: number; height: number };\n onSnapGuides?: (guides: { vertical: SnapGuide[]; horizontal: SnapGuide[] }) => void;\n onClearSnapGuides?: () => void;\n // Element ID for centralized transformer\n elementId?: string;\n}> = ({\n element,\n isSelected,\n onSelect,\n onTransform,\n allElements = [],\n canvasSize,\n onSnapGuides,\n onClearSnapGuides,\n elementId,\n}) => {\n const shapeRef = React.useRef<any>(null);\n\n // Don't render if element is hidden\n const isVisible = element.visible !== false;\n const isLocked = element.locked === true;\n\n // Don't render if not visible\n if (!isVisible) {\n return null;\n }\n\n const handleClick = (e: KonvaEventObject<MouseEvent, Node<NodeConfig>>) => {\n if (isLocked) return;\n e.evt.button !== 0 ? undefined : onSelect();\n };\n\n const handleDragMove = (e: KonvaEventObject<MouseEvent, Node<NodeConfig>>) => {\n // Only allow left-click (button 0) dragging\n if (!canvasSize || !onSnapGuides || !isSelected || e.evt.button !== 0) return;\n\n const node = e.target;\n const snapResult = getSnappingPosition(element, node.x(), node.y(), allElements, {\n threshold: 5,\n snapToElements: true,\n snapToCanvas: true,\n canvasSize,\n });\n\n // Apply snapped position\n node.x(snapResult.x);\n node.y(snapResult.y);\n\n // Show snap guides\n onSnapGuides({\n vertical: snapResult.verticalGuides,\n horizontal: snapResult.horizontalGuides,\n });\n };\n\n const handleDragEnd = (e: any) => {\n // Clear snap guides\n if (onClearSnapGuides) {\n onClearSnapGuides();\n }\n\n onTransform({\n position: {\n x: e.target.x(),\n y: e.target.y(),\n },\n });\n };\n\n const handleTransform = () => {\n const node = shapeRef.current;\n if (!node) return;\n\n const scaleX = node.scaleX();\n const scaleY = node.scaleY();\n\n // Update width based on scaleX, keep font size constant\n const newWidth = Math.max(20, node.width() * scaleX);\n const newHeight = Math.max(20, node.height() * scaleY);\n\n // Reset scale immediately to prevent stretching\n node.scaleX(1);\n node.scaleY(1);\n\n // Update dimensions\n node.width(newWidth);\n node.height(newHeight);\n };\n\n // Build text decoration string\n const getTextDecoration = () => {\n const decorations: string[] = [];\n if (element.props.underline) decorations.push(\"underline\");\n if (element.props.strikethrough) decorations.push(\"line-through\");\n if (element.props.overline) decorations.push(\"overline\");\n return decorations.join(\" \") || \"\";\n };\n\n return (\n <>\n <Text\n ref={shapeRef}\n id={elementId || element.id}\n x={element.position.x}\n y={element.position.y}\n width={element.size.width}\n height={element.size.height}\n text={element.props.content}\n fontSize={element.props.fontSize}\n fontFamily={element.props.fontFamily || \"Arial\"}\n opacity={element.opacity}\n fill={element.props.color}\n align={element.props.align || \"left\"}\n fontStyle={\n `${element.props.bold ? \"bold\" : \"\"} ${element.props.italic ? \"italic\" : \"\"}`.trim() ||\n \"normal\"\n }\n textDecoration={getTextDecoration()}\n rotation={element.rotation}\n draggable={!isLocked && isSelected}\n listening={!isLocked}\n onClick={handleClick}\n onTap={isLocked ? undefined : onSelect}\n onDragMove={handleDragMove}\n onDragEnd={handleDragEnd}\n onTransform={handleTransform}\n ellipsis={element.props.textOverflow === \"ellipsis\"}\n verticalAlign={element.props.verticalAlign || \"top\"}\n stroke={element.props.strokeColor || \"#000000\"}\n strokeWidth={element.props.strokeWidth || 0}\n strokeEnabled\n fillAfterStrokeEnabled\n />\n </>\n );\n};\n\n/**\n * Text element renderer definition\n */\nexport const textElementRenderer: ElementRenderer<TextElementProps> = {\n type: \"text\",\n displayName: \"Text\",\n render: (element) => <div>Text: {element.props.content}</div>, // Placeholder for non-Konva contexts\n renderComponent: TextElementRenderer, // Konva rendering component\n icon: <Type className=\"w-4 h-4\" />,\n defaultProps: {\n content: \"Text\",\n fontSize: 16,\n fontFamily: \"Arial\",\n color: \"#000000\",\n strokeColor: \"#000000\",\n strokeWidth: 0,\n align: \"left\",\n verticalAlign: \"top\",\n bold: false,\n italic: false,\n underline: false,\n overline: false,\n strikethrough: false,\n wordWrap: \"break-word\",\n },\n defaultSize: {\n width: 200,\n height: 50,\n },\n inspectorSchema: [\n {\n name: \"content\",\n type: \"string\",\n label: \"Text Content\",\n defaultValue: \"Text\",\n },\n {\n name: \"fontSize\",\n type: \"number\",\n label: \"Font Size\",\n min: 0,\n max: 1024,\n step: 1,\n defaultValue: 16,\n },\n {\n name: \"fontFamily\",\n type: \"select\",\n label: \"Font Family\",\n options: [\n { value: \"Arial\", label: \"Arial\" },\n { value: \"Times New Roman\", label: \"Times New Roman\" },\n { value: \"Courier New\", label: \"Courier New\" },\n { value: \"Georgia\", label: \"Georgia\" },\n { value: \"Verdana\", label: \"Verdana\" },\n { value: \"Outfit\", label: \"Outfit\" },\n ],\n defaultValue: \"Outfit\",\n },\n {\n name: \"color\",\n type: \"color\",\n label: \"Text Color\",\n defaultValue: \"#000000\",\n },\n {\n name: \"strokeColor\",\n type: \"color\",\n label: \"Stroke Color\",\n defaultValue: \"#000000\",\n },\n {\n name: \"strokeWidth\",\n type: \"number\",\n label: \"Stroke Width\",\n min: 0,\n max: 50,\n step: 1,\n defaultValue: 0,\n },\n {\n name: \"align\",\n type: \"select\",\n label: \"Horizontal Alignment\",\n options: [\n { value: \"left\", label: \"Left\" },\n { value: \"center\", label: \"Center\" },\n { value: \"right\", label: \"Right\" },\n ],\n defaultValue: \"left\",\n },\n {\n name: \"verticalAlign\",\n type: \"select\",\n label: \"Vertical Alignment\",\n options: [\n { value: \"top\", label: \"Top\" },\n { value: \"middle\", label: \"Middle\" },\n { value: \"bottom\", label: \"Bottom\" },\n ],\n defaultValue: \"top\",\n },\n {\n name: \"bold\",\n type: \"boolean\",\n label: \"Bold\",\n defaultValue: false,\n },\n {\n name: \"italic\",\n type: \"boolean\",\n label: \"Italic\",\n defaultValue: false,\n },\n {\n name: \"underline\",\n type: \"boolean\",\n label: \"Underline\",\n defaultValue: false,\n },\n {\n name: \"strikethrough\",\n type: \"boolean\",\n label: \"Strikethrough\",\n defaultValue: false,\n },\n ],\n};\n","/**\n * Snapping Utilities\n *\n * Helper functions for snapping elements to guides, other elements, and canvas edges.\n */\n\nimport { EditorElement } from \"../types\";\n\nexport interface SnapGuide {\n /** Position of the guide line */\n position: number;\n /** Orientation of the guide (vertical or horizontal) */\n orientation: \"vertical\" | \"horizontal\";\n /** Snap type (element edge, center, or canvas edge) */\n type: \"edge\" | \"center\" | \"canvas\";\n}\n\nexport interface SnapResult {\n /** Snapped x position */\n x: number;\n /** Snapped y position */\n y: number;\n /** Vertical guides to display */\n verticalGuides: SnapGuide[];\n /** Horizontal guides to display */\n horizontalGuides: SnapGuide[];\n}\n\nexport interface SnapOptions {\n /** Snap threshold in pixels */\n threshold?: number;\n /** Whether to snap to other elements */\n snapToElements?: boolean;\n /** Whether to snap to canvas edges */\n snapToCanvas?: boolean;\n /** Canvas size */\n canvasSize?: { width: number; height: number };\n}\n\n/**\n * Calculate the axis-aligned bounding box for a rotated element\n * Note: In Konva (without offsetX/offsetY), rotation happens around the TOP-LEFT corner (x, y)\n * For snapping, we primarily care about the CENTER point, not the rotated edges\n */\nfunction getRotatedBounds(\n x: number,\n y: number,\n width: number,\n height: number,\n rotation: number\n): { left: number; right: number; top: number; bottom: number; centerX: number; centerY: number } {\n // If no significant rotation, return simple bounds\n if (Math.abs(rotation) < 0.1 || Math.abs(rotation - 360) < 0.1) {\n return {\n left: x,\n right: x + width,\n top: y,\n bottom: y + height,\n centerX: x + width / 2,\n centerY: y + height / 2,\n };\n }\n\n // Convert rotation to radians\n const rad = (rotation * Math.PI) / 180;\n const cos = Math.cos(rad);\n const sin = Math.sin(rad);\n\n // Calculate the four corners (relative to rotation origin at x, y)\n const corners = [\n { x: 0, y: 0 }, // top-left (rotation origin)\n { x: width, y: 0 }, // top-right\n { x: width, y: height }, // bottom-right\n { x: 0, y: height }, // bottom-left\n ];\n\n // Rotate corners around (x, y) and find min/max\n let minX = Infinity;\n let maxX = -Infinity;\n let minY = Infinity;\n let maxY = -Infinity;\n\n for (const corner of corners) {\n // Rotate point around origin (x, y)\n const rotatedX = x + corner.x * cos - corner.y * sin;\n const rotatedY = y + corner.x * sin + corner.y * cos;\n minX = Math.min(minX, rotatedX);\n maxX = Math.max(maxX, rotatedX);\n minY = Math.min(minY, rotatedY);\n maxY = Math.max(maxY, rotatedY);\n }\n\n // Calculate the actual center of the rotated rectangle\n // The center point rotates around (x, y)\n const centerX = x + (width / 2) * cos - (height / 2) * sin;\n const centerY = y + (width / 2) * sin + (height / 2) * cos;\n\n return {\n left: minX,\n right: maxX,\n top: minY,\n bottom: maxY,\n centerX,\n centerY,\n };\n}\n\n/**\n * Calculate snapping for an element being dragged\n */\nexport function getSnappingPosition(\n draggedElement: EditorElement,\n x: number,\n y: number,\n allElements: EditorElement[],\n options: SnapOptions = {}\n): SnapResult {\n const { threshold = 5, snapToElements = true, snapToCanvas = true, canvasSize } = options;\n\n let snappedX = x;\n let snappedY = y;\n const verticalGuides: SnapGuide[] = [];\n const horizontalGuides: SnapGuide[] = [];\n\n // Calculate dragged element bounds (accounting for rotation)\n const draggedBounds = getRotatedBounds(\n x,\n y,\n draggedElement.size.width,\n draggedElement.size.height,\n draggedElement.rotation\n );\n\n const draggedLeft = draggedBounds.left;\n const draggedRight = draggedBounds.right;\n const draggedTop = draggedBounds.top;\n const draggedBottom = draggedBounds.bottom;\n const draggedCenterX = draggedBounds.centerX;\n const draggedCenterY = draggedBounds.centerY;\n\n // Check if the dragged element is significantly rotated\n const isRotated =\n Math.abs(draggedElement.rotation) > 0.1 && Math.abs(draggedElement.rotation - 360) > 0.1;\n\n // Track closest snap distances\n let closestVerticalSnap = threshold;\n let closestHorizontalSnap = threshold;\n let verticalOffset = 0;\n let horizontalOffset = 0;\n\n // Snap to canvas edges\n if (snapToCanvas && canvasSize) {\n // For rotated elements, snap to axis-aligned bounding box edges\n // NOTE: Edge snapping for rotated elements now enabled\n // Left edge\n if (Math.abs(draggedLeft) <= closestVerticalSnap) {\n verticalOffset = -draggedLeft;\n closestVerticalSnap = Math.abs(draggedLeft);\n verticalGuides.push({ position: 0, orientation: \"vertical\", type: \"canvas\" });\n }\n\n // Right edge\n if (Math.abs(draggedRight - canvasSize.width) <= closestVerticalSnap) {\n verticalOffset = canvasSize.width - draggedRight;\n closestVerticalSnap = Math.abs(draggedRight - canvasSize.width);\n verticalGuides.push({\n position: canvasSize.width,\n orientation: \"vertical\",\n type: \"canvas\",\n });\n }\n\n // Top edge\n if (Math.abs(draggedTop) <= closestHorizontalSnap) {\n horizontalOffset = -draggedTop;\n closestHorizontalSnap = Math.abs(draggedTop);\n horizontalGuides.push({ position: 0, orientation: \"horizontal\", type: \"canvas\" });\n }\n\n // Bottom edge\n if (Math.abs(draggedBottom - canvasSize.height) <= closestHorizontalSnap) {\n horizontalOffset = canvasSize.height - draggedBottom;\n closestHorizontalSnap = Math.abs(draggedBottom - canvasSize.height);\n horizontalGuides.push({\n position: canvasSize.height,\n orientation: \"horizontal\",\n type: \"canvas\",\n });\n }\n\n // Center X (always available, even for rotated elements)\n const canvasCenterX = canvasSize.width / 2;\n if (Math.abs(draggedCenterX - canvasCenterX) <= closestVerticalSnap) {\n verticalOffset = canvasCenterX - draggedCenterX;\n closestVerticalSnap = Math.abs(draggedCenterX - canvasCenterX);\n verticalGuides.length = 0; // Clear edge guides if center snaps\n verticalGuides.push({\n position: canvasCenterX,\n orientation: \"vertical\",\n type: \"center\",\n });\n }\n\n // Center Y (always available, even for rotated elements)\n const canvasCenterY = canvasSize.height / 2;\n if (Math.abs(draggedCenterY - canvasCenterY) <= closestHorizontalSnap) {\n horizontalOffset = canvasCenterY - draggedCenterY;\n closestHorizontalSnap = Math.abs(draggedCenterY - canvasCenterY);\n horizontalGuides.length = 0; // Clear edge guides if center snaps\n horizontalGuides.push({\n position: canvasCenterY,\n orientation: \"horizontal\",\n type: \"center\",\n });\n }\n }\n\n // Snap to other elements\n if (snapToElements) {\n for (const element of allElements) {\n // Skip self\n if (element.id === draggedElement.id) continue;\n\n // Skip hidden elements\n if (element.visible === false) continue;\n\n // Calculate element bounds (accounting for rotation)\n const elementBounds = getRotatedBounds(\n element.position.x,\n element.position.y,\n element.size.width,\n element.size.height,\n element.rotation\n );\n\n const elementLeft = elementBounds.left;\n const elementRight = elementBounds.right;\n const elementTop = elementBounds.top;\n const elementBottom = elementBounds.bottom;\n const elementCenterX = elementBounds.centerX;\n const elementCenterY = elementBounds.centerY;\n\n // Check if target element is rotated\n const isElementRotated =\n Math.abs(element.rotation) > 0.1 && Math.abs(element.rotation - 360) > 0.1;\n\n // For rotated elements (either dragged or target), only snap centers, not edges\n // NOTE: Edge snapping for rotated elements now enabled - snaps to axis-aligned bounding box\n const shouldSkipEdges = false; // Changed: always show edge guides\n\n // Vertical snapping (left, right, center)\n if (!shouldSkipEdges) {\n // Left to left\n if (Math.abs(draggedLeft - elementLeft) <= closestVerticalSnap) {\n verticalOffset = elementLeft - draggedLeft;\n closestVerticalSnap = Math.abs(draggedLeft - elementLeft);\n verticalGuides.length = 0;\n verticalGuides.push({ position: elementLeft, orientation: \"vertical\", type: \"edge\" });\n }\n\n // Right to right\n if (Math.abs(draggedRight - elementRight) <= closestVerticalSnap) {\n verticalOffset = elementRight - draggedRight;\n closestVerticalSnap = Math.abs(draggedRight - elementRight);\n verticalGuides.length = 0;\n verticalGuides.push({\n position: elementRight,\n orientation: \"vertical\",\n type: \"edge\",\n });\n }\n\n // Left to right\n if (Math.abs(draggedLeft - elementRight) <= closestVerticalSnap) {\n verticalOffset = elementRight - draggedLeft;\n closestVerticalSnap = Math.abs(draggedLeft - elementRight);\n verticalGuides.length = 0;\n verticalGuides.push({\n position: elementRight,\n orientation: \"vertical\",\n type: \"edge\",\n });\n }\n\n // Right to left\n if (Math.abs(draggedRight - elementLeft) <= closestVerticalSnap) {\n verticalOffset = elementLeft - draggedRight;\n closestVerticalSnap = Math.abs(draggedRight - elementLeft);\n verticalGuides.length = 0;\n verticalGuides.push({ position: elementLeft, orientation: \"vertical\", type: \"edge\" });\n }\n }\n\n // Center to center (always available)\n if (Math.abs(draggedCenterX - elementCenterX) <= closestVerticalSnap) {\n verticalOffset = elementCenterX - draggedCenterX;\n closestVerticalSnap = Math.abs(draggedCenterX - elementCenterX);\n verticalGuides.length = 0;\n verticalGuides.push({\n position: elementCenterX,\n orientation: \"vertical\",\n type: \"center\",\n });\n }\n\n // Horizontal snapping (top, bottom, center)\n if (!shouldSkipEdges) {\n // Top to top\n if (Math.abs(draggedTop - elementTop) <= closestHorizontalSnap) {\n horizontalOffset = elementTop - draggedTop;\n closestHorizontalSnap = Math.abs(draggedTop - elementTop);\n horizontalGuides.length = 0;\n horizontalGuides.push({\n position: elementTop,\n orientation: \"horizontal\",\n type: \"edge\",\n });\n }\n\n // Bottom to bottom\n if (Math.abs(draggedBottom - elementBottom) <= closestHorizontalSnap) {\n horizontalOffset = elementBottom - draggedBottom;\n closestHorizontalSnap = Math.abs(draggedBottom - eleme