diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js index c710053..631cfe7 100644 --- a/src/main/frontend/node-editor/node-editor.js +++ b/src/main/frontend/node-editor/node-editor.js @@ -1,28 +1,34 @@ import Drawflow from 'drawflow'; import 'drawflow/dist/drawflow.min.css'; +// Senkrechter Stub, mit dem jede Linie aus dem Ausgang austritt bzw. +// senkrecht in den Eingang des Ziels eintritt, bevor sie abbiegt. +const EXIT_STUB = 5; + function customCurvature(sx, sy, ex, ey) { const dx = ex - sx; - const dy = ey - sy; - const adyt = Math.abs(dy); const minOffset = 40; - if (dy >= -8) { - const offset = Math.max(adyt * 0.5, minOffset); - const c1x = sx + dx * 0.5; - const c1y = sy + offset; - const c2x = ex; - const c2y = ey - offset; - return ` M ${sx} ${sy} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey}`; + // Punkte nach dem Austritts- bzw. vor dem Eintritts-Stub. + const sy2 = sy + EXIT_STUB; + const ey2 = ey - EXIT_STUB; + + if (ey - sy >= -8) { + const offset = Math.max(Math.abs(ey2 - sy2) * 0.5, minOffset); + const c1x = sx; // Tangente bleibt senkrecht zum Austritts-Stub + const c1y = sy2 + offset; + const c2x = ex; // Tangente bleibt senkrecht zum Eintritts-Stub + const c2y = ey2 - offset; + return ` M ${sx} ${sy} L ${sx} ${sy2} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey2} L ${ex} ${ey}`; } - const loopOffset = adyt * 0.5 + 80; + const loopOffset = Math.abs(ey2 - sy2) * 0.5 + 80; const sign = Math.sign(dx) || 1; - const c1x = sx + sign * 60; - const c1y = sy + loopOffset; + const c1x = sx; // Tangente bleibt senkrecht zum Austritts-Stub + const c1y = sy2 + loopOffset; const c2x = ex - sign * 60; - const c2y = ey - loopOffset; - return ` M ${sx} ${sy} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey}`; + const c2y = ey2 - loopOffset; + return ` M ${sx} ${sy} L ${sx} ${sy2} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey2} L ${ex} ${ey}`; } if (Drawflow && Drawflow.prototype) { @@ -49,6 +55,9 @@ function buildNodeHtml(label, color) { return `
+
+ +
${safe}
@@ -106,6 +115,21 @@ function attach(host) { editor.on('nodeSelected', id => { host.$server && host.$server.onNodeSelected(parseInt(id, 10)); }); + + // Klick auf das Zahnrad-Icon eines Knotens -> Einstellungen öffnen. + container.addEventListener('mousedown', e => { + const gear = e.target instanceof Element ? e.target.closest('.task-node-gear') : null; + if (gear) e.stopPropagation(); // verhindert das Verschieben des Knotens + }, true); + container.addEventListener('click', e => { + const gear = e.target instanceof Element ? e.target.closest('.task-node-gear') : null; + if (!gear) return; + const nodeEl = gear.closest('.drawflow-node'); + if (!nodeEl) return; + e.stopPropagation(); + const id = parseInt(nodeEl.id.replace('node-', ''), 10); + host.$server && host.$server.onNodeActivated(id); + }, true); editor.on('nodeUnselected', () => { host.$server && host.$server.onNodeUnselected(); }); @@ -150,6 +174,22 @@ window.taskListEditor = { if (!def) return; addNodeInternal(host, def, x, y); }, + setNodeOutputs(host, nodeId, count) { + const state = editors.get(host); + if (!state) return; + const editor = state.editor; + const node = editor.getNodeFromId(nodeId); + if (!node) return; + let current = Object.keys(node.outputs || {}).length; + while (current < count) { + editor.addNodeOutput(nodeId); + current++; + } + while (current > count) { + editor.removeNodeOutput(nodeId, `output_${current}`); + current--; + } + }, updateNodeLabel(host, nodeId, label) { const state = editors.get(host); if (!state) return; diff --git a/src/main/frontend/node-editor/script-editor.js b/src/main/frontend/node-editor/script-editor.js new file mode 100644 index 0000000..53774de --- /dev/null +++ b/src/main/frontend/node-editor/script-editor.js @@ -0,0 +1,312 @@ +import Drawflow from 'drawflow'; +import 'drawflow/dist/drawflow.min.css'; + +// Logik-Knotentypen des Script-Editors (Anzahl Ein-/Ausgänge). +const LOGIC = { + constant: { inputs: 0, outputs: 1 }, + variable: { inputs: 0, outputs: 1 }, + compare: { inputs: 2, outputs: 2 }, + and: { inputs: 2, outputs: 1 }, + or: { inputs: 2, outputs: 1 }, + not: { inputs: 1, outputs: 1 }, + if: { inputs: 1, outputs: 2 }, + result: { inputs: 1, outputs: 0 }, + output: { inputs: 1, outputs: 0 } +}; + +const scriptEditors = new WeakMap(); + +// Rastergröße – passend zum gepunkteten Canvas-Hintergrund (24px). +const GRID = 24; + +function snap(value) { + return Math.round(value / GRID) * GRID; +} + +// Horizontale Kurvenführung: Linien treten waagerecht aus dem Ausgang aus +// und treten waagerecht in den Eingang ein (Ports liegen links/rechts). +function horizontalCurvature(sx, sy, ex, ey) { + const offset = Math.max(Math.abs(ex - sx) * 0.5, 40); + return ` M ${sx} ${sy} C ${sx + offset} ${sy} ${ex - offset} ${ey} ${ex} ${ey}`; +} + +// Drag-Start für die Operator-Kacheln (einmalig global registrieren). +if (!window.__scriptEditorDragInit) { + window.__scriptEditorDragInit = true; + document.addEventListener('dragstart', e => { + const target = e.target instanceof Element ? e.target.closest('[data-logictype]') : null; + if (target && e.dataTransfer) { + e.dataTransfer.setData('application/x-logictype', target.dataset.logictype); + e.dataTransfer.effectAllowed = 'copy'; + } + }, true); +} + +function logicHtml(typeId) { + switch (typeId) { + case 'constant': + return `
+
Konstante
+ +
`; + case 'variable': + return `
+
Variable
+ +
`; + case 'compare': + return `
+
Vergleich
+ +
Eingänge: oben links · unten rechts
+
Ausgänge: oben „trifft zu“ · unten „trifft nicht zu“
+
`; + case 'and': + return `
UND
`; + case 'or': + return `
ODER
`; + case 'not': + return `
NICHT
`; + case 'if': + return `
+
Wenn
+
① Dann · ② Sonst
+
`; + case 'result': + return `
+
Ergebnis
+ +
`; + case 'output': + return `
+
Ausgang
+ +
`; + default: + return `
?
`; + } +} + +function defaultData(typeId) { + switch (typeId) { + case 'constant': return { type: typeId, const: '' }; + case 'variable': return { type: typeId, source: '' }; + case 'compare': return { type: typeId, operator: '==' }; + case 'result': return { type: typeId, result: '' }; + case 'output': return { type: typeId, name: '' }; + default: return { type: typeId }; + } +} + +// Füllt das Variablen-Dropdown eines Wert-Knotens mit den verfügbaren +// Variablen (= eingegebene Daten der anderen Elemente) und erhält die +// gespeicherte Auswahl. +function fillSourceSelect(select, variables, current) { + const cur = current == null ? '' : String(current); + select.innerHTML = ''; + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = '– Variable wählen –'; + select.appendChild(empty); + + let found = false; + for (const v of variables) { + const option = document.createElement('option'); + option.value = String(v.key); + option.textContent = v.label; + if (String(v.key) === cur) { + option.selected = true; + found = true; + } + select.appendChild(option); + } + if (cur && !found) { + const option = document.createElement('option'); + option.value = cur; + option.textContent = '(entferntes Element)'; + option.selected = true; + select.appendChild(option); + } +} + +// Zeichnet alle Verbindungslinien des Graphen neu. Notwendig nach einem +// Import, da Drawflow die Linien aus den Bildschirmkoordinaten der Ports +// berechnet. +function redrawConnections(state) { + const modules = state.editor.drawflow.drawflow; + for (const moduleName in modules) { + for (const id in modules[moduleName].data) { + state.editor.updateConnectionNodes(`node-${id}`); + } + } +} + +// Wird der Graph importiert, während der Dialog-Container noch nicht sichtbar +// bzw. noch in der Öffnungs-Animation ist, berechnet Drawflow die Verbindungen +// aus falschen (skalierten oder nicht vorhandenen) Port-Koordinaten und schreibt +// diese fest in die SVG-Pfade – die Linien sitzen dann neben den Ports oder +// kollabieren auf den Nullpunkt. +// +// Daher die Linien zunächst jeden Frame neu zeichnen, bis sich die +// Container-Geometrie (Größe und Position) über mehrere Frames nicht mehr +// ändert (= Dialog-Animation abgeschlossen). Zusätzlich noch einige gestaffelte +// Neuberechnungen, um späte Reflows (Font-Laden, verzögertes Layout) abzufangen. +function redrawConnectionsWhenReady(state) { + let lastKey = null; + let stableFrames = 0; + let frames = 0; + const tick = () => { + const c = state.container; + if (c.clientWidth > 0 && c.clientHeight > 0) { + const r = c.getBoundingClientRect(); + const key = [r.left, r.top, r.width, r.height].map(Math.round).join('x'); + redrawConnections(state); + if (key === lastKey) { + if (++stableFrames >= 3) return; + } else { + stableFrames = 0; + lastKey = key; + } + } + if (frames++ < 120) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + + // Insurance gegen späte Layout-Änderungen nach abgeschlossener Animation. + for (const delay of [150, 350, 600]) { + setTimeout(() => { + if (state.container.clientWidth > 0 && state.container.clientHeight > 0) { + redrawConnections(state); + } + }, delay); + } +} + +function refreshVariableSelects(state) { + const modules = state.editor.drawflow.drawflow; + for (const moduleName in modules) { + const data = modules[moduleName].data; + for (const id in data) { + const node = data[id]; + if (node.name !== 'variable') continue; + const select = state.container.querySelector(`#node-${id} select[df-source]`); + if (!select) continue; + const current = node.data && node.data.source != null ? node.data.source : ''; + fillSourceSelect(select, state.variables, current); + } + } +} + +function attach(host) { + if (scriptEditors.has(host)) { + return scriptEditors.get(host); + } + host.classList.add('script-editor'); + host.innerHTML = ''; + const container = document.createElement('div'); + container.className = 'script-editor-container'; + container.style.width = '100%'; + container.style.height = '100%'; + host.appendChild(container); + + const editor = new Drawflow(container); + editor.reroute = true; + // Eigene horizontale Kurvenführung (überschreibt die vertikale aus dem Workflow-Editor). + editor.createCurvature = horizontalCurvature; + editor.start(); + + const state = { editor, container, counter: 0, variables: [] }; + scriptEditors.set(host, state); + + container.addEventListener('dragover', e => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }); + container.addEventListener('drop', e => { + e.preventDefault(); + const typeId = e.dataTransfer ? e.dataTransfer.getData('application/x-logictype') : ''; + if (!typeId || !LOGIC[typeId]) return; + const rect = container.getBoundingClientRect(); + const zoom = editor.zoom; + const x = (e.clientX - rect.left - editor.canvas_x) / zoom; + const y = (e.clientY - rect.top - editor.canvas_y) / zoom; + addNodeAt(state, typeId, x, y); + }); + + // Knoten nach dem Verschieben am Raster einrasten. + editor.on('nodeMoved', id => { + const el = container.querySelector(`#node-${id}`); + if (!el) return; + const x = snap(parseFloat(el.style.left) || 0); + const y = snap(parseFloat(el.style.top) || 0); + el.style.left = `${x}px`; + el.style.top = `${y}px`; + const node = editor.drawflow.drawflow[editor.module].data[id]; + if (node) { + node.pos_x = x; + node.pos_y = y; + } + editor.updateConnectionNodes(`node-${id}`); + }); + + return state; +} + +function addNodeAt(state, typeId, x, y) { + const def = LOGIC[typeId]; + if (!def) return; + state.editor.addNode(typeId, def.inputs, def.outputs, snap(x), snap(y), `logic-${typeId}`, defaultData(typeId), logicHtml(typeId)); + if (typeId === 'variable') { + refreshVariableSelects(state); + } +} + +function addNode(host, typeId) { + const state = attach(host); + const n = state.counter++; + addNodeAt(state, typeId, 60 + (n % 6) * 50, 50 + (n % 6) * 45); +} + +window.scriptEditor = { + attach, + addNode, + setVariables(host, variables) { + const state = attach(host); + state.variables = Array.isArray(variables) ? variables : []; + refreshVariableSelects(state); + // Geänderte Dropdown-Inhalte können die Knotenbreite und damit die + // Portpositionen verschieben – Verbindungen neu zeichnen. + redrawConnectionsWhenReady(state); + }, + clear(host) { + const state = scriptEditors.get(host); + if (state) state.editor.clear(); + }, + setGraph(host, json) { + const state = attach(host); + if (!json) { + state.editor.clear(); + return; + } + try { + state.editor.import(JSON.parse(json)); + refreshVariableSelects(state); + redrawConnectionsWhenReady(state); + } catch (e) { + console.error('scriptEditor.setGraph failed', e); + state.editor.clear(); + } + }, + exportGraph(host) { + const state = scriptEditors.get(host); + if (!state) return ''; + return JSON.stringify(state.editor.export()); + } +}; diff --git a/src/main/frontend/themes/tasklist-editor/styles.css b/src/main/frontend/themes/tasklist-editor/styles.css index e0fc9fc..4d063d2 100644 --- a/src/main/frontend/themes/tasklist-editor/styles.css +++ b/src/main/frontend/themes/tasklist-editor/styles.css @@ -181,6 +181,7 @@ body, .tasklist-app { } .task-node { + position: relative; background: var(--tle-panel); border: 1px solid var(--tle-border); border-radius: 10px; @@ -192,6 +193,31 @@ body, .tasklist-app { flex-direction: column; } +.task-node-gear { + position: absolute; + top: 12px; + right: 12px; + width: 18px; + height: 18px; + color: var(--tle-muted, #6b7280); + opacity: 0.55; + cursor: pointer; + z-index: 6; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.task-node-gear:hover { + opacity: 1; + color: var(--tle-text, #1f2933); +} + +.task-node-gear svg { + width: 100%; + height: 100%; + display: block; + pointer-events: none; +} + .task-node-header { height: 6px; width: 100%; @@ -316,3 +342,171 @@ body, .tasklist-app { font-size: var(--lumo-font-size-s); margin: 0 0 var(--lumo-space-s); } + +/* --- Script-/Logik-Editor --- */ +.script-editor, +.script-editor-container { + height: 100%; + width: 100%; +} + +/* Raster-Hintergrund wie im Workflow-Editor. */ +.script-editor-host { + background: + radial-gradient(circle, var(--tle-canvas-grid) 1px, transparent 1px) 0 0 / 24px 24px, + var(--tle-canvas-bg); + overflow: hidden; + position: relative; +} + +.script-editor .drawflow { + background: transparent; +} + +.script-editor .drawflow .drawflow-node { + background: transparent; + border: none; + padding: 0; + box-shadow: none; + min-width: 150px; +} + +/* Keine roten Ecken bei selektierten Knoten (Drawflow-Default überschreiben). */ +.script-editor .drawflow .drawflow-node.selected { + background: transparent; +} + +.logic-node { + background: var(--tle-panel, #fff); + border: 1px solid var(--tle-border, #d8dee9); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + padding: 8px 10px; + min-width: 150px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.logic-node-title { + font-weight: 600; + font-size: var(--lumo-font-size-s); + color: var(--tle-text, #1f2933); + text-align: center; +} + +.logic-node .logic-input, +.logic-node .logic-select { + width: 100%; + box-sizing: border-box; + font-size: var(--lumo-font-size-xs); + padding: 4px 6px; + border: 1px solid var(--tle-border, #d8dee9); + border-radius: 6px; +} + +.logic-ports { + font-size: 10px; + color: var(--tle-muted, #6b7280); + text-align: center; +} + +.logic-node.logic-compare { border-top: 3px solid #2563eb; } +.logic-node.logic-if { border-top: 3px solid #d97706; } +.logic-node.logic-bool { border-top: 3px solid #7c3aed; } +.logic-node.logic-constant { border-top: 3px solid #0891b2; } +.logic-node.logic-variable { border-top: 3px solid #059669; } +.logic-node.logic-result { border-top: 3px solid #dc2626; } +.logic-node.logic-output { border-top: 3px solid #0f766e; } + +/* Verfügbare Variablen im Script-Dialog */ +.script-vars { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: var(--lumo-contrast-5pct); + border-radius: var(--lumo-border-radius-m); +} + +.script-vars-label { + font-weight: 600; + font-size: var(--lumo-font-size-s); + color: var(--tle-text, #1f2933); +} + +.script-vars-empty { + font-size: var(--lumo-font-size-s); + color: var(--tle-muted, #6b7280); + font-style: italic; +} + +.script-var-chip { + font-size: var(--lumo-font-size-xs); + padding: 2px 10px; + border-radius: 999px; + background: #059669; + color: #fff; +} + +/* Operatoren-Palette links im Script-Dialog */ +.script-palette { + flex-shrink: 0; + overflow-y: auto; + padding: 8px; + gap: 6px; + background: var(--lumo-contrast-5pct); + border-radius: var(--lumo-border-radius-m); +} + +.script-palette .panel-heading { + margin: 0 0 4px; + font-size: var(--lumo-font-size-s); + color: var(--tle-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.script-tile { + display: flex; + align-items: center; + gap: var(--lumo-space-s); + padding: 6px 8px; + border: 1px solid var(--tle-border); + border-radius: 9px; + background: var(--tle-panel); + cursor: grab; + user-select: none; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} + +.script-tile:hover { + border-color: var(--block-color, var(--tle-border)); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1); +} + +.script-tile:active { + cursor: grabbing; +} + +.script-tile-icon { + width: 26px; + height: 26px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 14px; + line-height: 1; + flex-shrink: 0; +} + +.script-tile-label { + font-weight: 600; + color: var(--tle-text); + font-size: var(--lumo-font-size-s); +} diff --git a/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java b/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java index c37d55d..c62076c 100644 --- a/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java +++ b/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java @@ -53,6 +53,11 @@ public class NodeEditor extends Div { getElement().executeJs("window.taskListEditor.updateNodeLabel(this, $0, $1)", nodeId, label); } + /** Setzt die Anzahl der Ausgänge eines Knotens (z. B. für Script-Elemente). */ + public void setNodeOutputs(int nodeId, int count) { + getElement().executeJs("window.taskListEditor.setNodeOutputs(this, $0, $1)", nodeId, count); + } + public void clear() { getElement().executeJs("window.taskListEditor.clear(this)"); } @@ -98,6 +103,10 @@ public class NodeEditor extends Div { return addListener(NodeSelectedEvent.class, listener); } + public Registration addNodeActivatedListener(ComponentEventListener listener) { + return addListener(NodeActivatedEvent.class, listener); + } + public Registration addNodeUnselectedListener(ComponentEventListener listener) { return addListener(NodeUnselectedEvent.class, listener); } @@ -116,6 +125,11 @@ public class NodeEditor extends Div { fireEvent(new NodeSelectedEvent(this, nodeId)); } + @ClientCallable + private void onNodeActivated(int nodeId) { + fireEvent(new NodeActivatedEvent(this, nodeId)); + } + @ClientCallable private void onNodeUnselected() { fireEvent(new NodeUnselectedEvent(this)); @@ -151,6 +165,17 @@ public class NodeEditor extends Div { public int nodeId() { return nodeId; } } + public static class NodeActivatedEvent extends ComponentEvent { + private final int nodeId; + + public NodeActivatedEvent(NodeEditor source, int nodeId) { + super(source, true); + this.nodeId = nodeId; + } + + public int nodeId() { return nodeId; } + } + public static class NodeUnselectedEvent extends ComponentEvent { public NodeUnselectedEvent(NodeEditor source) { super(source, true); diff --git a/src/main/java/de/assecutor/tasklisteditor/component/ScriptEditor.java b/src/main/java/de/assecutor/tasklisteditor/component/ScriptEditor.java new file mode 100644 index 0000000..9b3a21a --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/component/ScriptEditor.java @@ -0,0 +1,61 @@ +package de.assecutor.tasklisteditor.component; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.function.SerializableConsumer; +import elemental.json.JsonArray; + +/** + * Eigenständiger Logik-Canvas (Drawflow) zum grafischen Zusammenstecken + * kleiner Code-Snippets innerhalb eines Script-Blocks. + * + *

Die Eigenschaften der Logik-Knoten (Operator, Wert, Ergebnis …) werden + * über Drawflow-{@code df-}-Bindings direkt im Knoten gepflegt und sind daher + * Teil des exportierten Graph-JSON. + */ +@JsModule("./node-editor/script-editor.js") +@NpmPackage(value = "drawflow", version = "0.0.59") +public class ScriptEditor extends Div { + + public ScriptEditor() { + addClassName("script-editor-host"); + getElement().getStyle().set("width", "100%").set("height", "100%"); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + getElement().executeJs("window.scriptEditor.attach(this)"); + } + + /** Fügt einen Logik-Knoten des angegebenen Typs hinzu. */ + public void addNode(String typeId) { + getElement().executeJs("window.scriptEditor.addNode(this, $0)", typeId); + } + + /** Lädt einen zuvor exportierten Logik-Graphen (JSON) in den Canvas. */ + public void setGraph(String json) { + getElement().executeJs("window.scriptEditor.setGraph(this, $0)", json); + } + + /** + * Stellt die verfügbaren Variablen (eingegebene Daten der anderen + * Elemente) für die Wert-Knoten bereit. Erwartet ein JSON-Array aus + * Objekten mit {@code key} und {@code label}. + */ + public void setVariables(JsonArray variables) { + getElement().executeJs("window.scriptEditor.setVariables(this, $0)", variables); + } + + public void clear() { + getElement().executeJs("window.scriptEditor.clear(this)"); + } + + /** Liest den aktuellen Logik-Graphen als JSON-String aus. */ + public void exportGraph(SerializableConsumer callback) { + getElement().executeJs("return window.scriptEditor.exportGraph(this)") + .then(String.class, callback::accept); + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java index 0eb60e7..efd8b8e 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java @@ -130,6 +130,17 @@ public class BlockTypeRegistry { PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) ) )); + + register(new BlockType( + "script", + "Script", + "Kleines Logik-Snippet grafisch zusammenstellen (if/else, Vergleiche …).", + "vaadin:code", + "#475569", + 100, + 1, 0, + List.of() + )); } private void register(BlockType type) { diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java index cc67893..a9414d3 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java @@ -3,6 +3,7 @@ package de.assecutor.tasklisteditor.model; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.JsonNode; import java.util.List; @@ -16,7 +17,7 @@ import java.util.List; */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonPropertyOrder({"s_id", "type", "topic", "opt", "reenter", "min", "max", - "max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries"}) + "max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "script"}) public class TaskDto { @JsonProperty("s_id") @@ -46,6 +47,9 @@ public class TaskDto { private List entries; + /** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */ + private JsonNode script; + public int getSId() { return sId; } @@ -157,4 +161,12 @@ public class TaskDto { public void setEntries(List entries) { this.entries = entries; } + + public JsonNode getScript() { + return script; + } + + public void setScript(JsonNode script) { + this.script = script; + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java index 0f0d52a..aefb418 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java @@ -145,6 +145,17 @@ public class TaskListExporter { boolean queryRemark = v.containsKey("query_remark") && asBool(v.get("query_remark")); task.setEntries(parseEntries(asText(v.get("entries")), queryRemark)); } + + if (v.containsKey("script")) { + String script = asText(v.get("script")); + if (script != null) { + try { + task.setScript(objectMapper.readTree(script)); + } catch (Exception ignored) { + // Ungültiges Skript-JSON wird ausgelassen. + } + } + } return task; } diff --git a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java index 614d835..4f50c54 100644 --- a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java +++ b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java @@ -26,6 +26,7 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.fasterxml.jackson.databind.ObjectMapper; import de.assecutor.tasklisteditor.component.NodeEditor; +import de.assecutor.tasklisteditor.component.ScriptEditor; import de.assecutor.tasklisteditor.model.BlockInstance; import de.assecutor.tasklisteditor.model.BlockType; import de.assecutor.tasklisteditor.model.BlockTypeRegistry; @@ -41,13 +42,20 @@ import elemental.json.Json; import elemental.json.JsonArray; import elemental.json.JsonObject; +import com.fasterxml.jackson.databind.JsonNode; + import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; @Route("") @PageTitle("TaskList Editor") @@ -64,8 +72,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private final ObjectMapper objectMapper; private final NodeEditor nodeEditor; private final Map instances = new HashMap<>(); - private final Div propertiesContent; - private final Paragraph propertiesEmpty; private String currentListName; public MainView(BlockTypeRegistry registry, @@ -95,15 +101,8 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { Div palette = buildPalette(); nodeEditor = new NodeEditor(); - Div propertiesPanel = buildPropertiesPanel(); - propertiesContent = (Div) propertiesPanel.getChildren() - .filter(c -> c.getElement().getClassList().contains("properties-content")) - .findFirst().orElseThrow(); - propertiesEmpty = new Paragraph("Klicke auf einen Funktionsblock, um seine Eigenschaften zu bearbeiten."); - propertiesEmpty.addClassName("properties-empty"); - propertiesContent.add(propertiesEmpty); - body.add(palette, nodeEditor, propertiesPanel); + body.add(palette, nodeEditor); body.expand(nodeEditor); add(body); @@ -114,12 +113,13 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { if (type == null) return; instances.put(e.nodeId(), new BlockInstance(e.nodeId(), type)); }); - nodeEditor.addNodeSelectedListener(e -> showProperties(e.nodeId())); - nodeEditor.addNodeUnselectedListener(e -> showEmpty()); - nodeEditor.addNodeRemovedListener(e -> { - instances.remove(e.nodeId()); - showEmpty(); + nodeEditor.addNodeActivatedListener(e -> { + BlockInstance instance = instances.get(e.nodeId()); + if (instance != null) { + openSettingsDialog(instance); + } }); + nodeEditor.addNodeRemovedListener(e -> instances.remove(e.nodeId())); } private Component buildHeader() { @@ -141,7 +141,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { clear.addClickListener(e -> { nodeEditor.clear(); instances.clear(); - showEmpty(); Notification.show("Canvas geleert"); }); @@ -215,45 +214,131 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } } - private Div buildPropertiesPanel() { - Div panel = new Div(); - panel.addClassName("properties-panel"); - - H3 heading = new H3("Eigenschaften"); - heading.addClassName("panel-heading"); - panel.add(heading); - - Div content = new Div(); - content.addClassName("properties-content"); - panel.add(content); - - return panel; - } - - private void showProperties(int nodeId) { - BlockInstance instance = instances.get(nodeId); - if (instance == null) { - showEmpty(); + /** + * Öffnet die Einstellungen eines Blocks in einem Dialog. Beim Script-Block + * werden zuvor die mit dem Eingang verbundenen Elemente aus dem aktuellen + * Canvas-Graphen ermittelt, um sie als Variablen anzubieten. + */ + private void openSettingsDialog(BlockInstance instance) { + if ("script".equals(instance.type().id())) { + nodeEditor.exportGraph(graphJson -> openScriptDialog(instance, graphJson)); return; } - propertiesContent.removeAll(); + openBlockDialog(instance); + } - Div badge = new Div(); - badge.addClassName("properties-badge"); - badge.getStyle().set("background", instance.type().color()); - badge.add(new Icon(parseIcon(instance.type().icon()))); - Span name = new Span(instance.type().label()); - name.addClassName("properties-name"); - - HorizontalLayout title = new HorizontalLayout(badge, name); - title.setAlignItems(FlexComponent.Alignment.CENTER); - title.addClassName("properties-title"); - propertiesContent.add(title); + private void openBlockDialog(BlockInstance instance) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle(instance.type().label()); + dialog.setWidth("440px"); Paragraph desc = new Paragraph(instance.type().description()); desc.addClassName("properties-desc"); - propertiesContent.add(desc); + VerticalLayout content = new VerticalLayout(desc, topicField(instance)); + content.setPadding(false); + content.setSpacing(true); + for (PropertyDefinition def : instance.type().properties()) { + content.add(buildField(instance, def)); + } + dialog.add(content); + + Button close = new Button("Schließen", ev -> dialog.close()); + close.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + dialog.getFooter().add(close); + dialog.open(); + } + + private void openScriptDialog(BlockInstance instance, String graphJson) { + List upstream = upstreamElements(graphJson, instance.id()); + List variables = upstream.stream() + .flatMap(b -> variablesFor(b).stream()) + .toList(); + + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Skript: " + instance.type().label()); + dialog.setWidth("82vw"); + dialog.setHeight("80vh"); + + // Verfügbare Variablen (mit dem Eingang verbundene Elemente) anzeigen. + Div vars = new Div(); + vars.addClassName("script-vars"); + Span varsLabel = new Span("Verfügbare Variablen:"); + varsLabel.addClassName("script-vars-label"); + vars.add(varsLabel); + if (variables.isEmpty()) { + Span none = new Span("keine verbundenen Eingangselemente"); + none.addClassName("script-vars-empty"); + vars.add(none); + } else { + for (VariableDef variable : variables) { + Span chip = new Span(variable.label()); + chip.addClassName("script-var-chip"); + vars.add(chip); + } + } + + ScriptEditor editor = new ScriptEditor(); + editor.getStyle().set("flex", "1").set("minHeight", "0").set("minWidth", "0") + .set("border", "1px solid var(--lumo-contrast-10pct)") + .set("border-radius", "var(--lumo-border-radius-m)"); + + // Operatoren-Palette links (wie im Workflow-Editor). + VerticalLayout palette = new VerticalLayout(); + palette.addClassName("script-palette"); + palette.setPadding(false); + palette.setSpacing(false); + palette.setWidth("190px"); + palette.setHeightFull(); + H3 paletteHeading = new H3("Operatoren"); + paletteHeading.addClassName("panel-heading"); + palette.add(paletteHeading, + scriptPaletteTile("constant", "Konstante", "#0891b2", "C"), + scriptPaletteTile("variable", "Variable", "#059669", "x"), + scriptPaletteTile("compare", "Vergleich", "#2563eb", "="), + scriptPaletteTile("if", "Wenn", "#d97706", "?"), + scriptPaletteTile("and", "UND", "#7c3aed", "∧"), + scriptPaletteTile("or", "ODER", "#7c3aed", "∨"), + scriptPaletteTile("not", "NICHT", "#7c3aed", "¬"), + scriptPaletteTile("result", "Ergebnis", "#dc2626", "✓"), + scriptPaletteTile("output", "Ausgang", "#0f766e", "→") + ); + + HorizontalLayout workspace = new HorizontalLayout(palette, editor); + workspace.setSizeFull(); + workspace.setPadding(false); + workspace.setSpacing(true); + workspace.expand(editor); + + VerticalLayout content = new VerticalLayout(topicField(instance), vars, workspace); + content.setSizeFull(); + content.setPadding(false); + content.setSpacing(true); + content.expand(workspace); + dialog.add(content); + + Object existing = instance.get("script"); + if (existing != null && !existing.toString().isBlank()) { + editor.setGraph(existing.toString()); + } + editor.setVariables(toVariableArray(variables)); + + Button apply = new Button("Übernehmen", new Icon(VaadinIcon.CHECK), ev -> + editor.exportGraph(json -> { + instance.set("script", json); + int outputs = countScriptOutputs(json); + nodeEditor.setNodeOutputs(instance.id(), outputs); + Notification.show("Skript übernommen (" + outputs + " Ausgang/Ausgänge)."); + dialog.close(); + })); + apply.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + Button cancel = new Button("Abbrechen", ev -> dialog.close()); + cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + dialog.getFooter().add(cancel, apply); + dialog.open(); + } + + private TextField topicField(BlockInstance instance) { TextField topic = new TextField("Bezeichnung"); topic.setWidthFull(); topic.setHelperText("Wird dem Bearbeiter angezeigt (topic)."); @@ -264,11 +349,124 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { nodeEditor.updateNodeLabel(instance.id(), value == null || value.isBlank() ? instance.type().label() : value); }); - propertiesContent.add(topic); + return topic; + } - for (PropertyDefinition def : instance.type().properties()) { - propertiesContent.add(buildField(instance, def)); + /** Ziehbare Operator-Kachel für die Script-Palette (Drag & Drop auf den Canvas). */ + private Component scriptPaletteTile(String typeId, String label, String color, String symbol) { + Div tile = new Div(); + tile.addClassName("script-tile"); + tile.getElement().setAttribute("draggable", "true"); + tile.getElement().setAttribute("data-logictype", typeId); + tile.getStyle().set("--block-color", color); + + Div icon = new Div(); + icon.addClassName("script-tile-icon"); + icon.getStyle().set("background", color); + icon.setText(symbol); + + Span name = new Span(label); + name.addClassName("script-tile-label"); + + tile.add(icon, name); + return tile; + } + + /** Mit dem Eingang des Script-Blocks verbundene (vorgelagerte) Elemente. */ + private List upstreamElements(String graphJson, int scriptId) { + return upstreamNodeIds(graphJson, scriptId).stream() + .map(instances::get) + .filter(java.util.Objects::nonNull) + .filter(b -> !"script".equals(b.type().id())) + .sorted((a, b) -> Integer.compare(a.id(), b.id())) + .toList(); + } + + /** Folgt den eingehenden Verbindungen des Knotens transitiv (alle Vorgänger). */ + private Set upstreamNodeIds(String graphJson, int scriptId) { + Set result = new LinkedHashSet<>(); + if (graphJson == null || graphJson.isBlank()) { + return result; } + try { + JsonNode data = objectMapper.readTree(graphJson).path("drawflow").path("Home").path("data"); + Deque queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(scriptId); + while (!queue.isEmpty()) { + int current = queue.poll(); + if (!visited.add(current)) { + continue; + } + JsonNode inputs = data.path(String.valueOf(current)).path("inputs"); + inputs.forEach(input -> input.path("connections").forEach(conn -> { + JsonNode node = conn.path("node"); + if (!node.isMissingNode()) { + int source = Integer.parseInt(node.asText()); + if (source != scriptId && result.add(source)) { + queue.add(source); + } + } + })); + } + } catch (Exception ignored) { + // Ungültiger Graph -> keine Variablen. + } + return result; + } + + /** Zählt die „Ausgang"-Knoten im Skript – bestimmt die Ausgänge des Script-Elements. */ + private int countScriptOutputs(String json) { + if (json == null || json.isBlank()) { + return 0; + } + try { + JsonNode data = objectMapper.readTree(json).path("drawflow").path("Home").path("data"); + int count = 0; + for (JsonNode node : data) { + if ("output".equals(node.path("name").asText())) { + count++; + } + } + return count; + } catch (Exception ex) { + return 0; + } + } + + private JsonArray toVariableArray(List definitions) { + JsonArray variables = Json.createArray(); + int i = 0; + for (VariableDef def : definitions) { + JsonObject obj = Json.createObject(); + obj.put("key", def.key()); + obj.put("label", def.label()); + variables.set(i++, obj); + } + return variables; + } + + /** Beschreibt eine vom Script referenzierbare Variable (Schlüssel + Anzeige). */ + private record VariableDef(String key, String label) { + } + + /** + * Liefert die Variablen, die ein Element dem Script bereitstellt. + * Die meisten Elemente geben ihren erfassten Wert weiter; das Element + * „Fotos aufnehmen" stellt die Anzahl der aufgenommenen Fotos bereit. + */ + private List variablesFor(BlockInstance element) { + String base = variableLabel(element); + if ("fotos-aufnehmen".equals(element.type().id())) { + return List.of(new VariableDef(element.id() + ".count", base + " · Anzahl Fotos")); + } + return List.of(new VariableDef(String.valueOf(element.id()), base)); + } + + private String variableLabel(BlockInstance element) { + return element.topic() == null || element.topic().isBlank() + ? element.type().label() + : element.topic(); } private Component buildField(BlockInstance instance, PropertyDefinition def) { @@ -334,7 +532,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } instances.clear(); - showEmpty(); nodeEditor.importTasks(descriptors, idsJson -> applyImportedIds(idsJson, resolved)); } @@ -475,7 +672,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } currentListName = document.getName(); nodeEditor.importGraph(document.getLayout()); - showEmpty(); Notification.show("Canvas „" + document.getName() + "“ geladen."); } @@ -537,9 +733,4 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { dialog.getFooter().add(close, save); dialog.open(); } - - private void showEmpty() { - propertiesContent.removeAll(); - propertiesContent.add(propertiesEmpty); - } }