@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
JavaScript
"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/