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 `
+
@@ -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 `
`;
+ case 'variable':
+ return `
`;
+ case 'compare':
+ return `
+
Vergleich
+
+
Eingänge: oben links · unten rechts
+
Ausgänge: oben „trifft zu“ · unten „trifft nicht zu“
+
`;
+ case 'and':
+ return `
`;
+ case 'or':
+ return `
`;
+ case 'not':
+ return `
`;
+ case 'if':
+ return `
+
Wenn
+
① Dann · ② Sonst
+
`;
+ case 'result':
+ return `
`;
+ case 'output':
+ return `
`;
+ 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);
- }
}