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);
|
||||
})();
|
||||
@@ -110,74 +110,97 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Gerätedesign (Rechtecke, Kreise, Text)</legend>
|
||||
<legend>Gerätedesign (SVG-Editor)</legend>
|
||||
|
||||
<input type="hidden" name="shape_definition" id="shape-definition" value="<?php echo htmlspecialchars($shapeDefinition); ?>">
|
||||
|
||||
<div class="shape-editor">
|
||||
<div class="shape-editor" id="device-type-shape-editor">
|
||||
<div class="shape-toolbox">
|
||||
<h4>Werkzeuge</h4>
|
||||
<p class="hint">Element in die Zeichenfläche ziehen, dann per Klick auswählen und bearbeiten.</p>
|
||||
<div class="shape-tool-list">
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="rect">Rechteck</button>
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="circle">Kreis</button>
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="text">Text</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shape-editor-canvas">
|
||||
<svg id="shape-canvas" viewBox="0 0 400 200" role="img" aria-label="Gerätezeichnung">
|
||||
<svg id="shape-canvas" viewBox="0 0 800 360" role="img" aria-label="Gerätezeichnung">
|
||||
<rect width="100%" height="100%" fill="#f8f8f8" stroke="#ddd" stroke-width="1"></rect>
|
||||
</svg>
|
||||
<p class="hint">Bestehende SVG-Objekte sind anklickbar und per Drag-and-Drop verschiebbar.</p>
|
||||
</div>
|
||||
<div class="shape-editor-controls">
|
||||
<div class="form-group">
|
||||
<label for="shape-type">Form</label>
|
||||
<select id="shape-type">
|
||||
<option value="rect">Rechteck</option>
|
||||
<option value="circle">Kreis</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="shape-control-grid">
|
||||
<label>
|
||||
x
|
||||
<input type="number" id="shape-x" value="20" step="1">
|
||||
</label>
|
||||
<label>
|
||||
y
|
||||
<input type="number" id="shape-y" value="20" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Breite
|
||||
<input type="number" id="shape-width" value="120" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Höhe
|
||||
<input type="number" id="shape-height" value="60" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Radius
|
||||
<input type="number" id="shape-radius" value="30" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Text
|
||||
<input type="text" id="shape-text" value="Label">
|
||||
</label>
|
||||
<label>
|
||||
Füllung
|
||||
<input type="color" id="shape-fill" value="#cccccc">
|
||||
</label>
|
||||
<label>
|
||||
Strich
|
||||
<input type="color" id="shape-stroke" value="#333333">
|
||||
</label>
|
||||
<label>
|
||||
Strichbreite
|
||||
<input type="number" id="shape-stroke-width" value="1" step="0.5">
|
||||
</label>
|
||||
</div>
|
||||
<div class="shape-overlay" id="shape-overlay">
|
||||
<h4>Objekt-Parameter</h4>
|
||||
<p class="shape-overlay-empty" id="shape-overlay-empty">Kein Objekt ausgewählt.</p>
|
||||
|
||||
<button type="button" class="button button-primary" id="shape-add">Form hinzufügen</button>
|
||||
<p class="hint">Shapes werden als JSON gespeichert und können jederzeit angepasst werden.</p>
|
||||
<div class="shape-overlay-form" id="shape-overlay-form" hidden>
|
||||
<div class="shape-control-grid">
|
||||
<label>
|
||||
Typ
|
||||
<input type="text" id="shape-param-type" readonly>
|
||||
</label>
|
||||
<label>
|
||||
X
|
||||
<input type="number" id="shape-param-x" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Y
|
||||
<input type="number" id="shape-param-y" step="1">
|
||||
</label>
|
||||
<label data-field="width">
|
||||
Breite
|
||||
<input type="number" id="shape-param-width" step="1">
|
||||
</label>
|
||||
<label data-field="height">
|
||||
Höhe
|
||||
<input type="number" id="shape-param-height" step="1">
|
||||
</label>
|
||||
<label data-field="radius">
|
||||
Radius
|
||||
<input type="number" id="shape-param-radius" step="1">
|
||||
</label>
|
||||
<label data-field="font_size">
|
||||
Font-Size
|
||||
<input type="number" id="shape-param-font-size" step="1">
|
||||
</label>
|
||||
<label data-field="text">
|
||||
Text
|
||||
<input type="text" id="shape-param-text">
|
||||
</label>
|
||||
<label>
|
||||
Füllung
|
||||
<input type="color" id="shape-param-fill">
|
||||
</label>
|
||||
<label>
|
||||
Strich
|
||||
<input type="color" id="shape-param-stroke">
|
||||
</label>
|
||||
<label>
|
||||
Strichbreite
|
||||
<input type="number" id="shape-param-stroke-width" step="0.5" min="0">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="shape-port-settings">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="shape-param-is-port">
|
||||
Dieses Objekt ist ein Port
|
||||
</label>
|
||||
<label id="shape-port-name-label" hidden>
|
||||
Port-Name
|
||||
<input type="text" id="shape-param-port-name" placeholder="z.B. Gi1/0/1">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="shape-overlay-actions">
|
||||
<button type="button" class="button button-danger" id="shape-delete">Objekt entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shape-list">
|
||||
<h4>Shapes</h4>
|
||||
<ul id="shape-list"></ul>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
@@ -323,86 +346,132 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
}
|
||||
|
||||
.shape-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 320px;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.shape-editor-canvas {
|
||||
flex: 1 1 320px;
|
||||
min-width: 280px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.shape-editor-canvas svg {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
min-height: 320px;
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.shape-editor-controls {
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
.shape-toolbox,
|
||||
.shape-overlay {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shape-control-grid {
|
||||
.shape-toolbox h4,
|
||||
.shape-overlay h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.shape-tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shape-tool {
|
||||
text-align: left;
|
||||
border: 1px solid #bbb;
|
||||
background: #f7f7f7;
|
||||
color: #222;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.shape-tool:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.shape-editor-canvas.drag-over {
|
||||
outline: 2px dashed #007bff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shape-object {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.shape-object.is-selected {
|
||||
filter: drop-shadow(0 0 5px rgba(0, 123, 255, 0.7));
|
||||
}
|
||||
|
||||
.shape-object.is-port {
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
|
||||
.shape-overlay-form .shape-control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.shape-control-grid label {
|
||||
font-size: 0.8rem;
|
||||
.shape-overlay-form label {
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shape-control-grid input,
|
||||
.shape-control-grid select {
|
||||
.shape-overlay-form input[type="number"],
|
||||
.shape-overlay-form input[type="text"],
|
||||
.shape-overlay-form input[type="color"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.shape-list {
|
||||
margin-top: 12px;
|
||||
.shape-port-settings {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.shape-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 6px 0 0;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shape-list li {
|
||||
display: flex;
|
||||
.inline-checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.shape-list li:last-child {
|
||||
border-bottom: none;
|
||||
.shape-overlay-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.shape-list button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
.shape-overlay-empty {
|
||||
margin: 6px 0 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shape-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -454,172 +523,4 @@ function confirmDelete(id) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hiddenInput = document.getElementById('shape-definition');
|
||||
const svgCanvas = document.getElementById('shape-canvas');
|
||||
const shapeList = document.getElementById('shape-list');
|
||||
const addShapeButton = document.getElementById('shape-add');
|
||||
const typeSelect = document.getElementById('shape-type');
|
||||
const xInput = document.getElementById('shape-x');
|
||||
const yInput = document.getElementById('shape-y');
|
||||
const widthInput = document.getElementById('shape-width');
|
||||
const heightInput = document.getElementById('shape-height');
|
||||
const radiusInput = document.getElementById('shape-radius');
|
||||
const textInput = document.getElementById('shape-text');
|
||||
const fillInput = document.getElementById('shape-fill');
|
||||
const strokeInput = document.getElementById('shape-stroke');
|
||||
const strokeWidthInput = document.getElementById('shape-stroke-width');
|
||||
|
||||
if (!hiddenInput || !svgCanvas || !shapeList) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shapes = [];
|
||||
|
||||
function parseShapes() {
|
||||
try {
|
||||
const parsed = JSON.parse(hiddenInput.value || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistShapes() {
|
||||
hiddenInput.value = JSON.stringify(shapes);
|
||||
}
|
||||
|
||||
function clearGenerated() {
|
||||
svgCanvas.querySelectorAll('.generated-shape').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
function createSvgElement(name) {
|
||||
return document.createElementNS('http://www.w3.org/2000/svg', name);
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
clearGenerated();
|
||||
shapes.forEach((shape) => {
|
||||
let el;
|
||||
const fill = shape.fill || '#cccccc';
|
||||
const stroke = shape.stroke || '#333333';
|
||||
const strokeWidth = typeof shape.strokeWidth === 'number' ? shape.strokeWidth : 1;
|
||||
|
||||
if (shape.type === 'rect') {
|
||||
el = createSvgElement('rect');
|
||||
el.setAttribute('x', shape.x ?? 10);
|
||||
el.setAttribute('y', shape.y ?? 10);
|
||||
el.setAttribute('width', shape.width ?? 120);
|
||||
el.setAttribute('height', shape.height ?? 60);
|
||||
} else if (shape.type === 'circle') {
|
||||
el = createSvgElement('circle');
|
||||
el.setAttribute('cx', shape.x ?? 60);
|
||||
el.setAttribute('cy', shape.y ?? 60);
|
||||
el.setAttribute('r', shape.r ?? 30);
|
||||
} else if (shape.type === 'text') {
|
||||
el = createSvgElement('text');
|
||||
el.setAttribute('x', shape.x ?? 30);
|
||||
el.setAttribute('y', shape.y ?? 20);
|
||||
el.setAttribute('fill', fill);
|
||||
el.setAttribute('font-size', '18');
|
||||
el.setAttribute('text-anchor', 'middle');
|
||||
el.setAttribute('dominant-baseline', 'central');
|
||||
el.textContent = shape.text || 'Text';
|
||||
el.classList.add('generated-shape');
|
||||
svgCanvas.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute('fill', fill);
|
||||
el.setAttribute('stroke', stroke);
|
||||
el.setAttribute('stroke-width', strokeWidth);
|
||||
el.classList.add('generated-shape');
|
||||
svgCanvas.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
shapeList.innerHTML = '';
|
||||
if (shapes.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.innerHTML = '<em>Noch keine Formen definiert.</em>';
|
||||
shapeList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.forEach((shape, index) => {
|
||||
const li = document.createElement('li');
|
||||
const label = document.createElement('span');
|
||||
const typeLabels = {
|
||||
rect: 'Rechteck',
|
||||
circle: 'Kreis',
|
||||
text: 'Text'
|
||||
};
|
||||
const summary = `${typeLabels[shape.type] || shape.type} @ (${shape.x ?? 0}, ${shape.y ?? 0})`;
|
||||
label.textContent = summary;
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Entfernen';
|
||||
removeButton.classList.add('button', 'button-small', 'button-danger');
|
||||
removeButton.dataset.removeShape = index;
|
||||
|
||||
li.appendChild(label);
|
||||
li.appendChild(removeButton);
|
||||
shapeList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getNumberValue(input, fallback) {
|
||||
const value = parseFloat(input.value);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
addShapeButton.addEventListener('click', () => {
|
||||
const type = typeSelect.value;
|
||||
const baseShape = {
|
||||
type,
|
||||
x: getNumberValue(xInput, 20),
|
||||
y: getNumberValue(yInput, 20),
|
||||
fill: fillInput.value || '#cccccc',
|
||||
stroke: strokeInput.value || '#333333',
|
||||
strokeWidth: getNumberValue(strokeWidthInput, 1)
|
||||
};
|
||||
|
||||
if (type === 'rect') {
|
||||
baseShape.width = getNumberValue(widthInput, 120);
|
||||
baseShape.height = getNumberValue(heightInput, 60);
|
||||
} else if (type === 'circle') {
|
||||
baseShape.r = getNumberValue(radiusInput, 30);
|
||||
} else if (type === 'text') {
|
||||
baseShape.text = textInput.value || 'Text';
|
||||
}
|
||||
|
||||
shapes.push(baseShape);
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
|
||||
shapeList.addEventListener('click', (event) => {
|
||||
if (!event.target.dataset.removeShape) {
|
||||
return;
|
||||
}
|
||||
const index = Number(event.target.dataset.removeShape);
|
||||
shapes.splice(index, 1);
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
|
||||
shapes = parseShapes();
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
</script>
|
||||
<script src="/assets/js/device-type-shape-editor.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user