WIP svg editor

This commit is contained in:
Troy Grunt
2026-02-11 22:09:47 +01:00
parent 88ff04aa01
commit c31e1a308d
2 changed files with 660 additions and 268 deletions

View File

@@ -0,0 +1,491 @@
(() => {
const SVG_NS = 'http://www.w3.org/2000/svg';
function initEditor() {
const editor = document.getElementById('device-type-shape-editor');
const svg = document.getElementById('shape-canvas');
const hiddenInput = document.getElementById('shape-definition');
if (!editor || !svg || !hiddenInput) {
return;
}
const overlay = {
empty: document.getElementById('shape-overlay-empty'),
form: document.getElementById('shape-overlay-form'),
type: document.getElementById('shape-param-type'),
x: document.getElementById('shape-param-x'),
y: document.getElementById('shape-param-y'),
width: document.getElementById('shape-param-width'),
height: document.getElementById('shape-param-height'),
radius: document.getElementById('shape-param-radius'),
fontSize: document.getElementById('shape-param-font-size'),
text: document.getElementById('shape-param-text'),
fill: document.getElementById('shape-param-fill'),
stroke: document.getElementById('shape-param-stroke'),
strokeWidth: document.getElementById('shape-param-stroke-width'),
isPort: document.getElementById('shape-param-is-port'),
portName: document.getElementById('shape-param-port-name'),
portNameLabel: document.getElementById('shape-port-name-label'),
deleteButton: document.getElementById('shape-delete')
};
const fieldVisibility = {
width: editor.querySelector('[data-field="width"]'),
height: editor.querySelector('[data-field="height"]'),
radius: editor.querySelector('[data-field="radius"]'),
text: editor.querySelector('[data-field="text"]'),
font_size: editor.querySelector('[data-field="font_size"]')
};
let draggedTemplate = null;
let dragState = {
active: false,
shapeId: null,
offsetX: 0,
offsetY: 0
};
let selectedShapeId = null;
let shapes = normalizeShapeList(readJson(hiddenInput.value));
bindToolbarDragEvents(editor);
bindCanvasDropEvents(svg);
bindCanvasPointerEvents(svg);
bindOverlayEvents(overlay);
render();
function bindToolbarDragEvents(root) {
const tools = root.querySelectorAll('.shape-tool[data-shape-template]');
tools.forEach((tool) => {
tool.addEventListener('dragstart', (event) => {
draggedTemplate = String(tool.dataset.shapeTemplate || '').trim();
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', draggedTemplate);
}
});
tool.addEventListener('dragend', () => {
draggedTemplate = null;
});
});
}
function bindCanvasDropEvents(canvas) {
const canvasWrap = canvas.closest('.shape-editor-canvas');
if (!canvasWrap) {
return;
}
canvasWrap.addEventListener('dragover', (event) => {
if (!draggedTemplate) {
return;
}
event.preventDefault();
canvasWrap.classList.add('drag-over');
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
});
canvasWrap.addEventListener('dragleave', () => {
canvasWrap.classList.remove('drag-over');
});
canvasWrap.addEventListener('drop', (event) => {
if (!draggedTemplate) {
return;
}
event.preventDefault();
canvasWrap.classList.remove('drag-over');
const point = toSvgPoint(event, canvas);
const shape = createDefaultShape(draggedTemplate, point.x, point.y);
shapes.push(shape);
selectedShapeId = shape.id;
persist();
render();
});
}
function bindCanvasPointerEvents(canvas) {
canvas.addEventListener('pointerdown', (event) => {
const target = event.target;
if (!(target instanceof SVGElement)) {
return;
}
const shapeElement = target.closest('[data-shape-id]');
if (!shapeElement) {
selectedShapeId = null;
renderOverlay();
return;
}
const shapeId = shapeElement.getAttribute('data-shape-id');
const shape = findShape(shapeId);
if (!shape) {
return;
}
selectedShapeId = shape.id;
renderOverlay();
const point = toSvgPoint(event, canvas);
const anchor = getShapeAnchor(shape);
dragState = {
active: true,
shapeId: shape.id,
offsetX: anchor.x - point.x,
offsetY: anchor.y - point.y
};
shapeElement.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', (event) => {
if (!dragState.active || !dragState.shapeId) {
return;
}
const shape = findShape(dragState.shapeId);
if (!shape) {
return;
}
const point = toSvgPoint(event, canvas);
const x = Math.round(point.x + dragState.offsetX);
const y = Math.round(point.y + dragState.offsetY);
setShapeAnchor(shape, x, y);
persist();
render();
});
canvas.addEventListener('pointerup', () => {
dragState.active = false;
dragState.shapeId = null;
});
canvas.addEventListener('pointercancel', () => {
dragState.active = false;
dragState.shapeId = null;
});
}
function bindOverlayEvents(o) {
const inputs = [
o.x, o.y, o.width, o.height, o.radius, o.fontSize, o.text, o.fill, o.stroke, o.strokeWidth, o.isPort, o.portName
];
inputs.forEach((input) => {
if (!input) {
return;
}
const eventName = input.type === 'checkbox' ? 'change' : 'input';
input.addEventListener(eventName, applyOverlayToSelectedShape);
});
o.deleteButton.addEventListener('click', () => {
if (!selectedShapeId) {
return;
}
shapes = shapes.filter((shape) => shape.id !== selectedShapeId);
selectedShapeId = null;
persist();
render();
});
}
function applyOverlayToSelectedShape() {
const shape = findShape(selectedShapeId);
if (!shape) {
return;
}
shape.x = toNumberOrDefault(overlay.x.value, shape.x);
shape.y = toNumberOrDefault(overlay.y.value, shape.y);
shape.width = toNumberOrDefault(overlay.width.value, shape.width);
shape.height = toNumberOrDefault(overlay.height.value, shape.height);
shape.radius = toNumberOrDefault(overlay.radius.value, shape.radius);
shape.fontSize = toNumberOrDefault(overlay.fontSize.value, shape.fontSize);
shape.text = String(overlay.text.value || shape.text || 'Text');
shape.fill = normalizeColor(overlay.fill.value, shape.fill);
shape.stroke = normalizeColor(overlay.stroke.value, shape.stroke);
shape.strokeWidth = toNumberOrDefault(overlay.strokeWidth.value, shape.strokeWidth);
shape.isPort = overlay.isPort.checked;
if (shape.isPort) {
shape.portName = String(overlay.portName.value || '').trim();
} else {
shape.portName = '';
}
persist();
render();
}
function render() {
renderCanvas();
renderOverlay();
}
function renderCanvas() {
svg.querySelectorAll('.shape-object').forEach((el) => el.remove());
shapes.forEach((shape) => {
const element = createSvgShapeElement(shape);
if (!element) {
return;
}
element.classList.add('shape-object');
if (shape.id === selectedShapeId) {
element.classList.add('is-selected');
}
if (shape.isPort) {
element.classList.add('is-port');
}
element.setAttribute('data-shape-id', shape.id);
svg.appendChild(element);
});
}
function renderOverlay() {
const selected = findShape(selectedShapeId);
const hasSelection = !!selected;
overlay.empty.hidden = hasSelection;
overlay.form.hidden = !hasSelection;
if (!selected) {
return;
}
overlay.type.value = selected.type;
overlay.x.value = selected.x;
overlay.y.value = selected.y;
overlay.width.value = selected.width;
overlay.height.value = selected.height;
overlay.radius.value = selected.radius;
overlay.fontSize.value = selected.fontSize;
overlay.text.value = selected.text || '';
overlay.fill.value = normalizeColor(selected.fill, '#cccccc');
overlay.stroke.value = normalizeColor(selected.stroke, '#333333');
overlay.strokeWidth.value = selected.strokeWidth;
overlay.isPort.checked = !!selected.isPort;
overlay.portName.value = selected.portName || '';
const isRect = selected.type === 'rect';
const isCircle = selected.type === 'circle';
const isText = selected.type === 'text';
setFieldVisible(fieldVisibility.width, isRect);
setFieldVisible(fieldVisibility.height, isRect);
setFieldVisible(fieldVisibility.radius, isCircle);
setFieldVisible(fieldVisibility.text, isText);
setFieldVisible(fieldVisibility.font_size, isText);
overlay.portNameLabel.hidden = !selected.isPort;
}
function persist() {
hiddenInput.value = JSON.stringify(shapes);
}
function findShape(id) {
if (!id) {
return null;
}
return shapes.find((shape) => shape.id === id) || null;
}
}
function createSvgShapeElement(shape) {
const fill = normalizeColor(shape.fill, '#cccccc');
const stroke = normalizeColor(shape.stroke, '#333333');
const strokeWidth = shape.strokeWidth > 0 ? shape.strokeWidth : 1;
if (shape.type === 'rect') {
const rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', String(shape.x));
rect.setAttribute('y', String(shape.y));
rect.setAttribute('width', String(shape.width));
rect.setAttribute('height', String(shape.height));
rect.setAttribute('fill', fill);
rect.setAttribute('stroke', stroke);
rect.setAttribute('stroke-width', String(strokeWidth));
return rect;
}
if (shape.type === 'circle') {
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', String(shape.x));
circle.setAttribute('cy', String(shape.y));
circle.setAttribute('r', String(shape.radius));
circle.setAttribute('fill', fill);
circle.setAttribute('stroke', stroke);
circle.setAttribute('stroke-width', String(strokeWidth));
return circle;
}
if (shape.type === 'text') {
const text = document.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(shape.x));
text.setAttribute('y', String(shape.y));
text.setAttribute('fill', fill);
text.setAttribute('font-size', String(shape.fontSize));
text.setAttribute('text-anchor', 'start');
text.setAttribute('dominant-baseline', 'hanging');
text.textContent = shape.text || 'Text';
return text;
}
return null;
}
function normalizeShapeList(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((shape, index) => {
const normalizedType = ['rect', 'circle', 'text'].includes(shape.type) ? shape.type : 'rect';
const legacyRadius = toNumberOrDefault(shape.r, 26);
return {
id: String(shape.id || `shape_${Date.now()}_${index}`),
type: normalizedType,
x: toNumberOrDefault(shape.x, 20),
y: toNumberOrDefault(shape.y, 20),
width: toNumberOrDefault(shape.width, 120),
height: toNumberOrDefault(shape.height, 60),
radius: toNumberOrDefault(shape.radius, legacyRadius),
text: String(shape.text || 'Text'),
fontSize: toNumberOrDefault(shape.fontSize, 16),
fill: normalizeColor(shape.fill, '#cccccc'),
stroke: normalizeColor(shape.stroke, '#333333'),
strokeWidth: toNumberOrDefault(shape.strokeWidth, 1),
isPort: !!shape.isPort,
portName: String(shape.portName || '')
};
});
}
function createDefaultShape(type, x, y) {
const uid = `shape_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
if (type === 'circle') {
return {
id: uid,
type: 'circle',
x: Math.round(x),
y: Math.round(y),
width: 0,
height: 0,
radius: 26,
text: '',
fontSize: 16,
fill: '#cfe6ff',
stroke: '#1f5f99',
strokeWidth: 1,
isPort: false,
portName: ''
};
}
if (type === 'text') {
return {
id: uid,
type: 'text',
x: Math.round(x),
y: Math.round(y),
width: 0,
height: 0,
radius: 0,
text: 'Text',
fontSize: 16,
fill: '#2a2a2a',
stroke: '#2a2a2a',
strokeWidth: 0,
isPort: false,
portName: ''
};
}
return {
id: uid,
type: 'rect',
x: Math.round(x),
y: Math.round(y),
width: 120,
height: 60,
radius: 0,
text: '',
fontSize: 16,
fill: '#d9e8b3',
stroke: '#4d5f27',
strokeWidth: 1,
isPort: false,
portName: ''
};
}
function getShapeAnchor(shape) {
if (shape.type === 'circle') {
return { x: shape.x, y: shape.y };
}
return { x: shape.x, y: shape.y };
}
function setShapeAnchor(shape, x, y) {
shape.x = x;
shape.y = y;
}
function setFieldVisible(field, visible) {
if (!field) {
return;
}
field.hidden = !visible;
}
function toSvgPoint(event, svg) {
const pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) {
return { x: 0, y: 0 };
}
const p = pt.matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function toNumberOrDefault(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function readJson(raw) {
if (!raw || !String(raw).trim()) {
return [];
}
try {
return JSON.parse(raw);
} catch (error) {
return [];
}
}
function normalizeColor(value, fallback) {
const v = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
return v;
}
return fallback;
}
document.addEventListener('DOMContentLoaded', initEditor);
})();