Improve node editor layout and inspector UX

This commit is contained in:
Remi
2025-09-21 09:16:34 +02:00
committed by itsrubberduck
parent 777234ae07
commit 6639f3d156
2 changed files with 309 additions and 55 deletions

View File

@@ -41,12 +41,80 @@
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
@dblclick.prevent="() => emit('select', node.id)"
>
<button class="node-add node-add--top" @click.stop="emit('add-before', node.id)">
<v-icon icon="mdi-plus" size="16" />
</button>
<button class="node-add node-add--bottom" @click.stop="emit('add-after', node.id)">
<v-icon icon="mdi-plus" size="16" />
</button>
<v-tooltip location="top" :open-delay="120">
<template #activator="{ props }">
<button
class="node-add node-add--top"
:class="{ 'node-add--connected': connections(node.id, 'incoming').length > 0 }"
v-bind="props"
@click.stop="emit('add-before', node.id)"
>
<v-icon icon="mdi-plus" size="16" />
</button>
</template>
<div class="node-connection-tooltip">
<p class="node-connection-tooltip__title">Eingehende Verbindungen</p>
<p
v-if="!connections(node.id, 'incoming').length"
class="node-connection-tooltip__empty"
>
Keine eingehenden Verbindungen
</p>
<ul v-else class="node-connection-tooltip__list">
<li
v-for="connection in connections(node.id, 'incoming')"
:key="connection.key"
class="node-connection-tooltip__item"
>
<span class="node-connection-tooltip__id">{{ connection.id }}</span>
<span v-if="connection.title" class="node-connection-tooltip__title-text"> {{ connection.title }}</span>
<span
class="node-connection-tooltip__badge"
:class="{ 'node-connection-tooltip__badge--auto': connection.auto }"
>
{{ connectionLabel(connection) }}
</span>
</li>
</ul>
</div>
</v-tooltip>
<v-tooltip location="bottom" :open-delay="120">
<template #activator="{ props }">
<button
class="node-add node-add--bottom"
:class="{ 'node-add--connected': connections(node.id, 'outgoing').length > 0 }"
v-bind="props"
@click.stop="emit('add-after', node.id)"
>
<v-icon icon="mdi-plus" size="16" />
</button>
</template>
<div class="node-connection-tooltip">
<p class="node-connection-tooltip__title">Ausgehende Verbindungen</p>
<p
v-if="!connections(node.id, 'outgoing').length"
class="node-connection-tooltip__empty"
>
Keine ausgehenden Verbindungen
</p>
<ul v-else class="node-connection-tooltip__list">
<li
v-for="connection in connections(node.id, 'outgoing')"
:key="connection.key"
class="node-connection-tooltip__item"
>
<span class="node-connection-tooltip__id">{{ connection.id }}</span>
<span v-if="connection.title" class="node-connection-tooltip__title-text"> {{ connection.title }}</span>
<span
class="node-connection-tooltip__badge"
:class="{ 'node-connection-tooltip__badge--auto': connection.auto }"
>
{{ connectionLabel(connection) }}
</span>
</li>
</ul>
</div>
</v-tooltip>
<span class="node-connector node-connector--input" />
<span class="node-connector node-connector--output" />
<div class="flex h-full flex-col">
@@ -105,10 +173,10 @@
</div>
<div
ref="minimapRef"
class="pointer-events-auto absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/40 p-3 shadow-lg"
class="pointer-events-auto absolute bottom-4 right-4 z-20 rounded-xl border border-white/10 bg-black/40 p-3 shadow-lg"
@click="onMinimapClick"
>
<svg :width="minimapSize.width" :height="minimapSize.height" class="text-white/70">
<svg :width="minimapSize.width" :height="minimapSize.height" class="block text-white/70">
<g>
<rect
v-for="node in preparedNodes"
@@ -152,6 +220,8 @@ const NODE_HEIGHT = 160
const WORKSPACE_PADDING = 800
const MIN_WORKSPACE_WIDTH = 4000
const MIN_WORKSPACE_HEIGHT = 2800
const MIN_NODE_MARGIN_X = 200
const MIN_NODE_MARGIN_Y = 120
const DRAG_MARGIN = 200
interface CanvasNodePreviewTransition {
@@ -178,6 +248,16 @@ interface CanvasNodeInput {
accent: string
}
interface NodeConnectionPreview {
key: string
id: string
title?: string
type: DecisionNodeTransition['type']
auto: boolean
}
type ConnectionDirection = 'incoming' | 'outgoing'
interface CanvasPan {
x: number
y: number
@@ -220,6 +300,18 @@ const MINIMAP_PADDING = 200
let resizeObserver: ResizeObserver | null = null
const workspaceOffset = computed(() => {
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<string, CanvasNodeInput>()
const result = new Map<string, { incoming: NodeConnectionPreview[]; outgoing: NodeConnectionPreview[] }>()
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;
}
</style>

View File

@@ -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"
>
<v-icon icon="mdi-radar" size="22" color="cyan" />
</a>
<div class="hidden h-10 w-px bg-white/10 lg:block" />
<div class="hidden h-11 w-px bg-white/10 lg:block" />
<div class="flex min-w-0 items-center gap-2">
<v-menu v-model="flowMenuOpen" transition="scale-transition" offset-y>
<template #activator="{ props }">
@@ -29,7 +29,7 @@
color="cyan"
variant="tonal"
prepend-icon="mdi-sitemap"
class="rounded-lg px-4 font-semibold tracking-wide text-white"
class="app-bar-button px-4 font-semibold tracking-wide text-white"
:loading="flowsLoading"
>
{{ currentFlowLabel }}
@@ -103,13 +103,13 @@
<div class="flex min-w-0 flex-1 items-center gap-3">
<v-text-field
v-model="nodeFilter.search"
density="compact"
density="comfortable"
variant="solo"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
placeholder="Nodes durchsuchen"
class="max-w-[260px] rounded-xl bg-white/5 text-sm text-white/80"
class="app-bar-field flex-1 min-w-[200px] text-sm text-white/80"
/>
<v-menu v-model="filtersMenuOpen" transition="scale-transition" :close-on-content-click="false" offset-y>
<template #activator="{ props }">
@@ -122,11 +122,10 @@
>
<v-btn
v-bind="props"
size="small"
variant="outlined"
color="cyan"
prepend-icon="mdi-tune"
class="rounded-lg border-white/20 text-xs font-semibold uppercase tracking-[0.3em]"
class="app-bar-button border-white/20 text-xs font-semibold uppercase tracking-[0.3em]"
>
Filter
</v-btn>
@@ -187,7 +186,7 @@
icon
variant="tonal"
color="white"
class="rounded-lg border border-white/10 bg-white/5 text-white/80 hover:border-cyan-200"
class="app-bar-button app-bar-icon-button border border-white/10 bg-white/5 text-white/80 hover:border-cyan-200"
@click="inspectorOpen = !inspectorOpen"
>
<v-icon :icon="inspectorOpen ? 'mdi-dock-right' : 'mdi-dock-left'" />
@@ -195,9 +194,8 @@
<v-btn
color="white"
variant="tonal"
size="small"
prepend-icon="mdi-auto-fix"
class="rounded-lg px-4 font-semibold tracking-wide text-white"
class="app-bar-button px-4 font-semibold tracking-wide text-white"
:disabled="!flowDetail"
@click="autoLayoutNodes"
>
@@ -356,13 +354,7 @@
v-if="inspectorOpen && flowDetail"
class="w-[380px] shrink-0 overflow-y-auto border-l border-white/10 bg-[#0b1224]/85 backdrop-blur"
>
<div class="border-b border-white/10 px-5 py-4">
<h2 class="text-lg font-semibold">Node Inspector</h2>
<p class="text-xs text-white/50">
{{ nodeForm ? 'Bearbeite Knoten und Auto-Trigger' : 'Wähle einen Node auf der Canvas aus' }}
</p>
</div>
<div v-if="nodeForm" class="space-y-5 px-5 py-5">
<div v-if="nodeForm" class="space-y-5 px-5 py-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
@@ -426,10 +418,9 @@
<v-icon icon="mdi-tag-text-outline" />
</v-tab>
</v-tabs>
<v-window v-model="inspectorTab" class="rounded-xl border border-white/10 bg-white/5 p-4">
<v-window v-model="inspectorTab" class="rounded-xl border border-white/10 bg-white/5 p-4">
<v-window-item value="general">
<div class="space-y-4">
<v-text-field v-model="nodeForm.title" label="Titel" hide-details color="cyan" />
<v-textarea v-model="nodeForm.summary" label="Kurzbeschreibung" rows="2" hide-details color="cyan" />
<div class="grid grid-cols-2 gap-3">
<v-select
@@ -657,10 +648,10 @@
<v-text-field v-model="nodeForm.layout.icon" label="Icon" hide-details color="cyan" />
</div>
</v-window-item>
</v-window>
</v-window>
</div>
<div v-else class="px-5 py-8 text-sm text-white/50">
Kein Node ausgewählt.
<div v-else class="px-5 py-6 text-sm text-white/50">
Wähle einen Node auf der Canvas aus.
</div>
</aside>
</transition>
@@ -1039,6 +1030,22 @@ watch(selectedNodeId, (stateId) => {
})
})
watch(
inspectorOpen,
(open) => {
if (!open || !flowDetail.value) return
if (selectedNodeId.value) return
const preferred =
flowDetail.value.flow.startState &&
flowDetail.value.nodes.some((node) => node.stateId === flowDetail.value!.flow.startState)
? flowDetail.value.flow.startState
: flowDetail.value.nodes[0]?.stateId
if (preferred) {
selectedNodeId.value = preferred
}
}
)
watch(
nodeForm,
(value) => {
@@ -1056,18 +1063,6 @@ watch(
{ deep: true }
)
watch(
() => nodeForm.value?.title,
(title) => {
if (!nodeForm.value) return
const suggestion = buildNodeKeyFromText(title || nodeForm.value.stateId)
if (!nodeRenaming && nodeIdDraft.value === lastTitleSuggestion) {
nodeIdDraft.value = suggestion
}
lastTitleSuggestion = suggestion
}
)
watch(
() => flowForm.startState,
(start) => {
@@ -2184,4 +2179,50 @@ function removePlaceholder(index: number) {
.sidebar-button {
@apply w-full rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left transition hover:border-cyan-400;
}
.app-bar-button {
height: 44px;
min-height: 44px;
border-radius: 0.75rem;
}
.app-bar-button :deep(.v-btn__overlay) {
border-radius: 0.75rem;
}
.app-bar-icon-button {
width: 44px;
min-width: 44px;
padding: 0;
}
.app-bar-icon-button :deep(.v-btn__content) {
height: 100%;
align-items: center;
}
.app-bar-field {
@apply rounded-xl;
}
.app-bar-field :deep(.v-field) {
border-radius: 0.75rem;
background-color: rgba(255, 255, 255, 0.08);
min-height: 44px;
}
.app-bar-field :deep(.v-field__overlay) {
opacity: 0;
border-radius: 0.75rem;
}
.app-bar-field :deep(.v-field__input) {
min-height: 44px;
padding-top: 0;
padding-bottom: 0;
}
.app-bar-field :deep(.v-field__prepend-inner) {
align-items: center;
}
</style>