diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js new file mode 100644 index 0000000..c1e5d4e --- /dev/null +++ b/app/assets/js/device-type-shape-editor.js @@ -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); +})(); diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index 7ac02f0..3c1b1c6 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -110,74 +110,97 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
- Gerätedesign (Rechtecke, Kreise, Text) + Gerätedesign (SVG-Editor) -
+
+
+

Werkzeuge

+

Element in die Zeichenfläche ziehen, dann per Klick auswählen und bearbeiten.

+
+ + + +
+
+
- + +

Bestehende SVG-Objekte sind anklickbar und per Drag-and-Drop verschiebbar.

-
-
- - -
-
- - - - - - - - - -
+
+

Objekt-Parameter

+

Kein Objekt ausgewählt.

- -

Shapes werden als JSON gespeichert und können jederzeit angepasst werden.

+
- -
-

Shapes

-
    -