UNPKG

@deckedout/visual-editor

Version:

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

1,620 lines (1,600 loc) 152 kB
"use client" "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { AssetPicker: () => AssetPicker, ElementRegistry: () => ElementRegistry, ImageElementRenderer: () => ImageElementRenderer, Inspector: () => Inspector, LayersPanel: () => LayersPanel, TextElementRenderer: () => TextElementRenderer, VisualEditor: () => VisualEditor, VisualEditorWorkspace: () => VisualEditorWorkspace, bringToFront: () => bringToFront, checkOverlap: () => checkOverlap, clamp: () => clamp, cloneElement: () => cloneElement, constrainToCanvas: () => constrainToCanvas, createElement: () => createElement, defaultElements: () => defaultElements, degToRad: () => degToRad, distance: () => distance, duplicateElement: () => duplicateElement, exportToJSON: () => exportToJSON, generateElementId: () => generateElementId, getElementCenter: () => getElementCenter, getMaxZIndex: () => getMaxZIndex, getRotatedBoundingBox: () => getRotatedBoundingBox, getSnappingPosition: () => getSnappingPosition, globalElementRegistry: () => globalElementRegistry, imageElementRenderer: () => imageElementRenderer, importFromJSON: () => importFromJSON, isValidCanvasExport: () => isValidCanvasExport, isValidElement: () => isValidElement, pointInRect: () => pointInRect, radToDeg: () => radToDeg, renderField: () => renderField, sendToBack: () => sendToBack, snapPositionToGrid: () => snapPositionToGrid, snapToGrid: () => snapToGrid, sortByZIndex: () => sortByZIndex, textElementRenderer: () => textElementRenderer, useEditorState: () => useEditorState, useElementRegistry: () => useElementRegistry }); module.exports = __toCommonJS(index_exports); // src/core/VisualEditor.tsx var import_react5 = require("react"); var import_react_konva3 = require("react-konva"); // src/core/useEditorState.ts var import_react = require("react"); // src/utils/editorUtils.ts var import_uuid = require("uuid"); var generateElementId = () => { return `element-${(0, import_uuid.v4)()}`; }; var createElement = (type, props, options) => { return { id: generateElementId(), type, position: options?.position || { x: 0, y: 0 }, size: options?.size || { width: 100, height: 100 }, rotation: options?.rotation || 0, opacity: options?.opacity ?? 1, zIndex: options?.zIndex || 0, visible: options?.visible ?? true, locked: options?.locked ?? false, displayName: options?.displayName, props }; }; var cloneElement = (element) => { return { ...element, id: generateElementId(), // New ID for the clone props: { ...element.props }, position: { ...element.position }, size: { ...element.size } }; }; var duplicateElement = (element, offset = { x: 20, y: 20 }) => { const cloned = cloneElement(element); return { ...cloned, position: { x: element.position.x + offset.x, y: element.position.y + offset.y }, zIndex: element.zIndex + 1 // Place on top }; }; var sortByZIndex = (elements) => { return [...elements].sort((a, b) => a.zIndex - b.zIndex); }; var getMaxZIndex = (elements) => { if (elements.length === 0) return 0; return Math.max(...elements.map((el) => el.zIndex)); }; var bringToFront = (elements, elementId) => { const maxZ = getMaxZIndex(elements); return elements.map((el) => el.id === elementId ? { ...el, zIndex: maxZ + 1 } : el); }; var sendToBack = (elements, elementId) => { const minZ = Math.min(...elements.map((el) => el.zIndex)); return elements.map((el) => el.id === elementId ? { ...el, zIndex: minZ - 1 } : el); }; var checkOverlap = (rect1, rect2) => { return !(rect1.x + rect1.width < rect2.x || rect2.x + rect2.width < rect1.x || rect1.y + rect1.height < rect2.y || rect2.y + rect2.height < rect1.y); }; var pointInRect = (point, rect) => { return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; }; var snapToGrid = (value, gridSize) => { return Math.round(value / gridSize) * gridSize; }; var snapPositionToGrid = (position, gridSize) => { return { x: snapToGrid(position.x, gridSize), y: snapToGrid(position.y, gridSize) }; }; var clamp = (value, min, max) => { return Math.min(Math.max(value, min), max); }; var constrainToCanvas = (position, size, canvasSize) => { return { x: clamp(position.x, 0, canvasSize.width - size.width), y: clamp(position.y, 0, canvasSize.height - size.height) }; }; var getRotatedBoundingBox = (x, y, width, height, rotation) => { const rad = rotation * Math.PI / 180; const cos = Math.abs(Math.cos(rad)); const sin = Math.abs(Math.sin(rad)); const newWidth = width * cos + height * sin; const newHeight = width * sin + height * cos; return { x: x - (newWidth - width) / 2, y: y - (newHeight - height) / 2, width: newWidth, height: newHeight }; }; var exportToJSON = (data) => { return JSON.stringify(data, null, 2); }; var importFromJSON = (json) => { try { const data = JSON.parse(json); if (!data.width || !data.height || !Array.isArray(data.elements)) { throw new Error("Invalid canvas data structure"); } const normalizedElements = data.elements.map((element) => ({ ...element, visible: element.visible ?? true, locked: element.locked ?? false })); return { ...data, elements: normalizedElements }; } catch (error) { throw new Error(`Failed to parse canvas data: ${error.message}`); } }; var getElementCenter = (element) => { return { x: element.position.x + element.size.width / 2, y: element.position.y + element.size.height / 2 }; }; var distance = (p1, p2) => { const dx = p2.x - p1.x; const dy = p2.y - p1.y; return Math.sqrt(dx * dx + dy * dy); }; var degToRad = (degrees) => { return degrees * Math.PI / 180; }; var radToDeg = (radians) => { return radians * 180 / Math.PI; }; var isValidElement = (element) => { return element && typeof element.id === "string" && typeof element.type === "string" && element.position && typeof element.position.x === "number" && typeof element.position.y === "number" && element.size && typeof element.size.width === "number" && typeof element.size.height === "number" && typeof element.rotation === "number" && typeof element.zIndex === "number" && element.props !== void 0; }; var isValidCanvasExport = (data) => { return data && typeof data.width === "number" && typeof data.height === "number" && Array.isArray(data.elements) && data.elements.every(isValidElement); }; // src/core/useEditorState.ts var createInitialState = (mode) => ({ elements: [], selectedElementId: null, canvasSize: mode?.defaultCanvasSize || { width: 800, height: 600 }, zoom: 1, pan: { x: 0, y: 0 }, mode, history: { past: [], future: [] } }); var editorReducer = (state, action) => { switch (action.type) { case "ADD_ELEMENT": { const normalizedElement = { ...action.element, visible: action.element.visible ?? true, locked: action.element.locked ?? false }; const newElements = [...state.elements, normalizedElement]; return { ...state, elements: newElements, selectedElementId: normalizedElement.id, history: { past: [...state.history.past, state.elements], future: [] } }; } case "UPDATE_ELEMENT": { const newElements = state.elements.map( (el) => el.id === action.id ? { ...el, ...action.updates } : el ); return { ...state, elements: newElements, history: { past: [...state.history.past, state.elements], future: [] } }; } case "REMOVE_ELEMENT": { const newElements = state.elements.filter((el) => el.id !== action.id); return { ...state, elements: newElements, selectedElementId: state.selectedElementId === action.id ? null : state.selectedElementId, history: { past: [...state.history.past, state.elements], future: [] } }; } case "SELECT_ELEMENT": return { ...state, selectedElementId: action.id }; case "SET_ELEMENTS": return { ...state, elements: action.elements, history: { past: [...state.history.past, state.elements], future: [] } }; case "LOAD_ELEMENTS": return { ...state, elements: action.elements.map((el) => ({ ...el, visible: el.visible ?? true, locked: el.locked ?? false })) }; case "REORDER_ELEMENT": { const currentIndex = state.elements.findIndex((el) => el.id === action.elementId); if (currentIndex === -1 || currentIndex === action.newIndex) return state; const newElements = [...state.elements]; const [element] = newElements.splice(currentIndex, 1); newElements.splice(action.newIndex, 0, element); newElements.forEach((el, index) => { el.zIndex = index; }); return { ...state, elements: newElements, history: { past: [...state.history.past, state.elements], future: [] } }; } case "SET_CANVAS_SIZE": return { ...state, canvasSize: { width: action.width, height: action.height } }; case "SET_ZOOM": return { ...state, zoom: action.zoom }; case "SET_PAN": return { ...state, pan: { x: action.x, y: action.y } }; case "SET_MODE": return { ...state, mode: action.mode, canvasSize: action.mode.defaultCanvasSize }; case "CLEAR": return { ...state, elements: [], selectedElementId: null, history: { past: [...state.history.past, state.elements], future: [] } }; case "UNDO": { if (state.history.past.length === 0) return state; const previous = state.history.past[state.history.past.length - 1]; const newPast = state.history.past.slice(0, -1); return { ...state, elements: previous, history: { past: newPast, future: [state.elements, ...state.history.future] } }; } case "REDO": { if (state.history.future.length === 0) return state; const next = state.history.future[0]; const newFuture = state.history.future.slice(1); return { ...state, elements: next, history: { past: [...state.history.past, state.elements], future: newFuture } }; } default: return state; } }; var useEditorState = (initialMode = null) => { const [state, dispatch] = (0, import_react.useReducer)(editorReducer, createInitialState(initialMode)); const addElement = (0, import_react.useCallback)((element) => { dispatch({ type: "ADD_ELEMENT", element }); }, []); const updateElement = (0, import_react.useCallback)((id, updates) => { dispatch({ type: "UPDATE_ELEMENT", id, updates }); }, []); const removeElement = (0, import_react.useCallback)((id) => { dispatch({ type: "REMOVE_ELEMENT", id }); }, []); const selectElement = (0, import_react.useCallback)((id) => { dispatch({ type: "SELECT_ELEMENT", id }); }, []); const getSelectedElement = (0, import_react.useCallback)(() => { if (!state.selectedElementId) return null; return state.elements.find((el) => el.id === state.selectedElementId) || null; }, [state.selectedElementId, state.elements]); const getAllElements = (0, import_react.useCallback)(() => { return state.elements; }, [state.elements]); const moveElement = (0, import_react.useCallback)( (id, deltaX, deltaY) => { const element = state.elements.find((el) => el.id === id); if (!element) return; updateElement(id, { position: { x: element.position.x + deltaX, y: element.position.y + deltaY } }); }, [state.elements, updateElement] ); const rotateElement = (0, import_react.useCallback)( (id, angle) => { updateElement(id, { rotation: angle }); }, [updateElement] ); const resizeElement = (0, import_react.useCallback)( (id, width, height) => { updateElement(id, { size: { width, height } }); }, [updateElement] ); const updateZIndex = (0, import_react.useCallback)( (id, zIndex) => { updateElement(id, { zIndex }); }, [updateElement] ); const reorderElement = (0, import_react.useCallback)((id, newIndex) => { dispatch({ type: "REORDER_ELEMENT", elementId: id, newIndex }); }, []); const exportJSON = (0, import_react.useCallback)(() => { return { width: state.canvasSize.width, height: state.canvasSize.height, elements: state.elements, metadata: { version: "1.0.0", mode: state.mode?.name, created: (/* @__PURE__ */ new Date()).toISOString() } }; }, [state.canvasSize, state.elements, state.mode]); const importJSON = (0, import_react.useCallback)((data) => { dispatch({ type: "SET_CANVAS_SIZE", width: data.width, height: data.height }); dispatch({ type: "SET_ELEMENTS", elements: data.elements }); }, []); const clear = (0, import_react.useCallback)(() => { dispatch({ type: "CLEAR" }); }, []); const setCanvasSize = (0, import_react.useCallback)((width, height) => { dispatch({ type: "SET_CANVAS_SIZE", width, height }); }, []); const setMode = (0, import_react.useCallback)((mode) => { dispatch({ type: "SET_MODE", mode }); }, []); const undo = (0, import_react.useCallback)(() => { dispatch({ type: "UNDO" }); }, []); const redo = (0, import_react.useCallback)(() => { dispatch({ type: "REDO" }); }, []); const copyElement = (0, import_react.useCallback)( (id = null) => { const elementToCopy = id ? state.elements.find((el) => el.id === id) : getSelectedElement(); if (!elementToCopy) return null; return { ...elementToCopy, props: { ...elementToCopy.props }, position: { ...elementToCopy.position }, size: { ...elementToCopy.size } }; }, [state.elements, getSelectedElement] ); const duplicateElement2 = (0, import_react.useCallback)( (id = null, offset = { x: 20, y: 20 }) => { const elementToDuplicate = id ? state.elements.find((el) => el.id === id) : getSelectedElement(); if (!elementToDuplicate) return; const duplicated = duplicateElement(elementToDuplicate, offset); addElement(duplicated); }, [state.elements, getSelectedElement, addElement] ); const pasteElement = (0, import_react.useCallback)( (copiedElement, offset = { x: 20, y: 20 }) => { if (!copiedElement) return; const pasted = { ...copiedElement, id: generateElementId(), props: { ...copiedElement.props }, position: { x: copiedElement.position.x + offset.x, y: copiedElement.position.y + offset.y }, size: { ...copiedElement.size }, zIndex: Math.max(...state.elements.map((el) => el.zIndex), 0) + 1 }; addElement(pasted); }, [state.elements, addElement] ); const clearHistory = (0, import_react.useCallback)(() => { state.history.past = []; state.history.future = []; }, []); const loadElements = (0, import_react.useCallback)((elements) => { dispatch({ type: "LOAD_ELEMENTS", elements }); }, []); const api = (0, import_react.useMemo)( () => ({ addElement, updateElement, removeElement, selectElement, getSelectedElement, getAllElements, moveElement, rotateElement, resizeElement, updateZIndex, reorderElement, exportJSON, importJSON, clear, copyElement, duplicateElement: duplicateElement2, pasteElement, clearHistory, loadElements }), [ addElement, updateElement, removeElement, selectElement, getSelectedElement, getAllElements, moveElement, rotateElement, resizeElement, updateZIndex, reorderElement, exportJSON, importJSON, clear, copyElement, duplicateElement2, pasteElement, clearHistory, loadElements ] ); return { state, api, // Additional helpers setCanvasSize, setMode, undo, redo, canUndo: state.history.past.length > 0, canRedo: state.history.future.length > 0 }; }; // src/core/ElementRegistry.ts var import_react2 = require("react"); var ElementRegistry = class { constructor() { this.renderers = /* @__PURE__ */ new Map(); } /** * Register a new element renderer */ register(renderer) { if (this.renderers.has(renderer.type)) { console.warn( `Element renderer with type "${renderer.type}" is already registered. Skipping.` ); return; } this.renderers.set(renderer.type, renderer); } /** * Register multiple element renderers at once */ registerMany(renderers) { renderers.forEach((renderer) => this.register(renderer)); } /** * Get a renderer by type */ get(type) { return this.renderers.get(type); } /** * Check if a renderer exists for a type */ has(type) { return this.renderers.has(type); } /** * Get all registered renderers */ getAll() { return Array.from(this.renderers.values()); } /** * Get the internal renderers map */ getMap() { return this.renderers; } /** * Get all registered types */ getAllTypes() { return Array.from(this.renderers.keys()); } /** * Unregister a renderer */ unregister(type) { return this.renderers.delete(type); } /** * Clear all registered renderers */ clear() { this.renderers.clear(); } /** * Get the number of registered renderers */ get size() { return this.renderers.size; } }; var globalElementRegistry = new ElementRegistry(); var useElementRegistry = (initialRenderers) => { return (0, import_react2.useMemo)(() => { const registry = new ElementRegistry(); if (initialRenderers) { registry.registerMany(initialRenderers); } return registry; }, [initialRenderers]); }; // src/elements/TextElement.tsx var import_react3 = __toESM(require("react")); var import_react_konva = require("react-konva"); var import_lucide_react = require("lucide-react"); // src/utils/snapping.ts function getRotatedBounds(x, y, width, height, rotation) { if (Math.abs(rotation) < 0.1 || Math.abs(rotation - 360) < 0.1) { return { left: x, right: x + width, top: y, bottom: y + height, centerX: x + width / 2, centerY: y + height / 2 }; } const rad = rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const corners = [ { x: 0, y: 0 }, // top-left (rotation origin) { x: width, y: 0 }, // top-right { x: width, y: height }, // bottom-right { x: 0, y: height } // bottom-left ]; let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const corner of corners) { const rotatedX = x + corner.x * cos - corner.y * sin; const rotatedY = y + corner.x * sin + corner.y * cos; minX = Math.min(minX, rotatedX); maxX = Math.max(maxX, rotatedX); minY = Math.min(minY, rotatedY); maxY = Math.max(maxY, rotatedY); } const centerX = x + width / 2 * cos - height / 2 * sin; const centerY = y + width / 2 * sin + height / 2 * cos; return { left: minX, right: maxX, top: minY, bottom: maxY, centerX, centerY }; } function getSnappingPosition(draggedElement, x, y, allElements, options = {}) { const { threshold = 5, snapToElements = true, snapToCanvas = true, canvasSize } = options; let snappedX = x; let snappedY = y; const verticalGuides = []; const horizontalGuides = []; const draggedBounds = getRotatedBounds( x, y, draggedElement.size.width, draggedElement.size.height, draggedElement.rotation ); const draggedLeft = draggedBounds.left; const draggedRight = draggedBounds.right; const draggedTop = draggedBounds.top; const draggedBottom = draggedBounds.bottom; const draggedCenterX = draggedBounds.centerX; const draggedCenterY = draggedBounds.centerY; const isRotated = Math.abs(draggedElement.rotation) > 0.1 && Math.abs(draggedElement.rotation - 360) > 0.1; let closestVerticalSnap = threshold; let closestHorizontalSnap = threshold; let verticalOffset = 0; let horizontalOffset = 0; if (snapToCanvas && canvasSize) { if (Math.abs(draggedLeft) <= closestVerticalSnap) { verticalOffset = -draggedLeft; closestVerticalSnap = Math.abs(draggedLeft); verticalGuides.push({ position: 0, orientation: "vertical", type: "canvas" }); } if (Math.abs(draggedRight - canvasSize.width) <= closestVerticalSnap) { verticalOffset = canvasSize.width - draggedRight; closestVerticalSnap = Math.abs(draggedRight - canvasSize.width); verticalGuides.push({ position: canvasSize.width, orientation: "vertical", type: "canvas" }); } if (Math.abs(draggedTop) <= closestHorizontalSnap) { horizontalOffset = -draggedTop; closestHorizontalSnap = Math.abs(draggedTop); horizontalGuides.push({ position: 0, orientation: "horizontal", type: "canvas" }); } if (Math.abs(draggedBottom - canvasSize.height) <= closestHorizontalSnap) { horizontalOffset = canvasSize.height - draggedBottom; closestHorizontalSnap = Math.abs(draggedBottom - canvasSize.height); horizontalGuides.push({ position: canvasSize.height, orientation: "horizontal", type: "canvas" }); } const canvasCenterX = canvasSize.width / 2; if (Math.abs(draggedCenterX - canvasCenterX) <= closestVerticalSnap) { verticalOffset = canvasCenterX - draggedCenterX; closestVerticalSnap = Math.abs(draggedCenterX - canvasCenterX); verticalGuides.length = 0; verticalGuides.push({ position: canvasCenterX, orientation: "vertical", type: "center" }); } const canvasCenterY = canvasSize.height / 2; if (Math.abs(draggedCenterY - canvasCenterY) <= closestHorizontalSnap) { horizontalOffset = canvasCenterY - draggedCenterY; closestHorizontalSnap = Math.abs(draggedCenterY - canvasCenterY); horizontalGuides.length = 0; horizontalGuides.push({ position: canvasCenterY, orientation: "horizontal", type: "center" }); } } if (snapToElements) { for (const element of allElements) { if (element.id === draggedElement.id) continue; if (element.visible === false) continue; const elementBounds = getRotatedBounds( element.position.x, element.position.y, element.size.width, element.size.height, element.rotation ); const elementLeft = elementBounds.left; const elementRight = elementBounds.right; const elementTop = elementBounds.top; const elementBottom = elementBounds.bottom; const elementCenterX = elementBounds.centerX; const elementCenterY = elementBounds.centerY; const isElementRotated = Math.abs(element.rotation) > 0.1 && Math.abs(element.rotation - 360) > 0.1; const shouldSkipEdges = false; if (!shouldSkipEdges) { if (Math.abs(draggedLeft - elementLeft) <= closestVerticalSnap) { verticalOffset = elementLeft - draggedLeft; closestVerticalSnap = Math.abs(draggedLeft - elementLeft); verticalGuides.length = 0; verticalGuides.push({ position: elementLeft, orientation: "vertical", type: "edge" }); } if (Math.abs(draggedRight - elementRight) <= closestVerticalSnap) { verticalOffset = elementRight - draggedRight; closestVerticalSnap = Math.abs(draggedRight - elementRight); verticalGuides.length = 0; verticalGuides.push({ position: elementRight, orientation: "vertical", type: "edge" }); } if (Math.abs(draggedLeft - elementRight) <= closestVerticalSnap) { verticalOffset = elementRight - draggedLeft; closestVerticalSnap = Math.abs(draggedLeft - elementRight); verticalGuides.length = 0; verticalGuides.push({ position: elementRight, orientation: "vertical", type: "edge" }); } if (Math.abs(draggedRight - elementLeft) <= closestVerticalSnap) { verticalOffset = elementLeft - draggedRight; closestVerticalSnap = Math.abs(draggedRight - elementLeft); verticalGuides.length = 0; verticalGuides.push({ position: elementLeft, orientation: "vertical", type: "edge" }); } } if (Math.abs(draggedCenterX - elementCenterX) <= closestVerticalSnap) { verticalOffset = elementCenterX - draggedCenterX; closestVerticalSnap = Math.abs(draggedCenterX - elementCenterX); verticalGuides.length = 0; verticalGuides.push({ position: elementCenterX, orientation: "vertical", type: "center" }); } if (!shouldSkipEdges) { if (Math.abs(draggedTop - elementTop) <= closestHorizontalSnap) { horizontalOffset = elementTop - draggedTop; closestHorizontalSnap = Math.abs(draggedTop - elementTop); horizontalGuides.length = 0; horizontalGuides.push({ position: elementTop, orientation: "horizontal", type: "edge" }); } if (Math.abs(draggedBottom - elementBottom) <= closestHorizontalSnap) { horizontalOffset = elementBottom - draggedBottom; closestHorizontalSnap = Math.abs(draggedBottom - elementBottom); horizontalGuides.length = 0; horizontalGuides.push({ position: elementBottom, orientation: "horizontal", type: "edge" }); } if (Math.abs(draggedTop - elementBottom) <= closestHorizontalSnap) { horizontalOffset = elementBottom - draggedTop; closestHorizontalSnap = Math.abs(draggedTop - elementBottom); horizontalGuides.length = 0; horizontalGuides.push({ position: elementBottom, orientation: "horizontal", type: "edge" }); } if (Math.abs(draggedBottom - elementTop) <= closestHorizontalSnap) { horizontalOffset = elementTop - draggedBottom; closestHorizontalSnap = Math.abs(draggedBottom - elementTop); horizontalGuides.length = 0; horizontalGuides.push({ position: elementTop, orientation: "horizontal", type: "edge" }); } } if (Math.abs(draggedCenterY - elementCenterY) <= closestHorizontalSnap) { horizontalOffset = elementCenterY - draggedCenterY; closestHorizontalSnap = Math.abs(draggedCenterY - elementCenterY); horizontalGuides.length = 0; horizontalGuides.push({ position: elementCenterY, orientation: "horizontal", type: "center" }); } } } snappedX = x + verticalOffset; snappedY = y + horizontalOffset; return { x: snappedX, y: snappedY, verticalGuides, horizontalGuides }; } // src/elements/TextElement.tsx var import_jsx_runtime = require("react/jsx-runtime"); var TextElementRenderer = ({ element, isSelected, onSelect, onTransform, allElements = [], canvasSize, onSnapGuides, onClearSnapGuides, elementId }) => { const shapeRef = import_react3.default.useRef(null); const isVisible = element.visible !== false; const isLocked = element.locked === true; if (!isVisible) { return null; } const handleClick = (e) => { if (isLocked) return; e.evt.button !== 0 ? void 0 : onSelect(); }; const handleDragMove = (e) => { if (!canvasSize || !onSnapGuides || !isSelected || e.evt.button !== 0) return; const node = e.target; const snapResult = getSnappingPosition(element, node.x(), node.y(), allElements, { threshold: 5, snapToElements: true, snapToCanvas: true, canvasSize }); node.x(snapResult.x); node.y(snapResult.y); onSnapGuides({ vertical: snapResult.verticalGuides, horizontal: snapResult.horizontalGuides }); }; const handleDragEnd = (e) => { if (onClearSnapGuides) { onClearSnapGuides(); } onTransform({ position: { x: e.target.x(), y: e.target.y() } }); }; const handleTransform = () => { const node = shapeRef.current; if (!node) return; const scaleX = node.scaleX(); const scaleY = node.scaleY(); const newWidth = Math.max(20, node.width() * scaleX); const newHeight = Math.max(20, node.height() * scaleY); node.scaleX(1); node.scaleY(1); node.width(newWidth); node.height(newHeight); }; const getTextDecoration = () => { const decorations = []; if (element.props.underline) decorations.push("underline"); if (element.props.strikethrough) decorations.push("line-through"); if (element.props.overline) decorations.push("overline"); return decorations.join(" ") || ""; }; return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)( import_react_konva.Text, { ref: shapeRef, id: elementId || element.id, x: element.position.x, y: element.position.y, width: element.size.width, height: element.size.height, text: element.props.content, fontSize: element.props.fontSize, fontFamily: element.props.fontFamily || "Arial", opacity: element.opacity, fill: element.props.color, align: element.props.align || "left", fontStyle: `${element.props.bold ? "bold" : ""} ${element.props.italic ? "italic" : ""}`.trim() || "normal", textDecoration: getTextDecoration(), rotation: element.rotation, draggable: !isLocked && isSelected, listening: !isLocked, onClick: handleClick, onTap: isLocked ? void 0 : onSelect, onDragMove: handleDragMove, onDragEnd: handleDragEnd, onTransform: handleTransform, ellipsis: element.props.textOverflow === "ellipsis", verticalAlign: element.props.verticalAlign || "top", stroke: element.props.strokeColor || "#000000", strokeWidth: element.props.strokeWidth || 0, strokeEnabled: true, fillAfterStrokeEnabled: true } ) }); }; var textElementRenderer = { type: "text", displayName: "Text", render: (element) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [ "Text: ", element.props.content ] }), // Placeholder for non-Konva contexts renderComponent: TextElementRenderer, // Konva rendering component icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.Type, { className: "w-4 h-4" }), defaultProps: { content: "Text", fontSize: 16, fontFamily: "Arial", color: "#000000", strokeColor: "#000000", strokeWidth: 0, align: "left", verticalAlign: "top", bold: false, italic: false, underline: false, overline: false, strikethrough: false, wordWrap: "break-word" }, defaultSize: { width: 200, height: 50 }, inspectorSchema: [ { name: "content", type: "string", label: "Text Content", defaultValue: "Text" }, { name: "fontSize", type: "number", label: "Font Size", min: 0, max: 1024, step: 1, defaultValue: 16 }, { name: "fontFamily", type: "select", label: "Font Family", options: [ { value: "Arial", label: "Arial" }, { value: "Times New Roman", label: "Times New Roman" }, { value: "Courier New", label: "Courier New" }, { value: "Georgia", label: "Georgia" }, { value: "Verdana", label: "Verdana" }, { value: "Outfit", label: "Outfit" } ], defaultValue: "Outfit" }, { name: "color", type: "color", label: "Text Color", defaultValue: "#000000" }, { name: "strokeColor", type: "color", label: "Stroke Color", defaultValue: "#000000" }, { name: "strokeWidth", type: "number", label: "Stroke Width", min: 0, max: 50, step: 1, defaultValue: 0 }, { name: "align", type: "select", label: "Horizontal Alignment", options: [ { value: "left", label: "Left" }, { value: "center", label: "Center" }, { value: "right", label: "Right" } ], defaultValue: "left" }, { name: "verticalAlign", type: "select", label: "Vertical Alignment", options: [ { value: "top", label: "Top" }, { value: "middle", label: "Middle" }, { value: "bottom", label: "Bottom" } ], defaultValue: "top" }, { name: "bold", type: "boolean", label: "Bold", defaultValue: false }, { name: "italic", type: "boolean", label: "Italic", defaultValue: false }, { name: "underline", type: "boolean", label: "Underline", defaultValue: false }, { name: "strikethrough", type: "boolean", label: "Strikethrough", defaultValue: false } ] }; // src/elements/ImageElement.tsx var import_react4 = __toESM(require("react")); var import_react_konva2 = require("react-konva"); var import_use_image = __toESM(require("use-image")); var import_lucide_react2 = require("lucide-react"); var import_jsx_runtime2 = require("react/jsx-runtime"); var ImageElementRenderer = ({ element, isSelected, onSelect, onTransform, allElements = [], canvasSize, onSnapGuides, onClearSnapGuides, imageUrls, elementId, onNodeUpdate }) => { const imageSrc = import_react4.default.useMemo(() => { const src = element.props.src; if (src && (src.startsWith("http") || src.startsWith("blob:") || src.startsWith("data:"))) { return src; } if (src && imageUrls) { const resolvedUrl = imageUrls.get(src); if (resolvedUrl) { return resolvedUrl; } } return src; }, [element.props.src, imageUrls]); const [image] = (0, import_use_image.default)(imageSrc); const shapeRef = import_react4.default.useRef(null); const transformerRef = import_react4.default.useRef(null); const isVisible = element.visible !== false; const isLocked = element.locked === true; import_react4.default.useEffect(() => { if (isSelected && transformerRef.current && shapeRef.current) { transformerRef.current.nodes([shapeRef.current]); transformerRef.current.getLayer().batchDraw(); } }, [isSelected]); import_react4.default.useEffect(() => { if (shapeRef.current) { const layer = shapeRef.current.getLayer(); if (layer) { layer.batchDraw(); } if (onNodeUpdate) { onNodeUpdate(); } } }, [image, onNodeUpdate]); if (!isVisible) { return null; } const handleClick = (e) => { if (isLocked) return; e.evt.button !== 0 ? void 0 : onSelect(); }; const handleDragMove = (e) => { if (!canvasSize || !onSnapGuides || e.evt.button !== 0) return; const node = e.target; const snapResult = getSnappingPosition(element, node.x(), node.y(), allElements, { threshold: 5, snapToElements: true, snapToCanvas: true, canvasSize }); node.x(snapResult.x); node.y(snapResult.y); onSnapGuides({ vertical: snapResult.verticalGuides, horizontal: snapResult.horizontalGuides }); }; const handleDragEnd = (e) => { if (onClearSnapGuides) { onClearSnapGuides(); } onTransform({ position: { x: e.target.x(), y: e.target.y() } }); }; return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, { children: image ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( import_react_konva2.Image, { ref: shapeRef, id: elementId || element.id, x: element.position.x, y: element.position.y, width: element.size.width, height: element.size.height, image, opacity: element.opacity, rotation: element.rotation, draggable: !isLocked && isSelected, listening: !isLocked, onClick: handleClick, onTap: isLocked ? void 0 : onSelect, onDragMove: handleDragMove, onDragEnd: handleDragEnd } ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)( import_react_konva2.Group, { ref: shapeRef, id: elementId || element.id, x: element.position.x, y: element.position.y, width: element.size.width, height: element.size.height, rotation: element.rotation, draggable: !isLocked && isSelected, listening: !isLocked, onClick: isLocked ? void 0 : handleClick, onTap: isLocked ? void 0 : onSelect, onDragMove: handleDragMove, onDragEnd: handleDragEnd, children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( import_react_konva2.Rect, { width: element.size.width, height: element.size.height, fill: "#f0f0f0", stroke: "#999999", strokeWidth: 2, dash: [10, 5], opacity: element.opacity } ), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( import_react_konva2.Text, { width: element.size.width, height: element.size.height, text: "No Image", fontSize: 16, fill: "#666666", align: "center", verticalAlign: "middle" } ) ] } ) }); }; var imageElementRenderer = { type: "image", displayName: "Image", render: (element) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [ "Image: ", element.props.src ] }), // Placeholder for non-Konva contexts renderComponent: ImageElementRenderer, // Konva rendering component icon: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Image, { className: "w-4 h-4" }), defaultProps: { src: "", fit: "fill" }, defaultSize: { width: 200, height: 200 }, inspectorSchema: [ { name: "src", type: "image", label: "Image Source", description: "URL or path to the image", defaultValue: "" }, { name: "fit", type: "select", label: "Fit Mode", options: [{ value: "fill", label: "Fill" }], defaultValue: "contain" } ] }; // src/elements/index.ts var defaultElements = [textElementRenderer, imageElementRenderer]; // src/core/VisualEditor.tsx var import_jsx_runtime3 = require("react/jsx-runtime"); var VisualEditor = ({ mode, initialData, width: propWidth, height: propHeight, readonly = false, onChange, onSelectionChange, onExport, customElements = [], showToolbar = true, showInspector = true, className = "", style = {} }) => { const containerRef = (0, import_react5.useRef)(null); const stageRef = (0, import_react5.useRef)(null); const { state, api, setCanvasSize, setMode: setEditorMode, undo, redo, canUndo, canRedo } = useEditorState(mode || null); const registry = useElementRegistry([ ...defaultElements, ...mode?.registeredElements || [], ...customElements ]); const canvasWidth = propWidth || state.canvasSize.width || mode?.defaultCanvasSize.width || 800; const canvasHeight = propHeight || state.canvasSize.height || mode?.defaultCanvasSize.height || 600; (0, import_react5.useEffect)(() => { if (propWidth && propHeight) { setCanvasSize(propWidth, propHeight); } }, [propWidth, propHeight, setCanvasSize]); (0, import_react5.useEffect)(() => { if (initialData) { api.importJSON(initialData); } }, [initialData]); (0, import_react5.useEffect)(() => { if (mode) { setEditorMode(mode); if (mode.registeredElements) { mode.registeredElements.forEach((el) => registry.register(el)); } if (mode.onModeActivate) { mode.onModeActivate(api); } } }, [mode]); (0, import_react5.useEffect)(() => { if (onChange) { const data = api.exportJSON(); onChange(data); } }, [state.elements, onChange, api]); (0, import_react5.useEffect)(() => { if (onSelectionChange) { const selected = api.getSelectedElement(); onSelectionChange(selected); } }, [state.selectedElementId, onSelectionChange, api]); (0, import_react5.useEffect)(() => { const handleKeyDown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "z" || (e.ctrlKey || e.metaKey) && e.key === "y") { e.preventDefault(); redo(); } if ((e.key === "Delete" || e.key === "Backspace") && !readonly) { e.preventDefault(); const selected = api.getSelectedElement(); if (selected) { api.removeElement(selected.id); } } if (e.key === "Escape") { e.preventDefault(); api.selectElement(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [undo, redo, readonly, api]); const renderElement = (0, import_react5.useCallback)( (element) => { const renderer = registry.get(element.type); if (!renderer) { console.warn(`No renderer found for element type: ${element.type}`); return null; } const RendererComponent = renderer.renderComponent; if (!RendererComponent) { console.warn(`No renderComponent found for element type: ${element.type}`); return null; } const isSelected = state.selectedElementId === element.id; return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( RendererComponent, { element, isSelected, onSelect: () => !readonly && api.selectElement(element.id), onTransform: (updates) => !readonly && api.updateElement(element.id, updates) }, element.id ); }, [registry, state.selectedElementId, readonly, api] ); const handleStageClick = (0, import_react5.useCallback)( (e) => { if (e.target === e.target.getStage()) { api.selectElement(null); } }, [api] ); const sortedElements = [...state.elements].sort((a, b) => a.zIndex - b.zIndex); return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( "div", { ref: containerRef, className: `visual-editor ${className}`, style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden", backgroundColor: mode?.backgroundColor || "#f0f0f0", ...style }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( "div", { style: { flex: 1, display: "flex", justifyContent: "center", alignItems: "center", overflow: "auto" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( import_react_konva3.Stage, { ref: stageRef, width: canvasWidth, height: canvasHeight, onClick: handleStageClick, onTap: handleStageClick, style: { backgroundColor: "#ffffff", boxShadow: "0 2px 8px rgba(0,0,0,0.1)" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_konva3.Layer, { children: sortedElements.map(renderElement) }) } ) } ) } ); }; // src/components/Inspector.tsx var import_react6 = __toESM(require("react")); // src/ui/input.tsx var React4 = __toESM(require("react")); // src/lib/utils.ts var import_clsx = require("clsx"); var import_tailwind_merge = require("tailwind-merge"); function cn(...inputs) { return (0, import_tailwind_merge.twMerge)((0, import_clsx.clsx)(inputs)); } // src/ui/input.tsx var import_jsx_runtime4 = require("react/jsx-runtime"); var Input = React4.forwardRef( ({ className, type, ...props }, ref) => { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( "input", { type, className: cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className ), ref, ...props } ); } ); Input.displayName = "Input"; // src/ui/label.tsx var React5 = __toESM(require("react")); var LabelPrimitive = __toESM(require("@radix-ui/react-label")); var import_class_variance_authority = require("class-variance-authority"); var import_jsx_runtime5 = require("react/jsx-runtime"); var labelVariants = (0, import_class_variance_authority.cva)( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" ); var Label = React5.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)( LabelPrimitive.Root, { ref, className: cn(labelVariants(), className), ...props } )); Label.displayName = LabelPrimitive.Root.displayName; // src/ui/slider.tsx var React6 = __toESM(require("react")); var SliderPrimitive = __toESM(require("@radix-ui/react-slider")); var import_jsx_runtime6 = require("react/jsx-runtime"); var Slider = React6.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)( SliderPrimitive.Root, { ref, className: cn( "relative flex w-full touch-none select-none items-center", className ), ...props, children: [ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(SliderPrimitive.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(SliderPrimitive.Range, { className: "absolute h-full bg-primary" }) }), /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(SliderPrimitive.Thumb, { className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" }) ] } )); Slider.displayName = SliderPrimitive.Root.displayName; // src/ui/separator.tsx var React7 = __toESM(require("react")); var SeparatorPrimitive = __toESM(require("@radix-ui/react-separator")); var import_jsx_runtime7 = require("react/jsx-runtime"); var Separator = React7.forwardRef( ({ className, orientation = "horizontal", decorative = true, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)( SeparatorPrimitive.Root, { ref, decorative, orientation, className: cn( "shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className ), ...props } ) ); Separator.displayName = SeparatorPrimitive.Root.displayName; // src/ui/select.tsx var React8 = __toESM(require("react")); var SelectPrimitive = __toESM(require("@radix-ui/