testeranto
Version:
the AI powered BDD test framework for typescript projects
401 lines (400 loc) • 16.2 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState, useCallback } from "react";
import { SVGAttributesEditor } from "./SVGEditor/SVGAttributesEditor";
import { SVGElementForm } from "./SVGEditor/SVGElementForm";
import { GenericXMLEditorPage } from "./GenericXMLEditorPage";
import { SVGPreview } from "./SVGEditor/SVGPreview";
export const SVGEditorPage = () => {
const initialTree = {
id: "root",
type: "svg",
attributes: {
width: "400",
height: "400",
viewBox: "0 0 400 400",
xmlns: "http://www.w3.org/2000/svg",
},
children: [
{
id: "rect-1",
type: "rect",
attributes: {
x: "50",
y: "50",
width: "100",
height: "100",
fill: "blue",
stroke: "black",
"stroke-width": "2",
},
children: [],
}
],
};
const [svgTree, setSvgTree] = useState(initialTree);
const [selectedNodeId, setSelectedNodeId] = useState(null);
const [activeTab, setActiveTab] = useState("preview");
const [history, setHistory] = useState([initialTree]);
const [historyIndex, setHistoryIndex] = useState(0);
const [editMode, setEditMode] = useState("none");
const [showGrid, setShowGrid] = useState(false);
const [snapToGrid, setSnapToGrid] = useState(false);
const [hiddenNodes, setHiddenNodes] = useState(new Set());
const [dragInfo, setDragInfo] = useState({
isDragging: false,
nodeId: null,
});
// Find a node by ID
const findNode = useCallback((node, id) => {
if (node.id === id)
return node;
for (const child of node.children) {
const found = findNode(child, id);
if (found)
return found;
}
return null;
}, []);
// Helper to update tree and manage history
const updateTree = useCallback((newTree) => {
setSvgTree(newTree);
// Add to history, truncating future history if we're not at the end
setHistory((prevHistory) => {
const newHistory = prevHistory.slice(0, historyIndex + 1);
newHistory.push(newTree);
return newHistory;
});
setHistoryIndex((prevIndex) => prevIndex + 1);
}, [historyIndex]);
// Update node attributes
const updateNodeAttributes = useCallback((nodeId, attributes) => {
setSvgTree((prevTree) => {
const newTree = JSON.parse(JSON.stringify(prevTree));
const node = findNode(newTree, nodeId);
if (node) {
node.attributes = Object.assign(Object.assign({}, node.attributes), attributes);
}
updateTree(newTree);
return newTree;
});
}, [findNode, updateTree]);
// Update node text content
const updateNodeTextContent = useCallback((nodeId, textContent) => {
setSvgTree((prevTree) => {
const newTree = JSON.parse(JSON.stringify(prevTree));
const node = findNode(newTree, nodeId);
if (node) {
// For text and tspan nodes, we need to handle text content
// This is a simplified approach - in a real implementation, you'd track text content separately
// For now, we'll add a special attribute to track the text content
node.attributes['data-text-content'] = textContent;
}
updateTree(newTree);
return newTree;
});
}, [findNode, updateTree]);
// Toggle node visibility
const toggleNodeVisibility = useCallback((nodeId) => {
setHiddenNodes((prev) => {
const newHidden = new Set(prev);
if (newHidden.has(nodeId)) {
newHidden.delete(nodeId);
}
else {
newHidden.add(nodeId);
}
return newHidden;
});
}, []);
// Handle drag start
const handleDragStart = useCallback((nodeId) => {
setDragInfo({ isDragging: true, nodeId });
}, []);
// Handle drag end
const handleDragEnd = useCallback(() => {
setDragInfo({ isDragging: false, nodeId: null });
}, []);
// Move a node
const moveNode = useCallback((nodeId, newParentId, index) => {
setSvgTree((prevTree) => {
const newTree = JSON.parse(JSON.stringify(prevTree));
// Find the node to move and its original parent
let nodeToMove = null;
let oldParent = null;
const findNodeAndParent = (current, parent = null) => {
if (current.id === nodeId) {
nodeToMove = current;
oldParent = parent;
return true;
}
for (const child of current.children) {
if (findNodeAndParent(child, current))
return true;
}
return false;
};
findNodeAndParent(newTree);
if (!nodeToMove || !oldParent)
return prevTree;
// Remove from old parent
oldParent.children = oldParent.children.filter((child) => child.id !== nodeId);
// Find new parent
let targetParent = null;
if (newParentId === null) {
targetParent = newTree;
}
else {
const findParent = (current) => {
if (current.id === newParentId)
return current;
for (const child of current.children) {
const found = findParent(child);
if (found)
return found;
}
return null;
};
targetParent = findParent(newTree);
}
if (!targetParent)
return prevTree;
// Insert at specified index
targetParent.children.splice(index, 0, nodeToMove);
updateTree(newTree);
return newTree;
});
}, [updateTree]);
// Add a new child node
const addChildNode = useCallback((parentId, nodeType) => {
setSvgTree((prevTree) => {
const newTree = JSON.parse(JSON.stringify(prevTree));
const parent = findNode(newTree, parentId);
if (parent) {
// Default attributes based on node type to make them visible
let defaultAttributes = {};
switch (nodeType) {
case "rect":
defaultAttributes = {
x: "50",
y: "50",
width: "100",
height: "100",
fill: "blue",
stroke: "black",
"stroke-width": "2",
};
break;
case "circle":
defaultAttributes = {
cx: "100",
cy: "100",
r: "50",
fill: "red",
stroke: "black",
"stroke-width": "2",
};
break;
case "path":
defaultAttributes = {
d: "M50 50 L150 150",
stroke: "green",
"stroke-width": "3",
fill: "none",
};
break;
case "text":
defaultAttributes = {
x: "50",
y: "50",
fill: "black",
"font-size": "16",
};
break;
case "g":
defaultAttributes = {
transform: "translate(0,0)",
};
break;
default:
defaultAttributes = {};
}
const newNode = {
id: `${nodeType}-${Date.now()}`,
type: nodeType,
attributes: defaultAttributes,
children: [],
};
parent.children = [...parent.children, newNode];
setSelectedNodeId(newNode.id);
updateTree(newTree);
}
return newTree;
});
}, [findNode, updateTree]);
// Remove a node
const removeNode = useCallback((nodeId) => {
setSvgTree((prevTree) => {
const newTree = JSON.parse(JSON.stringify(prevTree));
// Function to remove node from its parent's children
const removeFromParent = (node, targetId) => {
for (let i = 0; i < node.children.length; i++) {
if (node.children[i].id === targetId) {
node.children.splice(i, 1);
return true;
}
if (removeFromParent(node.children[i], targetId)) {
return true;
}
}
return false;
};
removeFromParent(newTree, nodeId);
if (selectedNodeId === nodeId) {
setSelectedNodeId(null);
}
updateTree(newTree);
return newTree;
});
}, [selectedNodeId, updateTree]);
// Undo/Redo handlers
const handleUndo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prevIndex) => prevIndex - 1);
setSvgTree(history[historyIndex - 1]);
}
}, [history, historyIndex]);
const handleRedo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prevIndex) => prevIndex + 1);
setSvgTree(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// Move handler - for now, just log to console
const handleMove = useCallback(() => {
console.log("Move functionality to be implemented");
// This would typically involve setting up a mode where nodes can be dragged
}, []);
// Helper variables for edit modes
const isMoveMode = editMode === "move";
const isScaleMode = editMode === "scale";
const isRotateMode = editMode === "rotate";
const selectedNode = selectedNodeId
? findNode(svgTree, selectedNodeId)
: null;
// Define node types for SVG
const svgNodeTypes = [
{ label: 'Rectangle', type: 'rect' },
{ label: 'Circle', type: 'circle' },
{ label: 'Path', type: 'path' },
{ label: 'Text', type: 'text' },
{ label: 'Group', type: 'g' }
];
// Function to create new SVG nodes
const createSVGNode = (parentId, nodeType) => {
// Default attributes based on node type
let defaultAttributes = {};
switch (nodeType) {
case "rect":
defaultAttributes = {
x: "50",
y: "50",
width: "100",
height: "100",
fill: "blue",
stroke: "black",
"stroke-width": "2",
};
break;
case "circle":
defaultAttributes = {
cx: "100",
cy: "100",
r: "50",
fill: "red",
stroke: "black",
"stroke-width": "2",
};
break;
case "path":
defaultAttributes = {
d: "M50 50 L150 150",
stroke: "green",
"stroke-width": "3",
fill: "none",
};
break;
case "text":
defaultAttributes = {
x: "50",
y: "50",
fill: "black",
"font-size": "16",
};
break;
case "g":
defaultAttributes = {
transform: "translate(0,0)",
};
break;
default:
defaultAttributes = {};
}
return {
id: `${nodeType}-${Date.now()}`,
type: nodeType,
attributes: defaultAttributes,
children: [],
};
};
// Render preview for SVG
const renderSVGPreview = (node, isSelected, eventHandlers) => {
const { type, attributes, children, textContent } = node;
const style = isSelected ? { outline: '2px dashed blue' } : {};
// Convert children to React elements
const childElements = children.map(child => renderSVGPreview(child, selectedNodeId === child.id, {}));
// Handle text content for text elements
if (type === 'text' || type === 'tspan') {
return React.createElement(type, Object.assign(Object.assign(Object.assign({ key: node.id }, attributes), eventHandlers), { style }), textContent, ...childElements);
}
// For other SVG elements
// Note: React.createElement expects the type to be a string for HTML/SVG elements
// Make sure the type is always lowercase
const elementType = type.toLowerCase();
return React.createElement(elementType, Object.assign(Object.assign(Object.assign({ key: node.id }, attributes), eventHandlers), { style }), childElements);
};
// Attribute editor for SVG
const renderSVGAttributeEditor = (node, onUpdateAttributes, onUpdateTextContent) => {
// Use SVGElementForm for supported types, fallback to SVGAttributesEditor
if (["circle", "rect", "g"].includes(node.type)) {
return (React.createElement(SVGElementForm, { elementType: node.type, attributes: node.attributes, onChange: onUpdateAttributes }));
}
else {
return (React.createElement(SVGAttributesEditor, { node: node, onUpdateAttributes: onUpdateAttributes, onUpdateTextContent: onUpdateTextContent }));
}
};
// Custom SVG preview that uses the original SVGPreview component
const SvgCustomPreview = () => {
// Convert XMLNode to SVGNode
const svgTree = {
id: xmlTree.id,
type: xmlTree.type,
attributes: xmlTree.attributes,
children: xmlTree.children
};
return (React.createElement(SVGPreview, { svgTree: svgTree, editMode: editMode, showGrid: showGrid, selectedNodeId: selectedNodeId, onNodeInteraction: (nodeId, updates) => {
// Update node attributes based on interaction
updateNodeAttributes(nodeId, updates);
}, hiddenNodes: hiddenNodes }));
};
// Modify renderSVGPreview to use our custom preview
const renderSVGPreviewWithControls = (node, isSelected, eventHandlers) => {
// For the generic preview, we'll still use the basic rendering
// The SVG-specific interactions are handled by SVGPreview component
return renderSVGPreview(node, isSelected, eventHandlers);
};
// For the SVG editor, we'll need to create a custom implementation
// that properly integrates the SVGPreview component
// Let's use the GenericXMLEditorPage but override the preview when in SVG mode
return (React.createElement(GenericXMLEditorPage, { initialTree: initialTree, renderPreview: renderSVGPreviewWithControls, attributeEditor: renderSVGAttributeEditor, nodeTypes: svgNodeTypes, onAddNode: createSVGNode }));
};