From 6639f3d15663da73b99262e7ea8fa048d0bb5bd1 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Sun, 21 Sep 2025 09:16:34 +0200 Subject: [PATCH] Improve node editor layout and inspector UX --- app/components/editor/DecisionNodeCanvas.vue | 255 +++++++++++++++++-- app/pages/editor/index.vue | 109 +++++--- 2 files changed, 309 insertions(+), 55 deletions(-) diff --git a/app/components/editor/DecisionNodeCanvas.vue b/app/components/editor/DecisionNodeCanvas.vue index 2134e4d..bc3d659 100644 --- a/app/components/editor/DecisionNodeCanvas.vue +++ b/app/components/editor/DecisionNodeCanvas.vue @@ -41,12 +41,80 @@ @pointerdown.stop="(event) => onNodePointerDown(event, node)" @dblclick.prevent="() => emit('select', node.id)" > - - + + +
+

Eingehende Verbindungen

+

+ Keine eingehenden Verbindungen +

+ +
+
+ + +
+

Ausgehende Verbindungen

+

+ Keine ausgehenden Verbindungen +

+ +
+
@@ -105,10 +173,10 @@
- + { + if (!props.nodes.length) { + return { x: MIN_NODE_MARGIN_X, y: MIN_NODE_MARGIN_Y } + } + const minX = Math.min(...props.nodes.map((node) => node.layout?.x ?? 0)) + const minY = Math.min(...props.nodes.map((node) => node.layout?.y ?? 0)) + return { + x: Math.max(0, MIN_NODE_MARGIN_X - minX), + y: Math.max(0, MIN_NODE_MARGIN_Y - minY), + } +}) + watch( () => props.pan, (value) => { @@ -304,8 +396,9 @@ const viewportRect = computed(() => { } }) -const preparedNodes = computed(() => - props.nodes.map((node) => { +const preparedNodes = computed(() => { + const offset = workspaceOffset.value + return props.nodes.map((node) => { const width = node.layout?.width ?? NODE_WIDTH const height = node.layout?.height ?? NODE_HEIGHT const accent = node.layout?.color || props.roleColors[node.role] || '#22d3ee' @@ -316,15 +409,25 @@ const preparedNodes = computed(() => class: transitionClass(transition), })) + const baseLayout: DecisionNodeLayout = { ...(node.layout || {}) } + const displayLayout: DecisionNodeLayout = { + ...baseLayout, + x: (baseLayout.x ?? 0) + offset.x, + y: (baseLayout.y ?? 0) + offset.y, + width, + height, + } + return { ...node, width, height, accent, + layout: displayLayout, previewTransitions: transitions as CanvasNodePreviewTransition[], } }) -) +}) const canvasBounds = computed(() => { const defaultWidth = MIN_WORKSPACE_WIDTH - WORKSPACE_PADDING @@ -343,6 +446,46 @@ const canvasBounds = computed(() => { } }) +const nodeConnections = computed(() => { + const lookup = new Map() + const result = new Map() + + for (const node of props.nodes) { + lookup.set(node.id, node) + result.set(node.id, { incoming: [], outgoing: [] }) + } + + for (const node of props.nodes) { + const transitions = node.model.transitions || [] + for (const transition of transitions) { + const key = transition.key || `${node.id}_${transition.target}_${transition.type}` + const outgoingEntry = result.get(node.id) + if (outgoingEntry) { + outgoingEntry.outgoing.push({ + key: `out-${key}`, + id: transition.target, + title: lookup.get(transition.target)?.title, + type: transition.type, + auto: Boolean(transition.autoTrigger || transition.type === 'auto'), + }) + } + + const targetEntry = result.get(transition.target) + if (targetEntry) { + targetEntry.incoming.push({ + key: `in-${key}`, + id: node.id, + title: node.title, + type: transition.type, + auto: Boolean(transition.autoTrigger || transition.type === 'auto'), + }) + } + } + } + + return result +}) + interface EdgeDefinition { id: string from: string @@ -475,6 +618,19 @@ function transitionColor(transition: DecisionNodeTransition) { } } +function connections(nodeId: string, direction: ConnectionDirection) { + const entry = nodeConnections.value.get(nodeId) + if (!entry) return [] + return entry[direction] +} + +function connectionLabel(connection: NodeConnectionPreview) { + if (connection.auto) { + return 'AUTO' + } + return connection.type.toUpperCase() +} + type PathPoint = { x: number; y: number } function computeEdgePath( @@ -673,7 +829,8 @@ function onNodePointerMove(event: PointerEvent) { width: dragState.width, height: dragState.height, }) - emit('node-move', { stateId: dragState.nodeId, x: clamped.x, y: clamped.y }) + const actual = toActualPosition(clamped) + emit('node-move', { stateId: dragState.nodeId, x: actual.x, y: actual.y }) } function onNodePointerUp(event: PointerEvent) { @@ -688,18 +845,30 @@ function onNodePointerUp(event: PointerEvent) { width: dragState.width, height: dragState.height, }) - emit('node-drop', { stateId: dragState.nodeId, x: clamped.x, y: clamped.y }) + const actual = toActualPosition(clamped) + emit('node-drop', { stateId: dragState.nodeId, x: actual.x, y: actual.y }) } dragState = null window.removeEventListener('pointermove', onNodePointerMove) } -function clampToWorkspace(x: number, y: number, size: { width: number; height: number }) { - const maxX = Math.max(0, canvasBounds.value.width - size.width - DRAG_MARGIN) - const maxY = Math.max(0, canvasBounds.value.height - size.height - DRAG_MARGIN) +function toActualPosition(position: { x: number; y: number }) { + const offset = workspaceOffset.value return { - x: Math.min(Math.max(0, x), maxX), - y: Math.min(Math.max(0, y), maxY), + x: Math.max(0, position.x - offset.x), + y: Math.max(0, position.y - offset.y), + } +} + +function clampToWorkspace(x: number, y: number, size: { width: number; height: number }) { + const offset = workspaceOffset.value + const minX = offset.x + const minY = offset.y + const maxX = Math.max(minX, canvasBounds.value.width - size.width - DRAG_MARGIN) + const maxY = Math.max(minY, canvasBounds.value.height - size.height - DRAG_MARGIN) + return { + x: Math.min(Math.max(minX, x), maxX), + y: Math.min(Math.max(minY, y), maxY), } } @@ -719,13 +888,17 @@ function resetView() { function onMinimapClick(event: MouseEvent) { const container = minimapRef.value if (!container) return - const rect = container.getBoundingClientRect() + const svgElement = container.querySelector('svg') + const target = svgElement || container + const rect = target.getBoundingClientRect() const offsetX = event.clientX - rect.left const offsetY = event.clientY - rect.top + const clampedX = Math.min(Math.max(0, offsetX), rect.width) + const clampedY = Math.min(Math.max(0, offsetY), rect.height) const bounds = minimapBounds.value const scale = minimapScale.value || 1 - const worldX = offsetX / scale + bounds.minX - const worldY = offsetY / scale + bounds.minY + const worldX = clampedX / scale + bounds.minX + const worldY = clampedY / scale + bounds.minY const zoom = currentZoom.value || 1 const panX = viewportSize.value.width / 2 - worldX * zoom const panY = viewportSize.value.height / 2 - worldY * zoom @@ -779,6 +952,10 @@ onBeforeUnmount(() => { @apply pointer-events-auto absolute z-10 flex h-6 w-6 items-center justify-center rounded-full border border-white/20 bg-black/70 text-white opacity-0 transition; } +.node-add--connected { + @apply border-cyan-400/60 bg-cyan-500/20 text-cyan-100; +} + .group:hover .node-add { @apply opacity-100; } @@ -810,4 +987,40 @@ onBeforeUnmount(() => { left: 50%; transform: translate(-50%, 50%); } + +.node-connection-tooltip { + @apply max-w-[240px] space-y-2 text-left; +} + +.node-connection-tooltip__title { + @apply text-xs font-semibold uppercase tracking-[0.35em] text-white/60; +} + +.node-connection-tooltip__empty { + @apply text-xs text-white/50; +} + +.node-connection-tooltip__list { + @apply space-y-1; +} + +.node-connection-tooltip__item { + @apply flex flex-wrap items-center gap-1 text-xs text-white/80; +} + +.node-connection-tooltip__id { + @apply font-mono text-cyan-200; +} + +.node-connection-tooltip__title-text { + @apply text-white/60; +} + +.node-connection-tooltip__badge { + @apply rounded-full bg-white/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.25em] text-white/60; +} + +.node-connection-tooltip__badge--auto { + @apply bg-cyan-500/25 text-cyan-200; +} diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue index 6e09c7c..c64da09 100644 --- a/app/pages/editor/index.vue +++ b/app/pages/editor/index.vue @@ -16,11 +16,11 @@ href="/" target="_blank" rel="noopener" - class="flex h-10 w-10 items-center justify-center rounded-xl border border-cyan-400/40 bg-cyan-500/10 transition hover:border-cyan-300 hover:bg-cyan-500/20" + class="flex h-11 w-11 items-center justify-center rounded-xl border border-cyan-400/40 bg-cyan-500/10 transition hover:border-cyan-300 hover:bg-cyan-500/20" > -