WIP svg editor
This commit is contained in:
491
app/assets/js/device-type-shape-editor.js
Normal file
491
app/assets/js/device-type-shape-editor.js
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user