feat: lay out PARALLEL branches as vertical columns
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow, type EdgeProps } from '@vue-flow/core'
|
||||
import type { DesignerEdgeData } from '@/types/definition'
|
||||
import { formatWhenKind } from '@/mapper/when'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
|
||||
const props = defineProps<EdgeProps<DesignerEdgeData>>()
|
||||
@@ -20,7 +21,8 @@ const route = computed(() => {
|
||||
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 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" />
|
||||
<EdgeLabelRenderer>
|
||||
<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"
|
||||
:title="label"
|
||||
:title="caption"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
@@ -73,7 +75,7 @@ function selectEdge(event: MouseEvent) {
|
||||
@pointerdown.stop
|
||||
@click.stop="selectEdge"
|
||||
>
|
||||
{{ label }}
|
||||
{{ caption }}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { GitBranch, Plus } from '@lucide/vue'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,6 +19,15 @@ const readOnly = computed(() => {
|
||||
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
|
||||
})
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -64,29 +73,31 @@ 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 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' }"
|
||||
class="pointer-events-none absolute flex flex-col rounded-lg border border-violet-100/80"
|
||||
: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>
|
||||
<button
|
||||
type="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"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLaneStep(id, lane, 'approval')"
|
||||
>
|
||||
<Plus :size="11" />
|
||||
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 class="flex flex-wrap gap-x-2">
|
||||
<button
|
||||
type="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"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLaneStep(id, lane, 'approval')"
|
||||
>
|
||||
<Plus :size="11" />
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import type {
|
||||
StepDocument,
|
||||
} 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'
|
||||
|
||||
export const APPROVAL_TYPE = 'approval'
|
||||
@@ -40,7 +40,7 @@ export interface DesignerEdge {
|
||||
}
|
||||
|
||||
export function edgeLabel(data: DesignerEdgeData | undefined): string {
|
||||
return formatWhenKind(data?.when) || 'default'
|
||||
return formatWhen(data?.when) || 'default'
|
||||
}
|
||||
|
||||
export function toDocument(
|
||||
|
||||
+28
-23
@@ -8,7 +8,7 @@ import type {
|
||||
TransitionDocument,
|
||||
} 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'
|
||||
|
||||
export interface CanvasNode {
|
||||
@@ -31,14 +31,14 @@ export interface CanvasEdge {
|
||||
}
|
||||
|
||||
export const LANE_HEADER = 44
|
||||
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_LABEL_H = 58
|
||||
export const LANE_INSET_X = 16
|
||||
export const LANE_PAD_RIGHT = 16
|
||||
export const LANE_PAD_BOTTOM = 20
|
||||
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 {
|
||||
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 } {
|
||||
return {
|
||||
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,
|
||||
width: LANE_INSET_X + Math.max(1, laneCount) * (LANE_STEP_W + LANE_COL_GAP) - LANE_COL_GAP + LANE_PAD_RIGHT,
|
||||
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 } {
|
||||
return {
|
||||
x: LANE_LABEL_W + LANE_INSET_X + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
|
||||
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y,
|
||||
x: LANE_INSET_X + laneIndex * (LANE_STEP_W + LANE_COL_GAP),
|
||||
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 size = parallelSize(lanes.length, maxSteps)
|
||||
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 {
|
||||
...node,
|
||||
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
|
||||
}
|
||||
const lanes = parent.data.lanes
|
||||
const colW = LANE_STEP_W + LANE_COL_GAP
|
||||
const laneIndex = Math.min(
|
||||
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]
|
||||
if (!lane) {
|
||||
@@ -106,8 +111,8 @@ export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
|
||||
zIndex: 1,
|
||||
data: { ...data, laneId: lane },
|
||||
position: {
|
||||
x: Math.max(LANE_LABEL_W + LANE_INSET_X, node.position.x),
|
||||
y: LANE_HEADER + laneIndex * LANE_H + LANE_INSET_Y,
|
||||
x: LANE_INSET_X + laneIndex * colW,
|
||||
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) {
|
||||
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
|
||||
.sort((a, b) => a.position.y - b.position.y || a.id.localeCompare(b.id))
|
||||
let minY = LANE_HEADER + LANE_LABEL_H
|
||||
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
|
||||
const y = Math.max(minY, step.position.y)
|
||||
spaced.set(step.id, { x: step.position.x, y })
|
||||
minY = y + LANE_STEP_H + LANE_STEP_GAP
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +169,7 @@ export function branchesOf(
|
||||
return lanes.map((lane) => {
|
||||
const steps = nodes
|
||||
.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 transitions: TransitionDocument[] = edges
|
||||
.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
|
||||
@@ -259,7 +264,7 @@ export function materializeParallel(
|
||||
id: `e-${id}-${branch.id}-${index}`,
|
||||
source: transition.from,
|
||||
target: transition.to,
|
||||
label: formatWhenKind(transition.when) || 'default',
|
||||
label: formatWhen(transition.when) || 'default',
|
||||
data,
|
||||
})
|
||||
})
|
||||
@@ -332,7 +337,7 @@ export function reflowLanes(nodes: CanvasNode[], parallelId: string): CanvasNode
|
||||
parent.data.lanes.forEach((lane, laneIndex) => {
|
||||
const steps = nodes
|
||||
.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) => {
|
||||
const data = step.data
|
||||
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
|
||||
|
||||
@@ -252,8 +252,9 @@ export const useDesignerStore = defineStore('designer', () => {
|
||||
publishedVersion.value = params.version ?? null
|
||||
loadedFromDraft.value = Boolean(params.fromDraft)
|
||||
const expanded = expandLegacyParallel(params.nodes, params.edges)
|
||||
nodes.value = fitParallelFrames(expanded.nodes)
|
||||
edges.value = assignEdgeHandles(expanded.nodes, expanded.edges)
|
||||
const parallels = expanded.nodes.filter((node) => node.data?.kind === 'PARALLEL').map((node) => node.id)
|
||||
nodes.value = parallels.reduce((current, id) => reflowLanes(current, id), expanded.nodes)
|
||||
edges.value = assignEdgeHandles(nodes.value, expanded.edges)
|
||||
syncStartFlags()
|
||||
clearSelection()
|
||||
resetHistory()
|
||||
@@ -707,10 +708,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), kind),
|
||||
])
|
||||
nodes.value = reflowLanes(
|
||||
[...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind)],
|
||||
parallelId,
|
||||
)
|
||||
}
|
||||
|
||||
function renameLane(parallelId: string, from: string, to: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user