fix: polish PARALLEL swimlanes and color-code edge conditions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-20 09:53:01 +08:00
co-authored by Cursor
parent 2c2dd8d0d2
commit 18b62003d1
11 changed files with 229 additions and 81 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ function deleteSelectedEdge() {
@update:value="(v: string) => patchSelected({ name: v })"
/>
</n-form-item>
<n-form-item label="start step">
<n-form-item v-if="!selectedNode.parentNode" label="start step">
<n-switch :disabled="readOnly" :value="isStart" @update:value="onStartChange" />
</n-form-item>
+44 -8
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@vue-flow/core'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow, type EdgeProps } from '@vue-flow/core'
import type { DesignerEdgeData } from '@/types/definition'
import { useDesignerStore } from '@/stores/designer'
const props = defineProps<EdgeProps<DesignerEdgeData>>()
const designer = useDesignerStore()
const { addSelectedEdges, findEdge } = useVueFlow()
const route = computed(() => {
const [path, labelX, labelY] = getBezierPath({
@@ -19,23 +20,58 @@ const route = computed(() => {
return { path, labelX, labelY }
})
const kind = computed(() => (props.label === 'host' || props.label === 'predicate' ? props.label : 'default'))
const picked = computed(() => props.selected || designer.selectedEdgeId === props.id)
const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id))
const edgeStyle = computed(() => ({
stroke: hasError.value ? '#ef4444' : '#94a3b8',
strokeWidth: hasError.value ? 2 : 1.75,
}))
const edgeStyle = computed(() => {
if (hasError.value) {
return { stroke: '#ef4444', strokeWidth: 2.25 }
}
const stroke =
kind.value === 'host' ? (picked.value ? '#b45309' : '#d97706')
: kind.value === 'predicate' ? (picked.value ? '#0369a1' : '#0284c7')
: picked.value ? '#64748b'
: '#94a3b8'
return { stroke, strokeWidth: picked.value ? 2.25 : 1.75 }
})
const labelClass = computed(() => {
if (hasError.value) {
return 'border-red-200 bg-red-50 text-red-600'
}
if (kind.value === 'host') {
return 'border-amber-300 bg-amber-50 text-amber-800'
}
if (kind.value === 'predicate') {
return 'border-sky-300 bg-sky-50 text-sky-800'
}
return picked.value ? 'border-slate-400 bg-white text-slate-800' : 'border-slate-200 bg-white/95 text-slate-600'
})
function selectEdge(event: MouseEvent) {
event.stopPropagation()
const edge = findEdge(props.id)
if (edge) {
addSelectedEdges([edge])
}
designer.selectEdge(props.id)
}
</script>
<template>
<BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" />
<BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" :interaction-width="20" />
<EdgeLabelRenderer>
<div
class="nodrag nopan pointer-events-all absolute rounded-md border border-slate-200 bg-white/95 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 shadow-sm"
:class="hasError ? 'border-red-200 text-red-600' : ''"
class="nodrag nopan pointer-events-all absolute z-10 cursor-pointer rounded-md border px-1.5 py-0.5 text-[10px] font-medium shadow-sm"
:class="labelClass"
:title="label"
:style="{
transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`,
pointerEvents: 'all',
}"
@pointerdown.stop
@click.stop="selectEdge"
>
{{ label }}
</div>
+5 -2
View File
@@ -14,6 +14,9 @@ const props = defineProps<{
const designer = useDesignerStore()
const { action } = useCatalogLabels()
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
const showStart = computed(
() => Boolean(props.data.isStart) && !designer.nodes.some((node) => node.id === props.id && node.parentNode),
)
</script>
<template>
@@ -22,7 +25,7 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
:class="
hasError
? 'border-red-300 ring-2 ring-red-100'
: data.isStart
: data.isStart && showStart
? 'border-amber-300 ring-2 ring-amber-100'
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
"
@@ -36,7 +39,7 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
ACTION
</span>
<span
v-if="data.isStart"
v-if="showStart"
class="rounded-md bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
START
@@ -14,6 +14,9 @@ const props = defineProps<{
const designer = useDesignerStore()
const { assignees } = useCatalogLabels()
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
const showStart = computed(
() => Boolean(props.data.isStart) && !designer.nodes.some((node) => node.id === props.id && node.parentNode),
)
</script>
<template>
@@ -22,7 +25,7 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
:class="
hasError
? 'border-red-300 ring-2 ring-red-100'
: data.isStart
: data.isStart && showStart
? 'border-sky-300 ring-2 ring-sky-100'
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
"
@@ -36,7 +39,7 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
APPROVAL
</span>
<span
v-if="data.isStart"
v-if="showStart"
class="rounded-md bg-sky-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
START
+16 -7
View File
@@ -53,7 +53,7 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">{{ data.name }}</span>
<button
type="button"
class="nodrag nopan inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-40"
class="nodrag nopan cursor-pointer! inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-40"
:disabled="readOnly"
@click.stop="designer.addLane(id)"
>
@@ -64,19 +64,28 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
<div
v-for="(lane, index) in data.lanes"
:key="lane"
class="pointer-events-none absolute right-2 left-0 flex items-start border-b border-violet-100/80"
:style="{ top: `${LANE_HEADER + index * LANE_H}px`, height: `${LANE_H}px` }"
class="pointer-events-none absolute flex items-start rounded-lg border border-violet-100/80"
:style="{ top: `${LANE_HEADER + index * LANE_H + 8}px`, height: `${LANE_H - 12}px`, left: '12px', right: '16px' }"
>
<div class="pointer-events-auto flex h-full flex-col justify-center gap-1 px-2" :style="{ width: `${LANE_LABEL_W}px` }">
<div class="pointer-events-auto flex h-full flex-col justify-center gap-1 pl-2" :style="{ width: `${LANE_LABEL_W - 12}px` }">
<div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div>
<button
type="button"
class="nodrag nopan inline-flex items-center gap-0.5 text-[10px] font-medium text-slate-500 hover:text-violet-700 disabled:opacity-40"
class="nodrag nopan cursor-pointer! inline-flex items-center gap-0.5 text-[10px] font-medium text-sky-700 hover:text-sky-800 disabled:opacity-40"
:disabled="readOnly"
@click.stop="designer.addLaneStep(id, lane)"
@click.stop="designer.addLaneStep(id, lane, 'approval')"
>
<Plus :size="11" />
Step
Approval
</button>
<button
type="button"
class="nodrag nopan cursor-pointer! inline-flex items-center gap-0.5 text-[10px] font-medium text-amber-700 hover:text-amber-800 disabled:opacity-40"
:disabled="readOnly"
@click.stop="designer.addLaneStep(id, lane, 'action')"
>
<Plus :size="11" />
Action
</button>
</div>
</div>
+2 -3
View File
@@ -7,7 +7,7 @@ import type {
StepDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import { formatWhenKind } from '@/mapper/when'
import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel'
export const APPROVAL_TYPE = 'approval'
@@ -40,8 +40,7 @@ export interface DesignerEdge {
}
export function edgeLabel(data: DesignerEdgeData | undefined): string {
const when = formatWhen(data?.when)
return when || 'default'
return formatWhenKind(data?.when) || 'default'
}
export function toDocument(
+68 -37
View File
@@ -1,11 +1,28 @@
import { END_TYPE, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
import { fitParallelFrames } from '@/mapper/parallel'
import { END_TYPE, edgeLabel, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
import { fitParallelFrames, reflowLanes } from '@/mapper/parallel'
const RANK_Y = 220
const NODE_GAP_X = 320
const RANK_GAP_Y = 96
const COL_GAP_X = 88
const ORIGIN_X = 80
const ORIGIN_Y = 40
function boxOf(node: DesignerNode): { width: number; height: number } {
const width = Number.parseFloat(String(node.style?.width ?? ''))
const height = Number.parseFloat(String(node.style?.height ?? ''))
if (width > 0 && height > 0) {
return { width, height }
}
if (node.type === END_TYPE) {
return { width: 148, height: 52 }
}
return { width: 248, height: 140 }
}
function tidyLanes(nodes: DesignerNode[]): DesignerNode[] {
const ids = nodes.filter((node) => node.data?.kind === 'PARALLEL').map((node) => node.id)
return fitParallelFrames(ids.reduce((current, id) => reflowLanes(current, id), nodes))
}
function buildOutgoing(edges: DesignerEdge[]) {
const outgoing = new Map<string, DesignerEdge[]>()
for (const edge of edges) {
@@ -195,11 +212,12 @@ export function assignEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]):
if (!source || !target) {
return {
...edge,
label: edgeLabel(edge.data),
sourceHandle: normalizeHandleId(edge.sourceHandle) ?? 'source-bottom',
targetHandle: normalizeHandleId(edge.targetHandle) ?? 'target-top',
}
}
return { ...edge, ...pickHandlesForEdge(source, target) }
return { ...edge, label: edgeLabel(edge.data), ...pickHandlesForEdge(source, target) }
})
}
@@ -213,28 +231,27 @@ export function applyLayeredLayout(
edges: DesignerEdge[],
startStepId: string,
): { nodes: DesignerNode[]; edges: DesignerEdge[] } {
const parked = nodes.filter((node) => node.parentNode)
if (parked.length > 0) {
const top = nodes.filter((node) => !node.parentNode)
const topIds = new Set(top.map((node) => node.id))
const tidy = tidyLanes(nodes)
const parked = tidy.filter((node) => node.parentNode)
const top = tidy.filter((node) => !node.parentNode)
if (top.length === 0) {
return { nodes: tidy, edges: assignEdgeHandles(tidy, edges) }
}
const byId = new Map(top.map((n) => [n.id, n]))
const nodeIds = top.map((n) => n.id)
const topIds = new Set(nodeIds)
const topEdges = edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target))
const laid = applyLayeredLayout(top, topEdges, startStepId)
const merged = fitParallelFrames([...laid.nodes, ...parked])
return { nodes: merged, edges: assignEdgeHandles(merged, edges) }
}
if (nodes.length === 0) {
return { nodes, edges }
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const nodeIds = nodes.map((n) => n.id)
const outgoing = buildOutgoing(edges)
const start = byId.has(startStepId)
const outgoing = buildOutgoing(topEdges)
const startNode = tidy.find((node) => node.id === startStepId)
const start = startNode?.parentNode
? startNode.parentNode
: byId.has(startStepId)
? startStepId
: (nodes.find((n) => n.type !== END_TYPE)?.id ?? nodes[0]?.id)
: (top.find((n) => n.type !== END_TYPE)?.id ?? top[0]?.id)
const backEdgeIds = findBackEdgeIds(nodeIds, edges, start)
const rank = computeRanks(nodeIds, edges, backEdgeIds)
const backEdgeIds = findBackEdgeIds(nodeIds, topEdges, start)
const rank = computeRanks(nodeIds, topEdges, backEdgeIds)
if (start && (rank.get(start) ?? 0) > 0) {
const shift = rank.get(start) ?? 0
for (const id of nodeIds) {
@@ -243,23 +260,37 @@ export function applyLayeredLayout(
}
const col = assignColumns(nodeIds, start, rank, outgoing, backEdgeIds)
const ranks = [...new Set(nodeIds.map((id) => rank.get(id) ?? 0))].sort((a, b) => a - b)
const cols = [...new Set(nodeIds.map((id) => col.get(id) ?? 0))].sort((a, b) => a - b)
const rankY = new Map<number, number>()
let y = ORIGIN_Y
for (const r of ranks) {
rankY.set(r, y)
const height = Math.max(
...nodeIds.filter((id) => (rank.get(id) ?? 0) === r).map((id) => boxOf(byId.get(id)!).height),
)
y += height + RANK_GAP_Y
}
const colX = new Map<number, number>()
let x = ORIGIN_X
for (const c of cols) {
colX.set(c, x)
const width = Math.max(
...nodeIds.filter((id) => (col.get(id) ?? 0) === c).map((id) => boxOf(byId.get(id)!).width),
)
x += width + COL_GAP_X
}
const laidOut = nodes.map((node) => {
const r = rank.get(node.id) ?? 0
const c = col.get(node.id) ?? 0
return {
const laidOut = top.map((node) => ({
...node,
position: {
x: ORIGIN_X + c * NODE_GAP_X,
y: ORIGIN_Y + r * RANK_Y,
x: colX.get(col.get(node.id) ?? 0) ?? ORIGIN_X,
y: rankY.get(rank.get(node.id) ?? 0) ?? ORIGIN_Y,
},
}
})
}))
return {
nodes: laidOut,
edges: assignEdgeHandles(laidOut, edges),
}
const merged = [...laidOut, ...parked]
return { nodes: merged, edges: assignEdgeHandles(merged, edges) }
}
export { RANK_Y, NODE_GAP_X }
export { RANK_GAP_Y as RANK_Y, COL_GAP_X as NODE_GAP_X }
+55 -14
View File
@@ -8,7 +8,7 @@ import type {
TransitionDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import { formatWhenKind } from '@/mapper/when'
import type { RoutingWhen } from '@/mapper/when'
export interface CanvasNode {
@@ -31,10 +31,14 @@ export interface CanvasEdge {
}
export const LANE_HEADER = 44
export const LANE_H = 128
export const LANE_LABEL_W = 76
export const LANE_H = 176
export const LANE_LABEL_W = 88
export const LANE_INSET_X = 28
export const LANE_INSET_Y = 28
export const LANE_PAD_RIGHT = 40
export const LANE_PAD_BOTTOM = 20
export const LANE_STEP_W = 220
export const LANE_STEP_GAP = 28
export const LANE_STEP_GAP = 72
export function laneOf(data: DesignerNodeData | undefined): string | undefined {
if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) {
@@ -45,15 +49,15 @@ export function laneOf(data: DesignerNodeData | undefined): string | undefined {
export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } {
return {
width: LANE_LABEL_W + 16 + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) + 12,
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + 10,
width: LANE_LABEL_W + LANE_INSET_X + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) - LANE_STEP_GAP + LANE_PAD_RIGHT,
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + LANE_PAD_BOTTOM,
}
}
export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } {
return {
x: LANE_LABEL_W + 8 + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
y: LANE_HEADER + laneIndex * LANE_H + 16,
x: LANE_LABEL_W + LANE_INSET_X + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y,
}
}
@@ -66,10 +70,11 @@ export function fitParallelFrames(nodes: CanvasNode[]): CanvasNode[] {
const children = nodes.filter((child) => child.parentNode === node.id)
const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length))
const size = parallelSize(lanes.length, maxSteps)
const right = children.reduce((max, child) => Math.max(max, child.position.x + LANE_STEP_W), 0)
return {
...node,
zIndex: 0,
style: { width: `${size.width}px`, height: `${size.height}px` },
style: { width: `${Math.max(size.width, right + LANE_PAD_RIGHT)}px`, height: `${size.height}px` },
}
})
}
@@ -101,12 +106,34 @@ export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
zIndex: 1,
data: { ...data, laneId: lane },
position: {
x: Math.max(LANE_LABEL_W, node.position.x),
y: LANE_HEADER + laneIndex * LANE_H + 16,
x: Math.max(LANE_LABEL_W + LANE_INSET_X, node.position.x),
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y,
},
}
})
return fitParallelFrames(next)
const spaced = new Map<string, { x: number; y: number }>()
for (const parent of next) {
if (parent.data?.kind !== 'PARALLEL') {
continue
}
for (const lane of parent.data.lanes) {
const steps = next
.filter((node) => node.parentNode === parent.id && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
let minX = LANE_LABEL_W + LANE_INSET_X
for (const step of steps) {
const x = Math.max(minX, step.position.x)
spaced.set(step.id, { x, y: step.position.y })
minX = x + LANE_STEP_W + LANE_STEP_GAP
}
}
}
return fitParallelFrames(
next.map((node) => {
const position = spaced.get(node.id)
return position ? { ...node, position } : node
}),
)
}
export function approvalStepDocument(data: ApprovalNodeData, id: string): StepDocument {
@@ -171,7 +198,21 @@ export function spawnLaneStep(
laneIndex: number,
stepIndex: number,
id: string,
kind: 'approval' | 'action' = 'approval',
): CanvasNode {
const position = laneStepPosition(laneIndex, stepIndex)
if (kind === 'action') {
const data: ActionNodeData = { ...defaultActionData(), name: 'Action', laneId }
return {
id,
type: 'action',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position,
data,
}
}
const data: ApprovalNodeData = { ...defaultApprovalData(), name: 'Approval', laneId }
return {
id,
@@ -179,7 +220,7 @@ export function spawnLaneStep(
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
position,
data,
}
}
@@ -218,7 +259,7 @@ export function materializeParallel(
id: `e-${id}-${branch.id}-${index}`,
source: transition.from,
target: transition.to,
label: formatWhen(transition.when) || 'default',
label: formatWhenKind(transition.when) || 'default',
data,
})
})
+10
View File
@@ -19,6 +19,16 @@ export function isRefWhen(when: RoutingWhen | undefined): when is { ref: string;
return Boolean(when && typeof when === 'object' && 'ref' in when)
}
export function formatWhenKind(when: RoutingWhen | undefined): string {
if (!when) {
return ''
}
if (isRefWhen(when)) {
return 'host'
}
return 'predicate'
}
export function formatWhen(when: RoutingWhen | undefined): string {
if (!when) {
return ''
+11 -3
View File
@@ -173,13 +173,18 @@ export const useDesignerStore = defineStore('designer', () => {
}
function syncStartFlags() {
const current = nodes.value.find((node) => node.id === startStepId.value)
if (current?.parentNode) {
startStepId.value = current.parentNode
}
nodes.value = nodes.value.map((node) => {
if (!node.data || node.data.kind === 'END') {
return node
}
const isStart = !node.parentNode && node.id === startStepId.value
return {
...node,
data: { ...node.data, isStart: node.id === startStepId.value },
data: { ...node.data, isStart },
}
})
}
@@ -694,7 +699,7 @@ export const useDesignerStore = defineStore('designer', () => {
edges.value = edges.value.filter((edge) => !drop.has(edge.source) && !drop.has(edge.target))
}
function addLaneStep(parallelId: string, lane: string) {
function addLaneStep(parallelId: string, lane: string, kind: 'approval' | 'action' = 'approval') {
const parent = nodes.value.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL' || !parent.data.lanes.includes(lane)) {
return
@@ -702,7 +707,10 @@ export const useDesignerStore = defineStore('designer', () => {
pushHistory()
const laneIndex = parent.data.lanes.indexOf(lane)
const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length
nodes.value = fitParallelFrames([...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane))])
nodes.value = fitParallelFrames([
...nodes.value,
spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind),
])
}
function renameLane(parallelId: string, from: string, to: string): boolean {
+8
View File
@@ -25,6 +25,14 @@ body,
transform: scale(1.25);
}
.vue-flow__node-parallel button {
cursor: pointer;
}
.vue-flow__edge-labels {
z-index: 1000;
}
.pointer-events-all {
pointer-events: all;
}