@deckedout/visual-editor
Version:
A flexible visual editor for building interactive canvases with drag-and-drop elements
1,659 lines (1,639 loc) • 138 kB
JavaScript
"use client"
// src/core/VisualEditor.tsx
import { useEffect, useCallback as useCallback2, useRef } from "react";
import { Stage, Layer } from "react-konva";
// src/core/useEditorState.ts
import { useReducer, useCallback, useMemo } from "react";
// src/utils/editorUtils.ts
import { v4 as uuidv4 } from "uuid";
var generateElementId = () => {
return `element-${uuidv4()}`;
};
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] = useReducer(editorReducer, createInitialState(initialMode));
const addElement = useCallback((element) => {
dispatch({ type: "ADD_ELEMENT", element });
}, []);
const updateElement = useCallback((id, updates) => {
dispatch({ type: "UPDATE_ELEMENT", id, updates });
}, []);
const removeElement = useCallback((id) => {
dispatch({ type: "REMOVE_ELEMENT", id });
}, []);
const selectElement = useCallback((id) => {
dispatch({ type: "SELECT_ELEMENT", id });
}, []);
const getSelectedElement = useCallback(() => {
if (!state.selectedElementId) return null;
return state.elements.find((el) => el.id === state.selectedElementId) || null;
}, [state.selectedElementId, state.elements]);
const getAllElements = useCallback(() => {
return state.elements;
}, [state.elements]);
const moveElement = 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 = useCallback(
(id, angle) => {
updateElement(id, { rotation: angle });
},
[updateElement]
);
const resizeElement = useCallback(
(id, width, height) => {
updateElement(id, {
size: { width, height }
});
},
[updateElement]
);
const updateZIndex = useCallback(
(id, zIndex) => {
updateElement(id, { zIndex });
},
[updateElement]
);
const reorderElement = useCallback((id, newIndex) => {
dispatch({ type: "REORDER_ELEMENT", elementId: id, newIndex });
}, []);
const exportJSON = 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 = useCallback((data) => {
dispatch({
type: "SET_CANVAS_SIZE",
width: data.width,
height: data.height
});
dispatch({ type: "SET_ELEMENTS", elements: data.elements });
}, []);
const clear = useCallback(() => {
dispatch({ type: "CLEAR" });
}, []);
const setCanvasSize = useCallback((width, height) => {
dispatch({ type: "SET_CANVAS_SIZE", width, height });
}, []);
const setMode = useCallback((mode) => {
dispatch({ type: "SET_MODE", mode });
}, []);
const undo = useCallback(() => {
dispatch({ type: "UNDO" });
}, []);
const redo = useCallback(() => {
dispatch({ type: "REDO" });
}, []);
const copyElement = 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 = 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 = 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 = useCallback(() => {
state.history.past = [];
state.history.future = [];
}, []);
const loadElements = useCallback((elements) => {
dispatch({ type: "LOAD_ELEMENTS", elements });
}, []);
const api = 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
import { useMemo as useMemo2 } from "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 useMemo2(() => {
const registry = new ElementRegistry();
if (initialRenderers) {
registry.registerMany(initialRenderers);
}
return registry;
}, [initialRenderers]);
};
// src/elements/TextElement.tsx
import React from "react";
import { Text } from "react-konva";
import { Type } from "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
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var TextElementRenderer = ({
element,
isSelected,
onSelect,
onTransform,
allElements = [],
canvasSize,
onSnapGuides,
onClearSnapGuides,
elementId
}) => {
const shapeRef = React.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__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(
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__ */ jsxs("div", { children: [
"Text: ",
element.props.content
] }),
// Placeholder for non-Konva contexts
renderComponent: TextElementRenderer,
// Konva rendering component
icon: /* @__PURE__ */ jsx(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
import React2 from "react";
import { Image as KonvaImage, Rect, Text as Text2, Group } from "react-konva";
import useImage from "use-image";
import { Image } from "lucide-react";
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
var ImageElementRenderer = ({
element,
isSelected,
onSelect,
onTransform,
allElements = [],
canvasSize,
onSnapGuides,
onClearSnapGuides,
imageUrls,
elementId,
onNodeUpdate
}) => {
const imageSrc = React2.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] = useImage(imageSrc);
const shapeRef = React2.useRef(null);
const transformerRef = React2.useRef(null);
const isVisible = element.visible !== false;
const isLocked = element.locked === true;
React2.useEffect(() => {
if (isSelected && transformerRef.current && shapeRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer().batchDraw();
}
}, [isSelected]);
React2.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__ */ jsx2(Fragment2, { children: image ? /* @__PURE__ */ jsx2(
KonvaImage,
{
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__ */ jsxs2(
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__ */ jsx2(
Rect,
{
width: element.size.width,
height: element.size.height,
fill: "#f0f0f0",
stroke: "#999999",
strokeWidth: 2,
dash: [10, 5],
opacity: element.opacity
}
),
/* @__PURE__ */ jsx2(
Text2,
{
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__ */ jsxs2("div", { children: [
"Image: ",
element.props.src
] }),
// Placeholder for non-Konva contexts
renderComponent: ImageElementRenderer,
// Konva rendering component
icon: /* @__PURE__ */ jsx2(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
import { jsx as jsx3 } from "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 = useRef(null);
const stageRef = 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;
useEffect(() => {
if (propWidth && propHeight) {
setCanvasSize(propWidth, propHeight);
}
}, [propWidth, propHeight, setCanvasSize]);
useEffect(() => {
if (initialData) {
api.importJSON(initialData);
}
}, [initialData]);
useEffect(() => {
if (mode) {
setEditorMode(mode);
if (mode.registeredElements) {
mode.registeredElements.forEach((el) => registry.register(el));
}
if (mode.onModeActivate) {
mode.onModeActivate(api);
}
}
}, [mode]);
useEffect(() => {
if (onChange) {
const data = api.exportJSON();
onChange(data);
}
}, [state.elements, onChange, api]);
useEffect(() => {
if (onSelectionChange) {
const selected = api.getSelectedElement();
onSelectionChange(selected);
}
}, [state.selectedElementId, onSelectionChange, api]);
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 = useCallback2(
(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__ */ jsx3(
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 = useCallback2(
(e) => {
if (e.target === e.target.getStage()) {
api.selectElement(null);
}
},
[api]
);
const sortedElements = [...state.elements].sort((a, b) => a.zIndex - b.zIndex);
return /* @__PURE__ */ jsx3(
"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__ */ jsx3(
"div",
{
style: {
flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "auto"
},
children: /* @__PURE__ */ jsx3(
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__ */ jsx3(Layer, { children: sortedElements.map(renderElement) })
}
)
}
)
}
);
};
// src/components/Inspector.tsx
import React11 from "react";
// src/ui/input.tsx
import * as React4 from "react";
// src/lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs) {
return twMerge(clsx(inputs));
}
// src/ui/input.tsx
import { jsx as jsx4 } from "react/jsx-runtime";
var Input = React4.forwardRef(
({ className, type, ...props }, ref) => {
return /* @__PURE__ */ jsx4(
"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
import * as React5 from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva } from "class-variance-authority";
import { jsx as jsx5 } from "react/jsx-runtime";
var labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
var Label = React5.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx5(
LabelPrimitive.Root,
{
ref,
className: cn(labelVariants(), className),
...props
}
));
Label.displayName = LabelPrimitive.Root.displayName;
// src/ui/slider.tsx
import * as React6 from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
var Slider = React6.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsxs3(
SliderPrimitive.Root,
{
ref,
className: cn(
"relative flex w-full touch-none select-none items-center",
className
),
...props,
children: [
/* @__PURE__ */ jsx6(SliderPrimitive.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: /* @__PURE__ */ jsx6(SliderPrimitive.Range, { className: "absolute h-full bg-primary" }) }),
/* @__PURE__ */ jsx6(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
import * as React7 from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { jsx as jsx7 } from "react/jsx-runtime";
var Separator = React7.forwardRef(
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => /* @__PURE__ */ jsx7(
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
import * as React8 from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
var Select = SelectPrimitive.Root;
var SelectValue = SelectPrimitive.Value;
var SelectTrigger = React8.forwardRef(({ className, children, ...props }, ref) => /* @__PURE__ */ jsxs4(
SelectPrimitive.Trigger,
{
ref,
className: cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
),
...props,
children: [
children,
/* @__PURE__ */ jsx8(SelectPrimitive.Icon, { asChild: true, children: /* @__PURE__ */ jsx8(ChevronDown, { className: "h-4 w-4 opacity-50" }) })
]
}
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
var SelectScrollUpButton = React8.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx8(
SelectPrimitive.ScrollUpButton,
{
ref,
className: cn(
"flex cursor-default items-center justify-center py-1",
className
),
...props,
children: /* @__PURE__ */ jsx8(ChevronUp, { className: "h-4 w-4" })
}
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
var SelectScrollDownButton = React8.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx8(
SelectPrimitive.ScrollDownButton,
{
ref,
className: cn(
"flex cursor-default items-center justify-center py-1",
className
),
...props,
children: /* @__PURE__ */ jsx8(ChevronDown, { className: "h-4 w-4" })
}
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
var SelectContent = React8.forwardRef(({ className, children, position = "popper", ...props }, ref) => /* @__PURE__ */ jsx8(SelectPrimitive.Portal, { children: /* @__PURE__ */ jsxs4(
SelectPrimitive.Content,
{
ref,
className: cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
),
position,
...props,
children: [
/* @__PURE__ */ jsx8(SelectScrollUpButton, {}),
/* @__PURE__ */ jsx8(
SelectPrimitive.Viewport,
{
className: cn(
"p-1",
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
),
children
}
),
/* @__PURE__ */ jsx8(SelectScrollDownButton, {})
]
}
) }));
SelectContent.displayName = SelectPrimitive.Content.displayName;
var SelectLabel = React8.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx8(
SelectPrimitive.Label,
{
ref,
className: cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className),
...props
}
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
var SelectItem = React8.forwardRef(({ className, children, ...props }, ref) => /* @__PURE__ */ jsxs4(
SelectPrimitive.Item,
{
ref,
className: cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
),
...props,
children: [
/* @__PURE__ */ jsx8("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: /* @__PURE__ */ jsx8(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx8(Check, { className: "h-4 w-4" }) }) }),
/* @__PURE__ */ jsx8(SelectPrimitive.ItemText, { children })
]
}
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
var SelectSeparator = React8.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx8(
SelectPrimitive.Separator,
{
ref,
className: cn("-mx-1 my-1 h-px bg-muted", className),
...props
}
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
// src/ui/textarea.tsx
import * as React9 from "react";
i