diff --git a/app/assets/css/device-type-edit.css b/app/assets/css/device-type-edit.css new file mode 100644 index 0000000..4330203 --- /dev/null +++ b/app/assets/css/device-type-edit.css @@ -0,0 +1,301 @@ +.device-type-edit { + max-width: 800px; + margin: 20px auto; + padding: 20px; +} + +.device-type-edit .edit-form { + background: white; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.device-type-edit .edit-form fieldset { + margin: 20px 0; + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +.device-type-edit .edit-form legend { + padding: 0 10px; + font-weight: bold; + font-size: 1.1em; +} + +.device-type-edit .form-group { + margin: 15px 0; +} + +.device-type-edit .form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.device-type-edit .form-group input[type="text"], +.device-type-edit .form-group input[type="file"], +.device-type-edit .form-group select, +.device-type-edit .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-group textarea { + resize: vertical; +} + +.device-type-edit .form-group small { + display: block; + margin-top: 5px; + color: #666; +} + +.device-type-edit .required { + color: red; +} + +.device-type-edit .form-file-preview { + margin-top: 10px; +} + +.device-type-edit .device-type-current-image { + max-width: 300px; + border: 1px solid #ddd; + padding: 10px; + display: block; +} + +.device-type-edit .port-definition-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.device-type-edit .port-definition-table th, +.device-type-edit .port-definition-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; + vertical-align: middle; +} + +.device-type-edit .port-definition-table th { + background: #f5f5f5; +} + +.device-type-edit .port-definition-table input[type="text"], +.device-type-edit .port-definition-table select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-actions { + display: flex; + gap: 10px; + margin-top: 30px; +} + +.device-type-edit .shape-editor { + display: grid; + grid-template-columns: 180px 1fr 320px; + gap: 16px; + margin-top: 16px; +} + +.device-type-edit .shape-meta-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.device-type-edit .shape-meta-column { + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-meta-column label { + font-weight: 600; + font-size: 0.9em; +} + +.device-type-edit .shape-meta-column input, +.device-type-edit .shape-meta-column select { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 0.95em; +} + +.device-type-edit .port-actions { + margin-top: 15px; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.device-type-edit .shape-editor-canvas { + border: 1px solid #ddd; + border-radius: 6px; + background: white; + padding: 12px; +} + +.device-type-edit .shape-editor-canvas svg { + width: 100%; + min-height: 320px; + display: block; + font-family: inherit; + cursor: crosshair; +} + +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + border: 1px solid #ddd; + border-radius: 6px; + padding: 12px; + background: #fff; +} + +.device-type-edit .shape-toolbox h4, +.device-type-edit .shape-overlay h4 { + margin: 0 0 8px; +} + +.device-type-edit .shape-tool-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.device-type-edit .shape-tool { + text-align: left; + border: 1px solid #bbb; + background: #f7f7f7; + color: #222; + padding: 10px; + border-radius: 4px; + cursor: grab; +} + +.device-type-edit .shape-tool:active { + cursor: grabbing; +} + +.device-type-edit .shape-tool.is-active { + background: #007bff; + color: #fff; + border-color: #0056b3; +} + +.device-type-edit .shape-editor-canvas.drag-over { + outline: 2px dashed #007bff; + outline-offset: 2px; +} + +.device-type-edit .shape-object { + cursor: move; +} + +.device-type-edit #shape-canvas.shape-tool-active { + cursor: crosshair; +} + +.device-type-edit .shape-object.is-selected { + filter: drop-shadow(0 0 5px rgba(0, 123, 255, 0.7)); +} + +.device-type-edit .shape-object.is-port { + stroke-dasharray: 4 2; +} + +.device-type-edit .shape-overlay-form .shape-control-grid { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 8px; +} + +.device-type-edit .shape-overlay-form label { + font-size: 0.82rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-overlay-form input[type="number"], +.device-type-edit .shape-overlay-form input[type="text"], +.device-type-edit .shape-overlay-form input[type="color"] { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .shape-port-settings { + margin-top: 10px; +} + +.device-type-edit .inline-checkbox { + flex-direction: row !important; + align-items: center; + gap: 6px !important; +} + +.device-type-edit .shape-overlay-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.device-type-edit .shape-overlay-empty { + margin: 6px 0 0; + color: #666; + font-size: 0.9rem; +} + +.device-type-edit .hint { + font-size: 0.8rem; + color: #666; + margin: 8px 0 0; +} + +.device-type-edit .button { + padding: 10px 15px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.95em; +} + +.device-type-edit .button-primary { + background: #28a745; +} + +.device-type-edit .button-danger { + background: #dc3545; +} + +.device-type-edit .button:hover { + opacity: 0.8; +} + +@media (max-width: 1100px) { + .device-type-edit .shape-editor { + grid-template-columns: 1fr; + } +} diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js index ffe42de..4515ce6 100644 --- a/app/assets/js/device-type-shape-editor.js +++ b/app/assets/js/device-type-shape-editor.js @@ -1,6 +1,12 @@ (() => { const SVG_NS = 'http://www.w3.org/2000/svg'; const MIN_DRAW_SIZE = 4; + const MIN_CANVAS_WIDTH = 200; + const MIN_CANVAS_HEIGHT = 120; + const FORM_FACTOR_PRESETS = { + '19': { width: 760, height: 80 }, + '10': { width: 420, height: 80 } + }; function initEditor() { const editor = document.getElementById('device-type-shape-editor'); @@ -31,6 +37,13 @@ deleteButton: document.getElementById('shape-delete') }; + const metaInputs = { + formFactor: document.getElementById('shape-meta-form-factor'), + rackHeight: document.getElementById('shape-meta-rack-height'), + canvasWidth: document.getElementById('shape-meta-canvas-width'), + canvasHeight: document.getElementById('shape-meta-canvas-height') + }; + const fieldVisibility = { width: editor.querySelector('[data-field="width"]'), height: editor.querySelector('[data-field="height"]'), @@ -53,11 +66,20 @@ offsetY: 0 }; let selectedShapeId = null; - let shapes = normalizeShapeList(readJson(hiddenInput.value)); + let shapes = []; + let meta = getDefaultMeta(); + + const definition = readDefinition(hiddenInput.value); + shapes = normalizeShapeList(definition.shapes); + meta = definition.meta; bindToolbarEvents(editor); bindCanvasPointerEvents(svg); bindOverlayEvents(overlay); + bindMetaEvents(); + applyFormFactorPreset(); + applyMetaToInputs(); + persist(); render(); function bindToolbarEvents(root) { @@ -218,6 +240,63 @@ }); } + function bindMetaEvents() { + Object.values(metaInputs).forEach((input) => { + if (!input) { + return; + } + const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, handleMetaInputChange); + }); + } + + function handleMetaInputChange(event) { + const targetId = event?.target?.id; + applyMetaFromInputs(); + applyFormFactorPreset(targetId); + applyMetaToInputs(); + persist(); + render(); + } + + function applyMetaFromInputs() { + if (!metaInputs.formFactor) { + return; + } + meta.formFactor = normalizeFormFactor(metaInputs.formFactor.value); + meta.heightHe = Math.max(1, toNumberOrDefault(metaInputs.rackHeight.value, meta.heightHe)); + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(metaInputs.canvasWidth.value, meta.canvasWidth)); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(metaInputs.canvasHeight.value, meta.canvasHeight)); + } + + function applyMetaToInputs() { + if (!metaInputs.formFactor) { + return; + } + metaInputs.formFactor.value = meta.formFactor; + metaInputs.rackHeight.value = meta.heightHe; + metaInputs.canvasWidth.value = meta.canvasWidth; + metaInputs.canvasHeight.value = meta.canvasHeight; + } + + function applyFormFactorPreset(triggerId) { + if (!['shape-meta-form-factor', 'shape-meta-rack-height'].includes(triggerId) && triggerId !== undefined) { + return; + } + + if (meta.heightHe !== 1) { + return; + } + + const preset = FORM_FACTOR_PRESETS[meta.formFactor]; + if (!preset) { + return; + } + + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, preset.width); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, preset.height); + } + function applyOverlayToSelectedShape() { const shape = findShape(selectedShapeId); if (!shape) { @@ -248,6 +327,7 @@ function render() { renderToolState(); + updateCanvasDimensions(); renderCanvas(); renderOverlay(); } @@ -261,7 +341,8 @@ } function renderCanvas() { - svg.querySelectorAll('.shape-object').forEach((el) => el.remove()); + svg.querySelectorAll('.shape-object, .shape-auto-frame').forEach((el) => el.remove()); + renderAutoFrame(); shapes.forEach((shape) => { const element = createSvgShapeElement(shape); @@ -280,6 +361,39 @@ }); } + function renderAutoFrame() { + if (!['19', '10'].includes(meta.formFactor)) { + return; + } + + const padding = 4; + const frameWidth = Math.max(0, meta.canvasWidth - padding * 2); + const frameHeight = Math.max(0, meta.canvasHeight - padding * 2); + if (frameWidth <= 0 || frameHeight <= 0) { + return; + } + + const frame = document.createElementNS(SVG_NS, 'rect'); + frame.setAttribute('x', String(padding)); + frame.setAttribute('y', String(padding)); + frame.setAttribute('width', String(frameWidth)); + frame.setAttribute('height', String(frameHeight)); + frame.setAttribute('fill', '#ffffff'); + frame.setAttribute('stroke', '#000000'); + frame.setAttribute('stroke-width', '1'); + frame.setAttribute('class', 'shape-auto-frame'); + frame.setAttribute('pointer-events', 'none'); + svg.appendChild(frame); + } + + function updateCanvasDimensions() { + const width = Math.max(MIN_CANVAS_WIDTH, meta.canvasWidth); + const height = Math.max(MIN_CANVAS_HEIGHT, meta.canvasHeight); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.style.height = `${height}px`; + svg.style.minHeight = `${height}px`; + } + function renderOverlay() { const selected = findShape(selectedShapeId); const hasSelection = !!selected; @@ -319,7 +433,16 @@ } function persist() { - hiddenInput.value = JSON.stringify(shapes); + const payload = { + shapes, + meta: { + formFactor: meta.formFactor, + heightHe: meta.heightHe, + canvasWidth: meta.canvasWidth, + canvasHeight: meta.canvasHeight + } + }; + hiddenInput.value = JSON.stringify(payload); } function findShape(id) { @@ -542,6 +665,56 @@ } } + function readDefinition(raw) { + const parsed = readJson(raw); + if (Array.isArray(parsed)) { + return { + shapes: parsed, + meta: getDefaultMeta() + }; + } + + if (parsed && typeof parsed === 'object') { + return { + shapes: Array.isArray(parsed.shapes) ? parsed.shapes : [], + meta: normalizeMeta(parsed.meta ?? parsed) + }; + } + + return { + shapes: [], + meta: getDefaultMeta() + }; + } + + function normalizeMeta(source) { + const base = getDefaultMeta(); + if (!source || typeof source !== 'object') { + return base; + } + + return { + formFactor: normalizeFormFactor(source.formFactor ?? source.rackFormFactor ?? base.formFactor), + heightHe: Math.max(1, toNumberOrDefault(source.heightHe ?? source.rackHeight ?? base.heightHe, base.heightHe)), + canvasWidth: Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(source.canvasWidth ?? source.width ?? base.canvasWidth, base.canvasWidth)), + canvasHeight: Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(source.canvasHeight ?? source.height ?? base.canvasHeight, base.canvasHeight)) + }; + } + + function getDefaultMeta() { + return { + formFactor: 'other', + heightHe: 1, + canvasWidth: 800, + canvasHeight: 360 + }; + } + + function normalizeFormFactor(value) { + const normalized = String(value || '').trim(); + return ['10', '19'].includes(normalized) ? normalized : 'other'; + } + function normalizeColor(value, fallback) { const v = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(v)) { diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index d69e41f..f8d413b 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -120,10 +120,11 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ -
+
- Gerätetyp-Bild + Gerätetyp-Bild
@@ -131,6 +132,29 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
Gerätedesign (SVG-Editor) +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -276,7 +300,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ -
+
Ports können nach dem ersten Speichern jederzeit einzeln bearbeitet und gelöscht werden.
@@ -297,263 +321,6 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
- diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 0bf31ad..de0e4f7 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -120,7 +120,7 @@ Definiert eine Gerätevorlage. - Grundlage für alle grafischen Ansichten **Technische Attribute** -- `shape_definition`: JSON-Array mit einfachen Formen (rect/circle/text) für die integrierte Zeichenfläche. +- `shape_definition`: JSON-Objekt mit `meta` und `shapes`. `meta` enthält den Formfaktor (`'10'`, `'19'`, `'other'`), die Rack-Höhe in HE (`heightHe`) sowie die Zeichenflächen-Abmessungen (`canvasWidth`, `canvasHeight`). Bei 1HE-Geräten mit 10" oder 19" Frontpanel wird der Zeichenbereich automatisch auf die dort üblichen Breiten/Höhen (420×80px bzw. 760×80px) skaliert und zusätzlich von einem weißen Rahmen mit schwarzer Linie umgeben, damit die Darstellung den realen Formfaktor besser widerspiegelt. Der `shapes`-Array enthält wie bisher die einzelnen Formen (`rect`, `circle`, `text`), die im Editor platziert wurden. ---