feat: lay out PARALLEL branches as vertical columns

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-20 10:07:26 +08:00
co-authored by Cursor
parent 18b62003d1
commit 43b4a83407
5 changed files with 76 additions and 57 deletions
+6 -4
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow, type EdgeProps } from '@vue-flow/core' import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow, type EdgeProps } from '@vue-flow/core'
import type { DesignerEdgeData } from '@/types/definition' import type { DesignerEdgeData } from '@/types/definition'
import { formatWhenKind } from '@/mapper/when'
import { useDesignerStore } from '@/stores/designer' import { useDesignerStore } from '@/stores/designer'
const props = defineProps<EdgeProps<DesignerEdgeData>>() const props = defineProps<EdgeProps<DesignerEdgeData>>()
@@ -20,7 +21,8 @@ const route = computed(() => {
return { path, labelX, labelY } return { path, labelX, labelY }
}) })
const kind = computed(() => (props.label === 'host' || props.label === 'predicate' ? props.label : 'default')) const kind = computed(() => formatWhenKind(props.data?.when) || 'default')
const caption = computed(() => (typeof props.label === 'string' && props.label.trim() ? props.label : kind.value))
const picked = computed(() => props.selected || designer.selectedEdgeId === props.id) const picked = computed(() => props.selected || designer.selectedEdgeId === props.id)
const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id)) const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id))
@@ -63,9 +65,9 @@ function selectEdge(event: MouseEvent) {
<BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" :interaction-width="20" /> <BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" :interaction-width="20" />
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
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="nodrag nopan pointer-events-all absolute z-10 max-w-40 cursor-pointer truncate rounded-md border px-1.5 py-0.5 text-[10px] font-medium shadow-sm"
:class="labelClass" :class="labelClass"
:title="label" :title="caption"
:style="{ :style="{
transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`, transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`,
pointerEvents: 'all', pointerEvents: 'all',
@@ -73,7 +75,7 @@ function selectEdge(event: MouseEvent) {
@pointerdown.stop @pointerdown.stop
@click.stop="selectEdge" @click.stop="selectEdge"
> >
{{ label }} {{ caption }}
</div> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
</template> </template>
+33 -22
View File
@@ -4,7 +4,7 @@ import { useRoute } from 'vue-router'
import { Handle, Position } from '@vue-flow/core' import { Handle, Position } from '@vue-flow/core'
import { GitBranch, Plus } from '@lucide/vue' import { GitBranch, Plus } from '@lucide/vue'
import type { ParallelNodeData } from '@/types/definition' import type { ParallelNodeData } from '@/types/definition'
import { LANE_H, LANE_HEADER, LANE_LABEL_W } from '@/mapper/parallel' import { LANE_COL_GAP, LANE_HEADER, LANE_INSET_X, LANE_STEP_W } from '@/mapper/parallel'
import { useDesignerStore } from '@/stores/designer' import { useDesignerStore } from '@/stores/designer'
const props = defineProps<{ const props = defineProps<{
@@ -19,6 +19,15 @@ const readOnly = computed(() => {
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw)) return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
}) })
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id)) const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
function columnStyle(index: number) {
return {
left: `${LANE_INSET_X + index * (LANE_STEP_W + LANE_COL_GAP) - 8}px`,
width: `${LANE_STEP_W + 16}px`,
top: `${LANE_HEADER + 8}px`,
bottom: '12px',
}
}
</script> </script>
<template> <template>
@@ -64,29 +73,31 @@ const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === pr
<div <div
v-for="(lane, index) in data.lanes" v-for="(lane, index) in data.lanes"
:key="lane" :key="lane"
class="pointer-events-none absolute flex items-start rounded-lg border border-violet-100/80" class="pointer-events-none absolute flex flex-col 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' }" :style="columnStyle(index)"
> >
<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="pointer-events-auto flex flex-col gap-1 px-2 pt-2">
<div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div> <div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div>
<button <div class="flex flex-wrap gap-x-2">
type="button" <button
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" type="button"
:disabled="readOnly" 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"
@click.stop="designer.addLaneStep(id, lane, 'approval')" :disabled="readOnly"
> @click.stop="designer.addLaneStep(id, lane, 'approval')"
<Plus :size="11" /> >
Approval <Plus :size="11" />
</button> Approval
<button </button>
type="button" <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" type="button"
:disabled="readOnly" 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"
@click.stop="designer.addLaneStep(id, lane, 'action')" :disabled="readOnly"
> @click.stop="designer.addLaneStep(id, lane, 'action')"
<Plus :size="11" /> >
Action <Plus :size="11" />
</button> Action
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -7,7 +7,7 @@ import type {
StepDocument, StepDocument,
} from '@/types/definition' } from '@/types/definition'
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition' import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
import { formatWhenKind } from '@/mapper/when' import { formatWhen } from '@/mapper/when'
import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel' import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel'
export const APPROVAL_TYPE = 'approval' export const APPROVAL_TYPE = 'approval'
@@ -40,7 +40,7 @@ export interface DesignerEdge {
} }
export function edgeLabel(data: DesignerEdgeData | undefined): string { export function edgeLabel(data: DesignerEdgeData | undefined): string {
return formatWhenKind(data?.when) || 'default' return formatWhen(data?.when) || 'default'
} }
export function toDocument( export function toDocument(
+28 -23
View File
@@ -8,7 +8,7 @@ import type {
TransitionDocument, TransitionDocument,
} from '@/types/definition' } from '@/types/definition'
import { defaultActionData, defaultApprovalData } from '@/types/definition' import { defaultActionData, defaultApprovalData } from '@/types/definition'
import { formatWhenKind } from '@/mapper/when' import { formatWhen } from '@/mapper/when'
import type { RoutingWhen } from '@/mapper/when' import type { RoutingWhen } from '@/mapper/when'
export interface CanvasNode { export interface CanvasNode {
@@ -31,14 +31,14 @@ export interface CanvasEdge {
} }
export const LANE_HEADER = 44 export const LANE_HEADER = 44
export const LANE_H = 176 export const LANE_LABEL_H = 58
export const LANE_LABEL_W = 88 export const LANE_INSET_X = 16
export const LANE_INSET_X = 28 export const LANE_PAD_RIGHT = 16
export const LANE_INSET_Y = 28
export const LANE_PAD_RIGHT = 40
export const LANE_PAD_BOTTOM = 20 export const LANE_PAD_BOTTOM = 20
export const LANE_STEP_W = 220 export const LANE_STEP_W = 220
export const LANE_STEP_GAP = 72 export const LANE_STEP_H = 132
export const LANE_COL_GAP = 24
export const LANE_STEP_GAP = 56
export function laneOf(data: DesignerNodeData | undefined): string | undefined { export function laneOf(data: DesignerNodeData | undefined): string | undefined {
if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) { if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) {
@@ -49,15 +49,15 @@ export function laneOf(data: DesignerNodeData | undefined): string | undefined {
export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } { export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } {
return { return {
width: LANE_LABEL_W + LANE_INSET_X + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) - LANE_STEP_GAP + LANE_PAD_RIGHT, width: LANE_INSET_X + Math.max(1, laneCount) * (LANE_STEP_W + LANE_COL_GAP) - LANE_COL_GAP + LANE_PAD_RIGHT,
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + LANE_PAD_BOTTOM, height: LANE_HEADER + LANE_LABEL_H + Math.max(1, maxSteps) * (LANE_STEP_H + LANE_STEP_GAP) - LANE_STEP_GAP + LANE_PAD_BOTTOM,
} }
} }
export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } { export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } {
return { return {
x: LANE_LABEL_W + LANE_INSET_X + stepIndex * (LANE_STEP_W + LANE_STEP_GAP), x: LANE_INSET_X + laneIndex * (LANE_STEP_W + LANE_COL_GAP),
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y, y: LANE_HEADER + LANE_LABEL_H + stepIndex * (LANE_STEP_H + LANE_STEP_GAP),
} }
} }
@@ -71,10 +71,14 @@ export function fitParallelFrames(nodes: CanvasNode[]): CanvasNode[] {
const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length)) const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length))
const size = parallelSize(lanes.length, maxSteps) const size = parallelSize(lanes.length, maxSteps)
const right = children.reduce((max, child) => Math.max(max, child.position.x + LANE_STEP_W), 0) const right = children.reduce((max, child) => Math.max(max, child.position.x + LANE_STEP_W), 0)
const bottom = children.reduce((max, child) => Math.max(max, child.position.y + LANE_STEP_H), 0)
return { return {
...node, ...node,
zIndex: 0, zIndex: 0,
style: { width: `${Math.max(size.width, right + LANE_PAD_RIGHT)}px`, height: `${size.height}px` }, style: {
width: `${Math.max(size.width, right + LANE_PAD_RIGHT)}px`,
height: `${Math.max(size.height, bottom + LANE_PAD_BOTTOM)}px`,
},
} }
}) })
} }
@@ -89,9 +93,10 @@ export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
return node return node
} }
const lanes = parent.data.lanes const lanes = parent.data.lanes
const colW = LANE_STEP_W + LANE_COL_GAP
const laneIndex = Math.min( const laneIndex = Math.min(
lanes.length - 1, lanes.length - 1,
Math.max(0, Math.floor((node.position.y - LANE_HEADER) / LANE_H)), Math.max(0, Math.floor((node.position.x - LANE_INSET_X + colW / 2) / colW)),
) )
const lane = lanes[laneIndex] ?? lanes[0] const lane = lanes[laneIndex] ?? lanes[0]
if (!lane) { if (!lane) {
@@ -106,8 +111,8 @@ export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
zIndex: 1, zIndex: 1,
data: { ...data, laneId: lane }, data: { ...data, laneId: lane },
position: { position: {
x: Math.max(LANE_LABEL_W + LANE_INSET_X, node.position.x), x: LANE_INSET_X + laneIndex * colW,
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y, y: Math.max(LANE_HEADER + LANE_LABEL_H, node.position.y),
}, },
} }
}) })
@@ -119,12 +124,12 @@ export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
for (const lane of parent.data.lanes) { for (const lane of parent.data.lanes) {
const steps = next const steps = next
.filter((node) => node.parentNode === parent.id && laneOf(node.data) === lane) .filter((node) => node.parentNode === parent.id && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id)) .sort((a, b) => a.position.y - b.position.y || a.id.localeCompare(b.id))
let minX = LANE_LABEL_W + LANE_INSET_X let minY = LANE_HEADER + LANE_LABEL_H
for (const step of steps) { for (const step of steps) {
const x = Math.max(minX, step.position.x) const y = Math.max(minY, step.position.y)
spaced.set(step.id, { x, y: step.position.y }) spaced.set(step.id, { x: step.position.x, y })
minX = x + LANE_STEP_W + LANE_STEP_GAP minY = y + LANE_STEP_H + LANE_STEP_GAP
} }
} }
} }
@@ -164,7 +169,7 @@ export function branchesOf(
return lanes.map((lane) => { return lanes.map((lane) => {
const steps = nodes const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane) .filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id)) .sort((a, b) => a.position.y - b.position.y || a.id.localeCompare(b.id))
const ids = new Set(steps.map((step) => step.id)) const ids = new Set(steps.map((step) => step.id))
const transitions: TransitionDocument[] = edges const transitions: TransitionDocument[] = edges
.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) .filter((edge) => ids.has(edge.source) && ids.has(edge.target))
@@ -259,7 +264,7 @@ export function materializeParallel(
id: `e-${id}-${branch.id}-${index}`, id: `e-${id}-${branch.id}-${index}`,
source: transition.from, source: transition.from,
target: transition.to, target: transition.to,
label: formatWhenKind(transition.when) || 'default', label: formatWhen(transition.when) || 'default',
data, data,
}) })
}) })
@@ -332,7 +337,7 @@ export function reflowLanes(nodes: CanvasNode[], parallelId: string): CanvasNode
parent.data.lanes.forEach((lane, laneIndex) => { parent.data.lanes.forEach((lane, laneIndex) => {
const steps = nodes const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane) .filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id)) .sort((a, b) => a.position.y - b.position.y || a.id.localeCompare(b.id))
steps.forEach((step, stepIndex) => { steps.forEach((step, stepIndex) => {
const data = step.data const data = step.data
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') { if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
+7 -6
View File
@@ -252,8 +252,9 @@ export const useDesignerStore = defineStore('designer', () => {
publishedVersion.value = params.version ?? null publishedVersion.value = params.version ?? null
loadedFromDraft.value = Boolean(params.fromDraft) loadedFromDraft.value = Boolean(params.fromDraft)
const expanded = expandLegacyParallel(params.nodes, params.edges) const expanded = expandLegacyParallel(params.nodes, params.edges)
nodes.value = fitParallelFrames(expanded.nodes) const parallels = expanded.nodes.filter((node) => node.data?.kind === 'PARALLEL').map((node) => node.id)
edges.value = assignEdgeHandles(expanded.nodes, expanded.edges) nodes.value = parallels.reduce((current, id) => reflowLanes(current, id), expanded.nodes)
edges.value = assignEdgeHandles(nodes.value, expanded.edges)
syncStartFlags() syncStartFlags()
clearSelection() clearSelection()
resetHistory() resetHistory()
@@ -707,10 +708,10 @@ export const useDesignerStore = defineStore('designer', () => {
pushHistory() pushHistory()
const laneIndex = parent.data.lanes.indexOf(lane) const laneIndex = parent.data.lanes.indexOf(lane)
const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length
nodes.value = fitParallelFrames([ nodes.value = reflowLanes(
...nodes.value, [...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind)],
spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind), parallelId,
]) )
} }
function renameLane(parallelId: string, from: string, to: string): boolean { function renameLane(parallelId: string, from: string, to: string): boolean {