Refine editor toolbar and edge routing

This commit is contained in:
Remi
2025-09-20 21:18:26 +02:00
parent 3c41293520
commit 1380c21575
2 changed files with 367 additions and 153 deletions

View File

@@ -20,8 +20,8 @@
:stroke="edge.color"
:stroke-width="edge.highlighted ? 3 : 1.75"
:stroke-dasharray="edge.dashed ? '6 6' : undefined"
stroke-linecap="'round'"
stroke-linejoin="'round'"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-all duration-150"
opacity="0.9"
/>
@@ -238,8 +238,16 @@ interface EdgeDefinition {
highlighted: boolean
}
interface NodePosition {
x: number
y: number
width: number
height: number
highlighted?: boolean
}
const edges = computed<EdgeDefinition[]>(() => {
const positions = new Map(
const positions = new Map<string, NodePosition>(
preparedNodes.value.map((node) => [
node.id,
{
@@ -261,7 +269,9 @@ const edges = computed<EdgeDefinition[]>(() => {
for (const transition of node.model.transitions || []) {
const target = positions.get(transition.target)
if (!target) continue
const path = computeEdgePath(source, target)
const path = computeEdgePath(source, target, {
selfLoop: transition.target === node.id,
})
const color = transitionColor(transition)
const dashed = Boolean(transition.autoTrigger || transition.type === 'auto')
const highlighted = node.selected || source.highlighted
@@ -330,17 +340,129 @@ function transitionColor(transition: DecisionNodeTransition) {
}
}
function computeEdgePath(source: { x: number; y: number; width: number; height: number }, target: { x: number; y: number; width: number; height: number }) {
type PathPoint = { x: number; y: number }
function computeEdgePath(
source: NodePosition,
target: NodePosition,
options: { selfLoop?: boolean } = {}
) {
if (options.selfLoop) {
return computeSelfLoopPath(source)
}
const startX = source.x + source.width / 2
const startY = source.y + source.height
const endX = target.x + target.width / 2
const endY = target.y
const deltaY = Math.max(80, Math.abs(endY - startY) / 1.5)
const control1Y = startY + deltaY
const control2Y = endY - deltaY
return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}`
if (endY >= startY) {
const verticalDistance = endY - startY
const deltaY = Math.max(80, verticalDistance / 1.5)
const control1Y = startY + deltaY
const control2Y = endY - Math.min(deltaY, verticalDistance / 2)
return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}`
}
const exitOffset = Math.max(24, Math.min(72, source.height * 0.25))
const horizontalGap = Math.abs(endX - startX)
const horizontalExtra = Math.max(180, horizontalGap * 0.6)
const outerRight = Math.max(source.x + source.width, target.x + target.width)
const outerLeft = Math.min(source.x, target.x)
const direction = endX >= startX ? 1 : -1
const sideX = direction === 1 ? outerRight + horizontalExtra : outerLeft - horizontalExtra
const dropY = startY + exitOffset
const topCandidate = Math.min(source.y, target.y) - 100
const topLimit = Math.min(startY - 60, endY - 60)
let loopTop = Math.min(topCandidate, topLimit)
if (!Number.isFinite(loopTop)) {
loopTop = topLimit
}
if (!Number.isFinite(loopTop)) {
loopTop = startY - 80
}
if (loopTop >= dropY - 20) {
loopTop = dropY - 60
}
const points: PathPoint[] = [
{ x: startX, y: startY },
{ x: startX, y: dropY },
{ x: sideX, y: dropY },
{ x: sideX, y: loopTop },
{ x: endX, y: loopTop },
{ x: endX, y: endY },
]
return buildSmoothPath(points, 48)
}
function computeSelfLoopPath(node: NodePosition) {
const startX = node.x + node.width / 2
const startY = node.y + node.height
const exitOffset = Math.max(28, Math.min(72, node.height * 0.25))
const sideOffset = Math.max(200, node.width * 0.75)
const sideX = node.x + node.width + sideOffset
const loopTop = node.y - 120
const points: PathPoint[] = [
{ x: startX, y: startY },
{ x: startX, y: startY + exitOffset },
{ x: sideX, y: startY + exitOffset },
{ x: sideX, y: loopTop },
{ x: startX, y: loopTop },
{ x: startX, y: node.y },
]
return buildSmoothPath(points, 44)
}
function buildSmoothPath(points: PathPoint[], radius = 32) {
if (points.length < 2) {
return ''
}
const commands: string[] = [`M ${points[0].x} ${points[0].y}`]
for (let i = 1; i < points.length; i += 1) {
const prev = points[i - 1]
const curr = points[i]
const next = points[i + 1]
if (!next) {
commands.push(`L ${curr.x} ${curr.y}`)
continue
}
const prevVectorX = curr.x - prev.x
const prevVectorY = curr.y - prev.y
const nextVectorX = next.x - curr.x
const nextVectorY = next.y - curr.y
const prevLength = Math.hypot(prevVectorX, prevVectorY)
const nextLength = Math.hypot(nextVectorX, nextVectorY)
if (prevLength === 0 || nextLength === 0) {
commands.push(`L ${curr.x} ${curr.y}`)
continue
}
const startOffset = Math.min(radius, prevLength / 2)
const endOffset = Math.min(radius, nextLength / 2)
const startX = curr.x - (prevVectorX / prevLength) * startOffset
const startY = curr.y - (prevVectorY / prevLength) * startOffset
const endX = curr.x + (nextVectorX / nextLength) * endOffset
const endY = curr.y + (nextVectorY / nextLength) * endOffset
commands.push(`L ${startX} ${startY}`)
commands.push(`Q ${curr.x} ${curr.y} ${endX} ${endY}`)
}
return commands.join(' ')
}
let panPointerId: number | null = null
let panStart: CanvasPan = { x: 0, y: 0 }
let pointerOrigin = { x: 0, y: 0 }

View File

@@ -6,127 +6,214 @@
<v-app-bar
flat
density="comfortable"
color="rgba(11, 18, 36, 0.92)"
class="shrink-0 border-b border-white/10 backdrop-blur-md"
height="76"
color="rgba(7, 11, 21, 0.9)"
class="shrink-0 border-b border-white/10 backdrop-blur-xl"
height="64"
>
<div class="flex w-full items-center gap-4 overflow-hidden px-1">
<div class="flex shrink-0 items-center gap-2">
<v-icon icon="mdi-radar" size="24" color="cyan" />
<div class="leading-tight">
<p class="text-[11px] uppercase tracking-[0.35em] text-cyan-300/70">OpenSquawk</p>
<p class="text-sm font-semibold text-white/90">Decision Flow Studio</p>
<div class="flex w-full flex-wrap items-center gap-4 px-3 lg:flex-nowrap lg:gap-6 lg:px-5">
<div class="flex shrink-0 items-center gap-4">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl border border-cyan-400/40 bg-cyan-500/10"
>
<v-icon icon="mdi-radar" size="22" color="cyan" />
</div>
<div class="min-w-0 leading-tight">
<p class="text-[11px] uppercase tracking-[0.45em] text-cyan-200/70">OpenSquawk</p>
<p class="text-sm font-semibold text-white/90">Decision Flow Studio</p>
</div>
</div>
<div class="hidden h-10 w-px bg-white/10 lg:block" />
<div class="flex min-w-0 items-center gap-2">
<v-autocomplete
v-model="selectedFlowSlug"
v-model:search="flowSearch"
:items="filteredFlows"
item-title="name"
item-value="slug"
density="compact"
variant="outlined"
hide-details
clearable
class="w-[220px] md:w-[260px]"
prepend-inner-icon="mdi-file-tree"
:loading="flowsLoading"
label="Flow auswählen"
:custom-filter="() => true"
@update:model-value="(value) => value && selectFlow(value)"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #title>
<div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ item?.raw?.name }}</span>
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
{{ item.raw.nodeCount }}
</v-chip>
</div>
</template>
<template #subtitle>
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
</template>
</v-list-item>
</template>
</v-autocomplete>
<div class="flex shrink-0 items-center gap-1">
<v-tooltip text="Neuen Flow anlegen">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
color="cyan"
class="rounded-lg border border-white/10 bg-white/5 text-white/80 hover:border-cyan-300"
@click="openCreateFlow"
>
<v-icon icon="mdi-plus" />
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Legacy ATC Import">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
color="purple"
class="rounded-lg border border-white/10 bg-white/5 text-white/80 hover:border-purple-300"
:loading="importLoading"
@click="runImport"
>
<v-icon icon="mdi-database-import" />
</v-btn>
</template>
</v-tooltip>
</div>
</div>
</div>
<v-divider vertical class="h-9 border-white/10" />
<div class="flex items-center gap-2 min-w-0">
<v-autocomplete
v-model="selectedFlowSlug"
v-model:search="flowSearch"
:items="filteredFlows"
item-title="name"
item-value="slug"
density="compact"
variant="outlined"
hide-details
clearable
class="w-[240px]"
prepend-inner-icon="mdi-file-tree"
:loading="flowsLoading"
label="Flow auswählen"
:custom-filter="() => true"
@update:model-value="(value) => value && selectFlow(value)"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #title>
<div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ item?.raw?.name }}</span>
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
{{ item.raw.nodeCount }}
</v-chip>
</div>
</template>
<template #subtitle>
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
</template>
</v-list-item>
</template>
</v-autocomplete>
<div class="flex shrink-0 items-center gap-1">
<v-tooltip text="Neuen Flow anlegen">
<template #activator="{ props }">
<v-btn v-bind="props" icon size="small" variant="text" color="cyan" @click="openCreateFlow">
<v-icon icon="mdi-plus" />
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Legacy ATC Import">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
color="purple"
:loading="importLoading"
@click="runImport"
>
<v-icon icon="mdi-database-import" />
</v-btn>
</template>
</v-tooltip>
</div>
</div>
<v-divider vertical class="h-9 border-white/10" />
<div class="flex items-center gap-2 min-w-0">
<div class="flex min-w-0 flex-1 items-center gap-3">
<v-text-field
v-model="nodeFilter.search"
density="compact"
variant="outlined"
variant="solo"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
placeholder="Node suchen"
class="w-[200px]"
placeholder="Nodes durchsuchen"
class="max-w-[260px] rounded-xl bg-white/5 text-sm text-white/80"
/>
<v-select
v-model="nodeFilter.role"
:items="roleFilterOptions"
density="compact"
hide-details
variant="outlined"
class="w-[140px]"
label="Rolle"
/>
<v-select
v-model="nodeFilter.phase"
:items="phaseFilterOptions"
density="compact"
hide-details
variant="outlined"
class="w-[160px]"
label="Phase"
/>
<v-tooltip text="Nur Auto-Trigger anzeigen">
<v-menu v-model="filtersMenuOpen" transition="scale-transition" :close-on-content-click="false" offset-y>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
:color="nodeFilter.autopOnly ? 'amber' : undefined"
class="text-white/70 hover:text-white"
@click="toggleAutopOnly"
<v-badge
:content="activeFilterCount"
color="cyan"
offset-x="6"
offset-y="6"
:model-value="activeFilterCount > 0"
>
<v-icon icon="mdi-auto-fix" />
</v-btn>
<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]"
>
Filter
</v-btn>
</v-badge>
</template>
</v-tooltip>
<v-card class="w-[320px] border border-white/10 bg-[#050910]/95 backdrop-blur">
<v-card-title class="flex items-center justify-between text-sm font-semibold text-white/90">
Node-Filter
<v-chip v-if="activeFilterCount > 0" color="cyan" size="x-small" variant="flat">
{{ activeFilterCount }}
</v-chip>
</v-card-title>
<v-divider class="border-white/10" />
<v-card-text class="space-y-4">
<v-select
v-model="nodeFilter.role"
:items="roleFilterOptions"
density="comfortable"
hide-details
label="Rolle"
color="cyan"
/>
<v-select
v-model="nodeFilter.phase"
:items="phaseFilterOptions"
density="comfortable"
hide-details
label="Phase"
color="cyan"
/>
<v-switch
v-model="nodeFilter.autopOnly"
hide-details
inset
color="amber"
class="text-sm text-white/70"
label="Nur Auto-Trigger anzeigen"
/>
</v-card-text>
<v-divider class="border-white/10" />
<v-card-actions class="justify-between">
<v-btn variant="text" color="white" @click="resetNodeFilters">Zurücksetzen</v-btn>
<v-btn color="cyan" variant="flat" @click="filtersMenuOpen = false">Fertig</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</div>
<v-divider vertical class="h-9 border-white/10" />
<div class="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
<div class="flex shrink-0 items-center gap-2 text-xs uppercase tracking-widest text-white/60">
<span>Nodes</span>
<div class="flex shrink-0 items-center gap-3">
<div class="hidden min-w-0 text-right leading-tight sm:block">
<p class="text-[11px] uppercase tracking-[0.35em] text-white/40">Aktiver Flow</p>
<p class="truncate text-sm font-semibold text-white/90">
{{ flowForm.name || 'Kein Flow ausgewählt' }}
</p>
</div>
<v-chip
v-if="flowDetail"
color="cyan"
size="small"
variant="tonal"
class="font-mono text-xs uppercase tracking-widest text-cyan-100"
>
{{ flowDetail.flow.slug }}
</v-chip>
<v-btn
color="cyan"
variant="flat"
size="small"
prepend-icon="mdi-content-save"
class="rounded-lg px-4 font-semibold tracking-wide"
:disabled="!flowDirty"
:loading="flowSaveLoading"
@click="saveFlow"
>
Speichern
</v-btn>
<v-btn
color="white"
variant="tonal"
size="small"
prepend-icon="mdi-auto-fix"
class="rounded-lg px-4 font-semibold tracking-wide text-white"
:disabled="!flowDetail"
@click="autoLayoutNodes"
>
Auto-Layout
</v-btn>
</div>
</div>
</v-app-bar>
<div class="border-b border-white/10 bg-[#070d1a]/70 px-4 py-2 backdrop-blur">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
<div class="flex flex-1 items-center gap-3">
<div class="flex shrink-0 items-center gap-2 text-[11px] uppercase tracking-[0.4em] text-white/40">
<span class="text-white/70">Nodes</span>
<v-chip v-if="flowDetail" size="x-small" color="cyan" variant="flat">
{{ nodeSelectorItems.length }}
</v-chip>
@@ -145,10 +232,7 @@
:value="node.id"
v-slot="{ isSelected, toggle }"
>
<v-tooltip
:text="`${node.id}${node.title || 'Ohne Titel'}`"
location="bottom"
>
<v-tooltip :text="`${node.id}${node.title || 'Ohne Titel'}`" location="bottom">
<template #activator="{ props }">
<span class="inline-flex">
<v-badge
@@ -204,41 +288,23 @@
<p v-else class="truncate text-xs text-white/50">Kein Flow ausgewählt.</p>
</div>
</div>
<v-divider vertical class="h-9 border-white/10" />
<div class="flex shrink-0 items-center gap-3 pl-2 min-w-0">
<div class="min-w-0 text-right leading-tight">
<p class="text-[11px] uppercase tracking-wider text-white/50">Aktueller Flow</p>
<p class="truncate text-sm font-semibold">
{{ flowForm.name || 'Kein Flow ausgewählt' }}
</p>
</div>
<v-chip v-if="flowDetail" color="cyan" size="small" variant="outlined">
{{ flowDetail.flow.slug }}
</v-chip>
<v-btn
color="cyan"
variant="flat"
<div
v-if="activeFilterBadges.length"
class="flex shrink-0 flex-wrap items-center gap-1 text-[11px] uppercase tracking-widest text-white/50 md:justify-end"
>
<v-chip
v-for="badge in activeFilterBadges"
:key="badge.label"
:color="badge.color"
size="small"
prepend-icon="mdi-content-save"
:disabled="!flowDirty"
:loading="flowSaveLoading"
@click="saveFlow"
>
Speichern
</v-btn>
<v-btn
color="cyan"
variant="tonal"
size="small"
prepend-icon="mdi-auto-fix"
@click="autoLayoutNodes"
:disabled="!flowDetail"
class="bg-opacity-20 text-white/80"
>
Auto-Layout
</v-btn>
{{ badge.label }}
</v-chip>
</div>
</div>
</v-app-bar>
</div>
<div
v-if="flowsLoading || flowsError"
class="border-b border-white/10 bg-[#0b1224]/75 px-4 py-2 backdrop-blur"
@@ -712,6 +778,30 @@ const nodeFilter = reactive({
autopOnly: false,
})
const filtersMenuOpen = ref(false)
const activeFilterCount = computed(() => {
let count = 0
if (nodeFilter.role !== 'all') count += 1
if (nodeFilter.phase !== 'all') count += 1
if (nodeFilter.autopOnly) count += 1
return count
})
const activeFilterBadges = computed(() => {
const badges: { label: string; color: string }[] = []
if (nodeFilter.role !== 'all') {
badges.push({ label: `Rolle: ${nodeFilter.role}`, color: 'cyan' })
}
if (nodeFilter.phase !== 'all') {
badges.push({ label: `Phase: ${nodeFilter.phase}`, color: 'purple' })
}
if (nodeFilter.autopOnly) {
badges.push({ label: 'Auto-Trigger', color: 'amber' })
}
return badges
})
const selectedNodeId = ref<string | null>(null)
const inspectorTab = ref<'general' | 'transitions' | 'llm' | 'metadata'>('general')
const nodeForm = ref<DecisionNodeModel | null>(null)
@@ -1048,8 +1138,10 @@ async function runImport() {
}
}
function toggleAutopOnly() {
nodeFilter.autopOnly = !nodeFilter.autopOnly
function resetNodeFilters() {
nodeFilter.role = 'all'
nodeFilter.phase = 'all'
nodeFilter.autopOnly = false
}
function selectNode(stateId: string) {