From fff84ae781e7a1a9c060d3a664bf973dd538cb9c Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Sun, 8 Nov 2026 09:02:34 +0100 Subject: [PATCH 1/2] refactor: one layout snapshot for every handler Phase 4a of the declutter plan. The six handlers that lay nodes out or route edges each carried a copy of "walk the model, key the nodes and the routes, save": eight collectNodePositions, eight collectEdgeRoutes, five signature helpers, four snapshot/restore sets, about 1,400 lines, drifted in formatting, in types and in two places in meaning. server/layout-snapshot.ts holds them once, and persisted-edge-routes.ts exports the route-key and anchor helpers it always had. persistLayout replaces the seven save blocks; the one in runBoundaryFlowAndPersist keeps its own meta and calls the collectors. Two drifts end here: Reset Routes and the reroute noted their save under a model-state key nothing read ('workflow.layoutPersistence' instead of the shared constant), and four handlers keyed routes by AST path alone where the loader and the other writers key by endpoint signature. Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv --- CHANGELOG.md | 15 + .../change-routing-points-handler.ts | 153 +--- .../layout-boundary-flow-handler.ts | 257 +------ .../layout-serpentine-mesh-handler.ts | 267 +------ .../reroute-edges-avoid-overlaps-handler.ts | 115 +-- .../operations/reset-edge-routes-handler.ts | 97 +-- .../src/routing/persisted-edge-routes.ts | 17 +- .../src/server/diagram-action-handlers.ts | 657 +----------------- .../src/server/layout-snapshot.ts | 232 +++++++ .../test/layout-snapshot.test.ts | 150 ++++ 10 files changed, 467 insertions(+), 1493 deletions(-) create mode 100644 packages/diagram-server/src/server/layout-snapshot.ts create mode 100644 packages/diagram-server/test/layout-snapshot.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d93ed18..4b80149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,15 @@ platform API version. ### Changed +- **One layout snapshot for every handler.** The six handlers that lay nodes + out or route edges each carried their own copy of "walk the model, key the + nodes and the routes, save": eight `collectNodePositions`, eight + `collectEdgeRoutes`, five signature helpers, four snapshot/restore sets, + about 1,400 lines, drifted. `diagram-server/src/server/layout-snapshot.ts` + holds them once (`collectNodePositions`, `collectEdgeRoutes`, + `movableNodeIdsIn`, `snapshot*ById`, `restoreUnselectedLayout`, + `persistLayout`), and `persisted-edge-routes.ts` exports the route-key and + anchor helpers it always had. - **After a drop, the routes an edge cannot keep are drawn by libavoid, with the edges on one port as one shared path.** The layout draws the edges into a port as a trunk; the built-in router gives every edge its own channel and @@ -90,6 +99,12 @@ platform API version. ### Fixed +- **Reset Routes and the reroute note their save where the rest of the server + reads it.** Both wrote "has persisted edge routes" under a model-state key + nothing else used, so after them the flag stayed what the last layout left. + Every handler also keys a saved route the same way now (endpoint signature, + AST path where it repeats); four keyed by path alone, which the loader's + fallback read but the other writers overwrote. - A layout change still on its 500 ms debounce timer when the window closes is written out: the host flushes every diagram's pending layout saves on deactivation. diff --git a/packages/diagram-server/src/operations/change-routing-points-handler.ts b/packages/diagram-server/src/operations/change-routing-points-handler.ts index 7a54848..30d78a6 100644 --- a/packages/diagram-server/src/operations/change-routing-points-handler.ts +++ b/packages/diagram-server/src/operations/change-routing-points-handler.ts @@ -11,13 +11,10 @@ import { } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; -import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; -import { WorkflowDiagramConstants, portSide } from '@dialogram/shared'; -import type { WorkflowDiagramModel } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; -import { layoutTargetOf } from '../server/layout-target'; +import { persistLayout } from '../server/layout-snapshot'; +import { portAnchorAbsolute } from '../routing/persisted-edge-routes'; @injectable() export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandler { @@ -104,67 +101,9 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle return Math.sqrt(dx * dx + dy * dy); } - private absolutePosition(element: any): { x: number; y: number } { - let x = 0; - let y = 0; - let cur: any = element; - while (cur) { - const p = cur.position as { x?: number; y?: number } | undefined; - if (p) { - x += p.x ?? 0; - y += p.y ?? 0; - } - cur = cur.parent; - } - return { x, y }; - } - - private portAnchorAbsolute(portId: string): { x: number; y: number } | undefined { - const port: any = this.modelState.index.find(portId); - if (!port) { - return undefined; - } - // Find containing node (ports are typically children of nodes). - let node: any = port; - while (node && !(node as any).type?.startsWith?.('node:')) { - node = node.parent; - } - if (!node) { - return undefined; - } - - const nodeAbs = this.absolutePosition(node); - const portRel = (port.position as { x?: number; y?: number } | undefined) ?? { x: 0, y: 0 }; - const fallbackPortWidth = WorkflowDiagramConstants.PORT_WIDTH_PX; - const fallbackPortHeight = WorkflowDiagramConstants.PORT_HEIGHT_PX; - const w = (port.size?.width && port.size.width > 0) ? port.size.width : fallbackPortWidth; - const h = (port.size?.height && port.size.height > 0) ? port.size.height : fallbackPortHeight; - - const side = portSide({ - absolute: nodeAbs, - direction: (port as any).args?.['cal:portDirection'] as string | undefined, - side: (port as any).args?.['cal:portSide'] as string | undefined, - type: port.type as string | undefined - }); - - const absPortX = nodeAbs.x + (portRel.x ?? 0); - const absPortY = nodeAbs.y + (portRel.y ?? 0); - const cx = absPortX + w / 2; - const cy = absPortY + h / 2; - - switch (side) { - case 'WEST': - return { x: absPortX, y: cy }; - case 'EAST': - return { x: absPortX + w, y: cy }; - default: - return { x: cx, y: cy }; - } - } - private normalizeToFullPolyline(edge: any, maybeIntermediate: { x: number; y: number }[]): { x: number; y: number }[] { - const src = typeof edge?.sourceId === 'string' ? this.portAnchorAbsolute(edge.sourceId) : undefined; - const tgt = typeof edge?.targetId === 'string' ? this.portAnchorAbsolute(edge.targetId) : undefined; + const src = portAnchorAbsolute(this.modelState.index, edge?.sourceId); + const tgt = portAnchorAbsolute(this.modelState.index, edge?.targetId); if (!src || !tgt) { return maybeIntermediate; } @@ -207,65 +146,6 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle return true; } - private collectNodePositions(root: any): Map { - const result = new Map(); - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = args?.[WorkflowDiagramMetadata.AST_PATH]; - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - private async executeOperation(operation: ChangeRoutingPointsOperation): Promise { let didApply = false; for (const entry of operation.newRoutingPoints) { @@ -275,28 +155,7 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle return; } - const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!diagramModel) { - return; - } - - const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); - - // Prefer persisted positions (stable keys) and only update routes. - const persistedPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId); - const positions = persistedPositions ?? this.collectNodePositions(this.modelState.root); - const routes = this.collectEdgeRoutes(this.modelState.root); - - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - hasPersistedEdgeRoutes: boolean; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); - } + // A route change alone: the node positions already on disk stand. + await persistLayout(this.modelState, this.layoutPersistence, { keepPersistedPositions: true }); } } diff --git a/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts b/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts index c3308c0..df6b52b 100644 --- a/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts +++ b/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts @@ -1,6 +1,5 @@ import { LayoutOperation, Operation } from '@eclipse-glsp/protocol'; import { - GEdge, GModelRecordingCommand, LayoutEngine, ModelState, @@ -12,16 +11,19 @@ import { } from '@eclipse-glsp/server'; import { inject, injectable, optional } from 'inversify'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; -import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; -import type { WorkflowDiagramModel } from '@dialogram/shared'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { clearAllEdgeRoutingPoints } from '../routing/clear-edge-routes'; import { applyBoundaryFlowLayerConstraints, restoreNodeLayoutOptions } from './boundary-flow-layout'; import { WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND, WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from './reroute-edges-avoid-overlaps-handler'; -import { layoutTargetOf } from '../server/layout-target'; +import { + persistLayout, + restoreUnselectedLayout, + movableNodeIdsIn, + snapshotEdgeRoutesById, + snapshotNodePositionsById +} from '../server/layout-snapshot'; export const WORKFLOW_LAYOUT_BOUNDARY_FLOW_OPERATION_KIND = 'dialogram.layoutBoundaryFlow' as const; @@ -65,11 +67,11 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler } private async executeOperation(operation: WorkflowLayoutBoundaryFlowOperation): Promise { - const selectedMovableNodeIds = this.getSelectedMovableNodeIds(operation.elementIds); + const selectedMovableNodeIds = movableNodeIdsIn(this.modelState.index, operation.elementIds); const hasSelection = selectedMovableNodeIds.size > 0; - const preNodePositionsById = hasSelection ? this.snapshotNodePositionsById(this.modelState.root) : undefined; - const preEdgeRoutesById = hasSelection ? this.snapshotEdgeRoutesById(this.modelState.root) : undefined; + const preNodePositionsById = hasSelection ? snapshotNodePositionsById(this.modelState.root) : undefined; + const preEdgeRoutesById = hasSelection ? snapshotEdgeRoutesById(this.modelState.root) : undefined; const previousLayoutOptionsByNodeId = applyBoundaryFlowLayerConstraints(this.modelState.root, selectedMovableNodeIds, hasSelection); @@ -79,7 +81,7 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler restoreNodeLayoutOptions(this.modelState.root, previousLayoutOptionsByNodeId); if (hasSelection && preNodePositionsById && preEdgeRoutesById) { - this.restoreUnselectedLayout(this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); + restoreUnselectedLayout(this.modelState.index, this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); } // Finalize with canonical reroute only for selection-scoped layout. @@ -94,241 +96,6 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler }); } - const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!diagramModel) { - return; - } - - const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); - const positions = this.collectNodePositions(this.modelState.root); - const routes = this.collectEdgeRoutes(this.modelState.root); - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedLayout = true; - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); - } - } - - private isMovableNode(element: any): boolean { - const type = element?.type as string | undefined; - return typeof type === 'string' && type.startsWith('node:'); - } - - private getMovableNodeIdForElementId(elementId: string): string | undefined { - let current: any = this.modelState.index.find(elementId); - while (current) { - if (this.isMovableNode(current)) { - return String((current as any).id); - } - current = (current as any).parent; - } - return undefined; - } - - private getSelectedMovableNodeIds(elementIds: string[] | undefined): Set { - const result = new Set(); - const ids = Array.isArray(elementIds) ? elementIds : []; - - for (const id of ids) { - const movable = this.getMovableNodeIdForElementId(id); - if (movable) { - result.add(movable); - } - - const element: any = this.modelState.index.find(id); - const type = element?.type as string | undefined; - const isEdge = element instanceof GEdge || (typeof type === 'string' && type.startsWith('edge:')); - if (isEdge) { - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - if (typeof sourceId === 'string') { - const sourceMovable = this.getMovableNodeIdForElementId(sourceId); - if (sourceMovable) { - result.add(sourceMovable); - } - } - if (typeof targetId === 'string') { - const targetMovable = this.getMovableNodeIdForElementId(targetId); - if (targetMovable) { - result.add(targetMovable); - } - } - } - } - - return result; - } - - private snapshotNodePositionsById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (this.isMovableNode(element)) { - const id = String((element as any).id); - const pos = (element as any).position as { x?: number; y?: number } | undefined; - if (pos) { - result.set(id, { x: pos.x ?? 0, y: pos.y ?? 0 }); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private snapshotEdgeRoutesById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const id = String((element as any).id); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (Array.isArray(routingPoints)) { - result.set( - id, - routingPoints.map(p => ({ x: p.x, y: p.y })) - ); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private restoreUnselectedLayout( - root: any, - selectedMovableNodeIds: Set, - preNodePositionsById: Map, - preEdgeRoutesById: Map - ): void { - const visit = (element: any): void => { - if (!element) { - return; - } - - if (this.isMovableNode(element)) { - const id = String((element as any).id); - if (!selectedMovableNodeIds.has(id)) { - const prev = preNodePositionsById.get(id); - (element as any).position = prev ? { x: prev.x, y: prev.y } : undefined; - } - } - - if (element instanceof GEdge) { - const id = String((element as any).id); - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - - const sourceMovable = typeof sourceId === 'string' ? this.getMovableNodeIdForElementId(sourceId) : undefined; - const targetMovable = typeof targetId === 'string' ? this.getMovableNodeIdForElementId(targetId) : undefined; - - const sourceSelected = sourceMovable ? selectedMovableNodeIds.has(sourceMovable) : false; - const targetSelected = targetMovable ? selectedMovableNodeIds.has(targetMovable) : false; - - if (!sourceSelected && !targetSelected) { - const prevRoute = preEdgeRoutesById.get(id); - (element as any).routingPoints = prevRoute !== undefined ? prevRoute.map(p => ({ x: p.x, y: p.y })) : undefined; - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - } - - private collectNodePositions(root: GModelRoot): Map { - const result = new Map(); - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = args?.[WorkflowDiagramMetadata.AST_PATH]; - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; + await persistLayout(this.modelState, this.layoutPersistence, { layoutPerformed: true }); } } diff --git a/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts b/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts index 14b3323..6bdc7dc 100644 --- a/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts +++ b/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts @@ -19,11 +19,17 @@ import { WorkflowRerouteEdgesAvoidOverlapsOperationHandler, WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND } from './reroute-edges-avoid-overlaps-handler'; -import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; -import type { WorkflowDiagramModel } from '@dialogram/shared'; import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; import { WorkflowDiagramConstants, portSide } from '@dialogram/shared'; -import { layoutTargetOf } from '../server/layout-target'; +import { + isMovableNode, + movableNodeIdFor, + persistLayout, + restoreUnselectedLayout, + movableNodeIdsIn, + snapshotEdgeRoutesById, + snapshotNodePositionsById +} from '../server/layout-snapshot'; export const WORKFLOW_LAYOUT_SERPENTINE_MESH_OPERATION_KIND = 'dialogram.layoutSerpentineMesh' as const; @@ -61,11 +67,11 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl } private async executeOperation(operation: WorkflowLayoutSerpentineMeshOperation): Promise { - const selectedMovableNodeIds = this.getSelectedMovableNodeIds(operation.elementIds); + const selectedMovableNodeIds = movableNodeIdsIn(this.modelState.index, operation.elementIds); const hasSelection = selectedMovableNodeIds.size > 0; - const preNodePositionsById = hasSelection ? this.snapshotNodePositionsById(this.modelState.root) : undefined; - const preEdgeRoutesById = hasSelection ? this.snapshotEdgeRoutesById(this.modelState.root) : undefined; + const preNodePositionsById = hasSelection ? snapshotNodePositionsById(this.modelState.root) : undefined; + const preEdgeRoutesById = hasSelection ? snapshotEdgeRoutesById(this.modelState.root) : undefined; const previousGraphLayoutOptions = this.applySerpentineMeshPlacement(this.modelState.root, selectedMovableNodeIds, hasSelection); @@ -74,7 +80,7 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl (this.modelState.root as any).layoutOptions = previousGraphLayoutOptions ? { ...previousGraphLayoutOptions } : undefined; if (hasSelection && preNodePositionsById && preEdgeRoutesById) { - this.restoreUnselectedLayout(this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); + restoreUnselectedLayout(this.modelState.index, this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); } // Route the edges with the proven obstacle-aware router (the same one the @@ -89,32 +95,7 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl ...(hasSelection ? { elementIds: [...selectedMovableNodeIds] } : {}) }); - const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!diagramModel) { - return; - } - - const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); - const positions = this.collectNodePositions(this.modelState.root); - const routes = this.collectEdgeRoutes(this.modelState.root); - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedLayout = true; - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); - } + await persistLayout(this.modelState, this.layoutPersistence, { layoutPerformed: true }); } private applySerpentineMeshPlacement( @@ -338,8 +319,8 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl if (element instanceof GEdge) { const sourceId = (element as any).sourceId as string | undefined; const targetId = (element as any).targetId as string | undefined; - const sourceNodeId = typeof sourceId === 'string' ? this.getMovableNodeIdForElementId(sourceId) : undefined; - const targetNodeId = typeof targetId === 'string' ? this.getMovableNodeIdForElementId(targetId) : undefined; + const sourceNodeId = typeof sourceId === 'string' ? movableNodeIdFor(this.modelState.index, sourceId) : undefined; + const targetNodeId = typeof targetId === 'string' ? movableNodeIdFor(this.modelState.index, targetId) : undefined; if (sourceNodeId && targetNodeId) { if (boundaryNodeIds.has(sourceNodeId) && !boundaryNodeIds.has(targetNodeId)) { @@ -424,7 +405,7 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl return; } - if (this.isMovableNode(element)) { + if (isMovableNode(element)) { const id = String((element as any).id); if (!hasSelection || selectedMovableNodeIds.has(id)) { nodes.push(element); @@ -452,8 +433,8 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl if (element instanceof GEdge) { const sourceId = (element as any).sourceId as string | undefined; const targetId = (element as any).targetId as string | undefined; - const sourceNodeId = typeof sourceId === 'string' ? this.getMovableNodeIdForElementId(sourceId) : undefined; - const targetNodeId = typeof targetId === 'string' ? this.getMovableNodeIdForElementId(targetId) : undefined; + const sourceNodeId = typeof sourceId === 'string' ? movableNodeIdFor(this.modelState.index, sourceId) : undefined; + const targetNodeId = typeof targetId === 'string' ? movableNodeIdFor(this.modelState.index, targetId) : undefined; if (sourceNodeId && targetNodeId && scopedNodeIdSet.has(sourceNodeId) && scopedNodeIdSet.has(targetNodeId)) { edges.push({ sourceNodeId, targetNodeId }); @@ -613,11 +594,6 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl return ordered; } - private isMovableNode(element: any): boolean { - const type = element?.type as string | undefined; - return typeof type === 'string' && type.startsWith('node:'); - } - private isBoundaryNode(element: any): boolean { const type = element?.type as string | undefined; return type === WorkflowDiagramTypes.NODE_BOUNDARY_INPUT || type === WorkflowDiagramTypes.NODE_BOUNDARY_OUTPUT; @@ -630,209 +606,4 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl private isBoundaryOutputNode(element: any): boolean { return (element?.type as string | undefined) === WorkflowDiagramTypes.NODE_BOUNDARY_OUTPUT; } - - private getMovableNodeIdForElementId(elementId: string): string | undefined { - let current: any = this.modelState.index.find(elementId); - while (current) { - if (this.isMovableNode(current)) { - return String((current as any).id); - } - current = (current as any).parent; - } - return undefined; - } - - private getSelectedMovableNodeIds(elementIds: string[] | undefined): Set { - const result = new Set(); - const ids = Array.isArray(elementIds) ? elementIds : []; - - for (const id of ids) { - const movable = this.getMovableNodeIdForElementId(id); - if (movable) { - result.add(movable); - } - - const element: any = this.modelState.index.find(id); - const type = element?.type as string | undefined; - const isEdge = element instanceof GEdge || (typeof type === 'string' && type.startsWith('edge:')); - if (isEdge) { - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - if (typeof sourceId === 'string') { - const sourceMovable = this.getMovableNodeIdForElementId(sourceId); - if (sourceMovable) { - result.add(sourceMovable); - } - } - if (typeof targetId === 'string') { - const targetMovable = this.getMovableNodeIdForElementId(targetId); - if (targetMovable) { - result.add(targetMovable); - } - } - } - } - - return result; - } - - private snapshotNodePositionsById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (this.isMovableNode(element)) { - const id = String((element as any).id); - const pos = (element as any).position as { x?: number; y?: number } | undefined; - if (pos) { - result.set(id, { x: pos.x ?? 0, y: pos.y ?? 0 }); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private snapshotEdgeRoutesById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const id = String((element as any).id); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (Array.isArray(routingPoints)) { - result.set( - id, - routingPoints.map(p => ({ x: p.x, y: p.y })) - ); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private restoreUnselectedLayout( - root: any, - selectedMovableNodeIds: Set, - preNodePositionsById: Map, - preEdgeRoutesById: Map - ): void { - const visit = (element: any): void => { - if (!element) { - return; - } - - if (this.isMovableNode(element)) { - const id = String((element as any).id); - if (!selectedMovableNodeIds.has(id)) { - const prev = preNodePositionsById.get(id); - (element as any).position = prev ? { x: prev.x, y: prev.y } : undefined; - } - } - - if (element instanceof GEdge) { - const id = String((element as any).id); - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - - const sourceMovable = typeof sourceId === 'string' ? this.getMovableNodeIdForElementId(sourceId) : undefined; - const targetMovable = typeof targetId === 'string' ? this.getMovableNodeIdForElementId(targetId) : undefined; - - const sourceSelected = sourceMovable ? selectedMovableNodeIds.has(sourceMovable) : false; - const targetSelected = targetMovable ? selectedMovableNodeIds.has(targetMovable) : false; - - if (!sourceSelected && !targetSelected) { - const prevRoute = preEdgeRoutesById.get(id); - (element as any).routingPoints = prevRoute !== undefined ? prevRoute.map(p => ({ x: p.x, y: p.y })) : undefined; - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - } - - private collectNodePositions(root: GModelRoot): Map { - const result = new Map(); - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = args?.[WorkflowDiagramMetadata.AST_PATH]; - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } } diff --git a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts index 0f07622..8d5060f 100644 --- a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts +++ b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts @@ -12,13 +12,11 @@ import { } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; -import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; -import type { WorkflowDiagramModel } from '@dialogram/shared'; import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; -import { WorkflowDiagramConstants, portAnchor } from '@dialogram/shared'; +import { portAnchor } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; import { routeOrthogonal, type RouterConnector, type RouterObstacle } from '../routing/libavoid-router'; -import { layoutTargetOf } from '../server/layout-target'; +import { persistLayout } from '../server/layout-snapshot'; /** Every segment axis-aligned? A diagonal means the polyline is not a valid route. */ export function isOrthogonalPolyline(points: readonly { x: number; y: number }[]): boolean { @@ -844,94 +842,6 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation // Persistence helpers // ═══════════════════════════════════════════════════════════════════ - private collectNodePositions(root: GGraph): Map { - const result = new Map(); - const visit = (element: any, pax: number, pay: number): void => { - if (!element) { return; } - const rx = element.position?.x ?? 0; - const ry = element.position?.y ?? 0; - const ax = pax + rx; - const ay = pay + ry; - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: ax, y: ay }); - } - } - const ch = element.children as any[] | undefined; - if (Array.isArray(ch)) { for (const c of ch) { visit(c, ax, ay); } } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: GGraph): Map { - const result = new Map(); - const sigCounts = this.buildEdgeSignatureCounts(root); - const visit = (element: any): void => { - if (!element) { return; } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = this.edgeRouteKeyFor(args, sigCounts); - const rps = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(rps) && rps.length >= 2) { - result.set(key, rps); - } - } - const ch = element.children as any[] | undefined; - if (Array.isArray(ch)) { for (const c of ch) { visit(c); } } - }; - visit(root); - return result; - } - - private edgeSignatureFromArgs(args: Record | undefined): string | undefined { - if (!args) { return undefined; } - const norm = (v: unknown): string => - (typeof v === 'string' && v.trim() !== '') ? v.trim() : 'boundary'; - const from = norm(args['wf:from']); - const to = norm(args['wf:to']); - const out = typeof args['wf:outPort'] === 'string' ? args['wf:outPort'] as string : undefined; - const inp = typeof args['wf:inPort'] === 'string' ? args['wf:inPort'] as string : undefined; - if (!out || !inp) { return undefined; } - return `${from}|${out}|${to}|${inp}`; - } - - private buildEdgeSignatureCounts(root: GGraph): Map { - const counts = new Map(); - const visit = (el: any): void => { - if (!el) { return; } - if (el instanceof GEdge) { - const sig = this.edgeSignatureFromArgs(el.args as Record | undefined); - if (sig) { counts.set(sig, (counts.get(sig) ?? 0) + 1); } - } - const ch = el.children as any[] | undefined; - if (Array.isArray(ch)) { for (const c of ch) { visit(c); } } - }; - visit(root); - return counts; - } - - private edgeRouteKeyFor( - args: Record | undefined, - sigCounts: Map - ): string | undefined { - const sig = this.edgeSignatureFromArgs(args); - const path = args?.[WorkflowDiagramMetadata.AST_PATH]; - if (sig) { - const cnt = sigCounts.get(sig) ?? 0; - if (cnt > 1) { - return (typeof path === 'string' && path.trim() !== '') ? `${sig}|${path}` : undefined; - } - return sig; - } - return (typeof path === 'string' && path.trim() !== '') ? path as string : undefined; - } - // ═══════════════════════════════════════════════════════════════════ // Main execution // ═══════════════════════════════════════════════════════════════════ @@ -1278,26 +1188,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation /** Persist the layout, positions and routes; not for a preview (a drag). */ private async persistRoutes(root: GGraph, operation: WorkflowRerouteEdgesAvoidOverlapsOperation): Promise { if (operation.preview) { return; } - const dm = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!dm) { return; } - - const { filePath: workflowFilePath, networkId } = layoutTargetOf(dm as any); - - const positions = this.collectNodePositions(root); - const routes = this.collectEdgeRoutes(root); - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get('workflow.layoutPersistence') as - | { - workflowFilePath: string; networkId: string; hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; didInitialLayout: boolean; - hasClientBounds: boolean; allowInitialLayoutPersistence: boolean; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set('workflow.layoutPersistence', layoutMeta); - } + await persistLayout(this.modelState, this.layoutPersistence); } private routingSignature(points: { x: number; y: number }[] | undefined): string { diff --git a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts index 8103dcd..149efe5 100644 --- a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts +++ b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts @@ -9,11 +9,8 @@ import { } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; -import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; -import type { WorkflowDiagramModel } from '@dialogram/shared'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; -import { layoutTargetOf } from '../server/layout-target'; +import { persistLayout } from '../server/layout-snapshot'; export const WORKFLOW_RESET_EDGE_ROUTES_OPERATION_KIND = 'dialogram.resetEdgeRoutes' as const; @@ -113,95 +110,7 @@ export class WorkflowResetEdgeRoutesOperationHandler extends OperationHandler { visit(root); - // Persist the updated routing points (and keep node positions stable). - const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!diagramModel) { - return; - } - - const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); - - // Prefer the already-persisted node map (it uses stable keys), falling back to the - // current model snapshot if no layout file exists yet. - const persistedPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId); - const positions = persistedPositions ?? this.collectNodePositions(root); - - const routes = this.collectEdgeRoutes(root); - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get('workflow.layoutPersistence') as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set('workflow.layoutPersistence', layoutMeta); - } - } - - private collectNodePositions(root: any): Map { - const result = new Map(); - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = args?.[WorkflowDiagramMetadata.AST_PATH]; - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; + // Persist the updated routing points; the node positions already on disk stand. + await persistLayout(this.modelState, this.layoutPersistence, { keepPersistedPositions: true }); } } diff --git a/packages/diagram-server/src/routing/persisted-edge-routes.ts b/packages/diagram-server/src/routing/persisted-edge-routes.ts index 3239b8e..1b50c8c 100644 --- a/packages/diagram-server/src/routing/persisted-edge-routes.ts +++ b/packages/diagram-server/src/routing/persisted-edge-routes.ts @@ -17,9 +17,10 @@ import { GEdge, GModelElement, GModelIndex, GModelRoot, GPort } from '@eclipse-g import { WorkflowDiagramConstants, WorkflowDiagramMetadata, portAnchor } from '@dialogram/shared'; import { cleanLegacyFanoutPoints } from '../server/gmodel-convert'; -type Point = { x: number; y: number }; +export type Point = { x: number; y: number }; -function absolutePosition(element: GModelElement | undefined): Point { +/** An element's absolute position: its own offset and every ancestor's. */ +export function absolutePosition(element: GModelElement | undefined): Point { let x = 0; let y = 0; let current: any = element; @@ -34,6 +35,7 @@ function absolutePosition(element: GModelElement | undefined): Point { return { x, y }; } + /** * The point on a port's border where an edge attaches, in absolute diagram coordinates: * the right edge for outputs, the left edge for inputs, vertically centred. @@ -41,7 +43,7 @@ function absolutePosition(element: GModelElement | undefined): Point { * Returns `undefined` when the id does not resolve to a port, which leaves the persisted route * untouched — only port-to-port edges (all Workflow edges are) can be re-snapped meaningfully. */ -function portAnchorAbsolute(index: GModelIndex, portId: string | undefined): Point | undefined { +export function portAnchorAbsolute(index: GModelIndex, portId: string | undefined): Point | undefined { if (typeof portId !== 'string' || portId === '') { return undefined; } @@ -123,7 +125,8 @@ export function normalizePersistedRouteToFullPolyline( return snapRouteEndpoints(persisted, source, target); } -function edgeSignatureFromArgs(args: Record | undefined): string | undefined { +/** An edge by its two ends: `from|outPort|to|inPort`, a boundary end spelled `boundary`. */ +export function edgeSignatureFromArgs(args: Record | undefined): string | undefined { if (!args) { return undefined; } @@ -143,7 +146,8 @@ function edgeSignatureFromArgs(args: Record | undefined): strin return `${fromEntity}|${outPort}|${toEntity}|${inPort}`; } -function buildEdgeSignatureCounts(root: GModelRoot): Map { +/** How many edges of `root` share each signature; a repeated one needs its AST path to be a key. */ +export function buildEdgeSignatureCounts(root: GModelRoot): Map { const counts = new Map(); const visit = (element: any): void => { if (!element) { @@ -166,7 +170,8 @@ function buildEdgeSignatureCounts(root: GModelRoot): Map { return counts; } -function edgeRouteKeyFor( +/** The key a layout file stores an edge's route under: its signature, plus its AST path where the signature repeats, else the path alone. */ +export function edgeRouteKeyFor( args: Record | undefined, signatureCounts: Map ): string | undefined { diff --git a/packages/diagram-server/src/server/diagram-action-handlers.ts b/packages/diagram-server/src/server/diagram-action-handlers.ts index 030f938..12de3be 100644 --- a/packages/diagram-server/src/server/diagram-action-handlers.ts +++ b/packages/diagram-server/src/server/diagram-action-handlers.ts @@ -46,7 +46,6 @@ import { type WorkflowDiagramModel, WorkflowDiagramConstants, WorkflowDiagramTypes, - WORKFLOW_NETWORK_MODEL_KEY, WORKFLOW_LAYOUT_PERSISTENCE_KEY, portSide } from '@dialogram/shared'; @@ -59,7 +58,15 @@ import { import { clearAllEdgeRoutingPoints } from '../routing/clear-edge-routes'; import { releaseBoundaryNodeConstraints, restoreNodeLayoutOptions, runBoundaryFlowLayout } from '../operations/boundary-flow-layout'; import { perfNow } from './graph-load-perf'; -import { layoutTargetOf } from './layout-target'; +import { + collectEdgeRoutes, + collectNodePositions, + persistLayout, + restoreUnselectedLayout, + movableNodeIdsIn, + snapshotEdgeRoutesById, + snapshotNodePositionsById +} from './layout-snapshot'; /** * Custom ModelSubmissionHandler - minimal logging version. @@ -382,8 +389,8 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { layoutEngine: LayoutEngine ): Promise { await runBoundaryFlowLayout(modelState.root, layoutEngine); - const positions = this.collectNodePositions(modelState.root); - const routes = this.collectEdgeRoutes(modelState.root); + const positions = collectNodePositions(modelState.root); + const routes = collectEdgeRoutes(modelState.root); await this.layoutPersistence.saveLayoutImmediate(layoutMeta.workflowFilePath, layoutMeta.networkId, positions, routes); // From now on, treat it as persisted. layoutMeta.hasPersistedLayout = true; @@ -482,138 +489,6 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, merged, undefined); console.log(`[WorkflowModelSubmissionHandler] Placed ${newNodes.length} new node(s) below existing graph at y=${startY}.`); } - - private collectNodePositions(root: GModelRoot): Map { - const result = new Map(); - // Accumulate absolute positions by tracking parent offsets - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - // Compute this element's absolute position - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: GModelRoot): Map { - const result = new Map(); - const signatureCounts = this.buildEdgeSignatureCounts(root); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = this.edgeRouteKeyFor(args, signatureCounts); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private edgeSignatureFromArgs(args: Record | undefined): string | undefined { - if (!args) { - return undefined; - } - - const normalizeEndpoint = (value: unknown): string => { - if (typeof value === 'string' && value.trim() !== '') { - return value.trim(); - } - return 'boundary'; - }; - - const fromEntity = normalizeEndpoint(args['wf:from']); - const toEntity = normalizeEndpoint(args['wf:to']); - const outPort = typeof args['wf:outPort'] === 'string' ? (args['wf:outPort'] as string) : undefined; - const inPort = typeof args['wf:inPort'] === 'string' ? (args['wf:inPort'] as string) : undefined; - - if (!outPort || !inPort) { - return undefined; - } - - return `${fromEntity}|${outPort}|${toEntity}|${inPort}`; - } - - private buildEdgeSignatureCounts(root: GModelRoot): Map { - const counts = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const signature = this.edgeSignatureFromArgs(element.args as Record | undefined); - if (signature) { - counts.set(signature, (counts.get(signature) ?? 0) + 1); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return counts; - } - - private edgeRouteKeyFor( - args: Record | undefined, - signatureCounts: Map - ): string | undefined { - const signature = this.edgeSignatureFromArgs(args); - const astPath = args?.[WorkflowDiagramMetadata.AST_PATH]; - - if (signature) { - const count = signatureCounts.get(signature) ?? 0; - if (count > 1) { - if (typeof astPath === 'string' && astPath.trim() !== '') { - return `${signature}|${astPath}`; - } - return undefined; - } - return signature; - } - - if (typeof astPath === 'string' && astPath.trim() !== '') { - return astPath; - } - - return undefined; - } } /** @@ -1325,59 +1200,6 @@ export class WorkflowComputedBoundsActionHandler implements ActionHandler { return rerouteSet; } - private snapshotNodePositionsById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - const type = element?.type as string | undefined; - const isNode = typeof type === 'string' && type.startsWith('node:'); - if (isNode) { - const id = String((element as any).id); - const pos = (element as any).position as { x?: number; y?: number } | undefined; - result.set(id, pos ? { x: pos.x ?? 0, y: pos.y ?? 0 } : undefined); - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private snapshotEdgeRoutesById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const id = String((element as any).id); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (Array.isArray(routingPoints)) { - result.set( - id, - routingPoints.map(p => ({ x: p.x, y: p.y })) - ); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - private applyInteractiveRoutingOptions(root: any): { restore: () => void } { const prevRootLayoutOptions = (root as any).layoutOptions ? { ...(root as any).layoutOptions } : undefined; (root as any).layoutOptions = { @@ -1524,141 +1346,6 @@ export class WorkflowComputedBoundsActionHandler implements ActionHandler { }; visit(root); } - - private collectNodePositions(root: GModelRoot): Map { - const result = new Map(); - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: GModelRoot, allowedEdgeIds?: Set): Map { - const result = new Map(); - const signatureCounts = this.buildEdgeSignatureCounts(root); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - // Optionally filter by edge ids (e.g., only persist rerouted edges). - const id = String((element as any).id); - if (!allowedEdgeIds || allowedEdgeIds.has(id)) { - const args = element.args as Record | undefined; - const key = this.edgeRouteKeyFor(args, signatureCounts); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private edgeSignatureFromArgs(args: Record | undefined): string | undefined { - if (!args) { - return undefined; - } - - const normalizeEndpoint = (value: unknown): string => { - if (typeof value === 'string' && value.trim() !== '') { - return value.trim(); - } - return 'boundary'; - }; - - const fromEntity = normalizeEndpoint(args['wf:from']); - const toEntity = normalizeEndpoint(args['wf:to']); - const outPort = typeof args['wf:outPort'] === 'string' ? (args['wf:outPort'] as string) : undefined; - const inPort = typeof args['wf:inPort'] === 'string' ? (args['wf:inPort'] as string) : undefined; - - if (!outPort || !inPort) { - return undefined; - } - - return `${fromEntity}|${outPort}|${toEntity}|${inPort}`; - } - - private buildEdgeSignatureCounts(root: GModelRoot): Map { - const counts = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const signature = this.edgeSignatureFromArgs(element.args as Record | undefined); - if (signature) { - counts.set(signature, (counts.get(signature) ?? 0) + 1); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return counts; - } - - private edgeRouteKeyFor( - args: Record | undefined, - signatureCounts: Map - ): string | undefined { - const signature = this.edgeSignatureFromArgs(args); - const astPath = args?.[WorkflowDiagramMetadata.AST_PATH]; - - if (signature) { - const count = signatureCounts.get(signature) ?? 0; - if (count > 1) { - if (typeof astPath === 'string' && astPath.trim() !== '') { - return `${signature}|${astPath}`; - } - return undefined; - } - return signature; - } - - if (typeof astPath === 'string' && astPath.trim() !== '') { - return astPath; - } - - return undefined; - } } /** @@ -1698,11 +1385,11 @@ export class WorkflowLayoutOperationHandler extends OperationHandler { // GLSP's ELK layout engine currently computes layout for the full diagram. // For selection-only layout, we snapshot the current positions/routes, run // layout, then restore everything that was not selected. - const selectedMovableNodeIds = this.getSelectedMovableNodeIds(operation); + const selectedMovableNodeIds = movableNodeIdsIn(this.modelState.index, operation.elementIds); const hasSelection = selectedMovableNodeIds.size > 0; - const preNodePositionsById = hasSelection ? this.snapshotNodePositionsById(this.modelState.root) : undefined; - const preEdgeRoutesById = hasSelection ? this.snapshotEdgeRoutesById(this.modelState.root) : undefined; + const preNodePositionsById = hasSelection ? snapshotNodePositionsById(this.modelState.root) : undefined; + const preEdgeRoutesById = hasSelection ? snapshotEdgeRoutesById(this.modelState.root) : undefined; // When a user triggers a manual auto-layout, we want ELK to fully recompute edge routing. // Existing routing points (from previous ELK runs or manual routing) can otherwise @@ -1726,324 +1413,12 @@ export class WorkflowLayoutOperationHandler extends OperationHandler { } if (hasSelection && preNodePositionsById && preEdgeRoutesById) { - this.restoreUnselectedLayout(this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); - } - - const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; - if (!diagramModel) { - return; + restoreUnselectedLayout(this.modelState.index, this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); } - const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); - const positions = this.collectNodePositions(this.modelState.root); - const routes = this.collectEdgeRoutes(this.modelState.root); - await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); - - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - unpositionedNodeCount?: number; - } - | undefined; - if (layoutMeta) { - layoutMeta.hasPersistedLayout = true; - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); - } + await persistLayout(this.modelState, this.layoutPersistence, { layoutPerformed: true }); } // clearAllEdgeRoutingPoints extracted to src/glsp/routing/clear-edge-routes.ts - - private isMovableNode(element: any): boolean { - const type = element?.type as string | undefined; - return typeof type === 'string' && type.startsWith('node:'); - } - - private getMovableNodeIdForElementId(elementId: string): string | undefined { - let current: any = this.modelState.index.find(elementId); - while (current) { - if (this.isMovableNode(current)) { - return String((current as any).id); - } - current = (current as any).parent; - } - return undefined; - } - - private getSelectedMovableNodeIds(operation: LayoutOperation): Set { - const result = new Set(); - const elementIds = Array.isArray(operation.elementIds) ? operation.elementIds : []; - - for (const id of elementIds) { - const movable = this.getMovableNodeIdForElementId(id); - if (movable) { - result.add(movable); - } - - const element: any = this.modelState.index.find(id); - const type = element?.type as string | undefined; - const isEdge = element instanceof GEdge || (typeof type === 'string' && type.startsWith('edge:')); - if (isEdge) { - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - if (typeof sourceId === 'string') { - const sourceMovable = this.getMovableNodeIdForElementId(sourceId); - if (sourceMovable) { - result.add(sourceMovable); - } - } - if (typeof targetId === 'string') { - const targetMovable = this.getMovableNodeIdForElementId(targetId); - if (targetMovable) { - result.add(targetMovable); - } - } - } - } - - return result; - } - - private snapshotNodePositionsById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (this.isMovableNode(element)) { - const id = String((element as any).id); - const pos = (element as any).position as { x?: number; y?: number } | undefined; - if (pos) { - result.set(id, { x: pos.x ?? 0, y: pos.y ?? 0 }); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private snapshotEdgeRoutesById(root: any): Map { - const result = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const id = String((element as any).id); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (Array.isArray(routingPoints)) { - result.set( - id, - routingPoints.map(p => ({ x: p.x, y: p.y })) - ); - } else { - result.set(id, undefined); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private restoreUnselectedLayout( - root: any, - selectedMovableNodeIds: Set, - preNodePositionsById: Map, - preEdgeRoutesById: Map - ): void { - const visit = (element: any): void => { - if (!element) { - return; - } - - if (this.isMovableNode(element)) { - const id = String((element as any).id); - if (!selectedMovableNodeIds.has(id)) { - const prev = preNodePositionsById.get(id); - (element as any).position = prev ? { x: prev.x, y: prev.y } : undefined; - } - } - - if (element instanceof GEdge) { - const id = String((element as any).id); - const sourceId = (element as any).sourceId as string | undefined; - const targetId = (element as any).targetId as string | undefined; - - const sourceMovable = typeof sourceId === 'string' ? this.getMovableNodeIdForElementId(sourceId) : undefined; - const targetMovable = typeof targetId === 'string' ? this.getMovableNodeIdForElementId(targetId) : undefined; - - const sourceSelected = sourceMovable ? selectedMovableNodeIds.has(sourceMovable) : false; - const targetSelected = targetMovable ? selectedMovableNodeIds.has(targetMovable) : false; - - // Keep new routes for edges incident to moved (selected) nodes. - // Restore routes for edges that only connect non-selected nodes. - if (!sourceSelected && !targetSelected) { - const prevRoute = preEdgeRoutesById.get(id); - // Preserve empty arrays; see restoreUntargetedEdgeRoutes(). - (element as any).routingPoints = prevRoute !== undefined ? prevRoute.map(p => ({ x: p.x, y: p.y })) : undefined; - } - } - - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - } - - private collectNodePositions(root: GModelRoot): Map { - const result = new Map(); - // Accumulate absolute positions by tracking parent offsets - const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { - if (!element) { - return; - } - // Compute this element's absolute position - const relX = element.position?.x ?? 0; - const relY = element.position?.y ?? 0; - const absX = parentAbsX + relX; - const absY = parentAbsY + relY; - - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { - result.set(key, { x: absX, y: absY }); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child, absX, absY); - } - } - }; - visit(root, 0, 0); - return result; - } - - private collectEdgeRoutes(root: GModelRoot): Map { - const result = new Map(); - const signatureCounts = this.buildEdgeSignatureCounts(root); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const args = element.args as Record | undefined; - const key = this.edgeRouteKeyFor(args, signatureCounts); - const routingPoints = (element as any).routingPoints as { x: number; y: number }[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { - result.set(key, routingPoints); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return result; - } - - private edgeSignatureFromArgs(args: Record | undefined): string | undefined { - if (!args) { - return undefined; - } - - const normalizeEndpoint = (value: unknown): string => { - if (typeof value === 'string' && value.trim() !== '') { - return value.trim(); - } - return 'boundary'; - }; - - const fromEntity = normalizeEndpoint(args['wf:from']); - const toEntity = normalizeEndpoint(args['wf:to']); - const outPort = typeof args['wf:outPort'] === 'string' ? (args['wf:outPort'] as string) : undefined; - const inPort = typeof args['wf:inPort'] === 'string' ? (args['wf:inPort'] as string) : undefined; - - if (!outPort || !inPort) { - return undefined; - } - - return `${fromEntity}|${outPort}|${toEntity}|${inPort}`; - } - - private buildEdgeSignatureCounts(root: GModelRoot): Map { - const counts = new Map(); - const visit = (element: any): void => { - if (!element) { - return; - } - if (element instanceof GEdge) { - const signature = this.edgeSignatureFromArgs(element.args as Record | undefined); - if (signature) { - counts.set(signature, (counts.get(signature) ?? 0) + 1); - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return counts; - } - - private edgeRouteKeyFor( - args: Record | undefined, - signatureCounts: Map - ): string | undefined { - const signature = this.edgeSignatureFromArgs(args); - const astPath = args?.[WorkflowDiagramMetadata.AST_PATH]; - - if (signature) { - const count = signatureCounts.get(signature) ?? 0; - if (count > 1) { - if (typeof astPath === 'string' && astPath.trim() !== '') { - return `${signature}|${astPath}`; - } - return undefined; - } - return signature; - } - - if (typeof astPath === 'string' && astPath.trim() !== '') { - return astPath; - } - - return undefined; - } } diff --git a/packages/diagram-server/src/server/layout-snapshot.ts b/packages/diagram-server/src/server/layout-snapshot.ts new file mode 100644 index 0000000..176a67e --- /dev/null +++ b/packages/diagram-server/src/server/layout-snapshot.ts @@ -0,0 +1,232 @@ +/** + * The layout as the layout file stores it, read off the GModel. + * + * Every handler that lays nodes out or routes edges ends the same way: walk the + * model, key each node by its AST path (or its name), key each edge the way + * `applyPersistedEdgeRoutes` reads it back, and save both. The layout handlers + * also keep the unselected part of the diagram where it was. These are those + * steps, written once. + */ +import { GEdge, GModelIndex, GModelRoot, ModelState } from '@eclipse-glsp/server'; +import { WorkflowDiagramMetadata, WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; +import type { WorkflowDiagramModel } from '@dialogram/shared'; +import { buildEdgeSignatureCounts, edgeRouteKeyFor } from '../routing/persisted-edge-routes'; +import type { LayoutPersistenceService } from '../services/layout-persistence-service'; +import { layoutTargetOf } from './layout-target'; + +export type Point = { x: number; y: number }; + +/** What the model state keeps about the diagram's layout file. */ +export interface LayoutPersistenceMeta { + workflowFilePath: string; + networkId: string; + hasPersistedLayout: boolean; + hasPersistedEdgeRoutes: boolean; + didInitialLayout: boolean; + hasClientBounds: boolean; + allowInitialLayoutPersistence: boolean; + unpositionedNodeCount?: number; + [key: string]: unknown; +} + +/** A node the layout may move: any `node:*` element. */ +export function isMovableNode(element: any): boolean { + const type = element?.type as string | undefined; + return typeof type === 'string' && type.startsWith('node:'); +} + +/** The id of the nearest movable node at or above `elementId`. */ +export function movableNodeIdFor(index: GModelIndex, elementId: string): string | undefined { + let current: any = index.find(elementId); + while (current) { + if (isMovableNode(current)) { + return String(current.id); + } + current = current.parent; + } + return undefined; +} + +/** + * The movable nodes a selection names: each selected element's node, and both + * ends of each selected edge. + */ +export function movableNodeIdsIn(index: GModelIndex, elementIds: string[] | undefined): Set { + const result = new Set(); + for (const id of Array.isArray(elementIds) ? elementIds : []) { + const movable = movableNodeIdFor(index, id); + if (movable) { + result.add(movable); + } + const element: any = index.find(id); + const type = element?.type as string | undefined; + const isEdge = element instanceof GEdge || (typeof type === 'string' && type.startsWith('edge:')); + if (isEdge) { + for (const endId of [element.sourceId, element.targetId]) { + const endMovable = typeof endId === 'string' ? movableNodeIdFor(index, endId) : undefined; + if (endMovable) { + result.add(endMovable); + } + } + } + } + return result; +} + +function walk(root: any, visit: (element: any) => void): void { + const step = (element: any): void => { + if (!element) { + return; + } + visit(element); + const children = element.children as any[] | undefined; + if (Array.isArray(children)) { + for (const child of children) { + step(child); + } + } + }; + step(root); +} + +/** Every movable node's relative position by element id, `undefined` where it has none. */ +export function snapshotNodePositionsById(root: GModelRoot): Map { + const result = new Map(); + walk(root, element => { + if (isMovableNode(element)) { + const pos = element.position as { x?: number; y?: number } | undefined; + result.set(String(element.id), pos ? { x: pos.x ?? 0, y: pos.y ?? 0 } : undefined); + } + }); + return result; +} + +/** Every edge's routing points by element id, `undefined` where it has none. */ +export function snapshotEdgeRoutesById(root: GModelRoot): Map { + const result = new Map(); + walk(root, element => { + if (element instanceof GEdge) { + const routingPoints = (element as any).routingPoints as Point[] | undefined; + result.set(String(element.id), Array.isArray(routingPoints) ? routingPoints.map(p => ({ x: p.x, y: p.y })) : undefined); + } + }); + return result; +} + +/** + * Put every node outside `selectedMovableNodeIds`, and every edge with neither + * end in it, back where the two snapshots had them: a layout of a selection + * moves the selection alone. + */ +export function restoreUnselectedLayout( + index: GModelIndex, + root: GModelRoot, + selectedMovableNodeIds: Set, + preNodePositionsById: Map, + preEdgeRoutesById: Map +): void { + walk(root, element => { + if (isMovableNode(element)) { + const id = String(element.id); + if (!selectedMovableNodeIds.has(id)) { + const prev = preNodePositionsById.get(id); + element.position = prev ? { x: prev.x, y: prev.y } : undefined; + } + } + if (element instanceof GEdge) { + const sourceId = (element as any).sourceId as string | undefined; + const targetId = (element as any).targetId as string | undefined; + const sourceMovable = typeof sourceId === 'string' ? movableNodeIdFor(index, sourceId) : undefined; + const targetMovable = typeof targetId === 'string' ? movableNodeIdFor(index, targetId) : undefined; + const sourceSelected = sourceMovable ? selectedMovableNodeIds.has(sourceMovable) : false; + const targetSelected = targetMovable ? selectedMovableNodeIds.has(targetMovable) : false; + if (!sourceSelected && !targetSelected) { + const prevRoute = preEdgeRoutesById.get(String(element.id)); + (element as any).routingPoints = prevRoute !== undefined ? prevRoute.map(p => ({ x: p.x, y: p.y })) : undefined; + } + } + }); +} + +/** + * Every positioned node's absolute position, keyed as the layout file keys + * nodes: its AST path, else its entity name, else its port name. + */ +export function collectNodePositions(root: GModelRoot): Map { + const result = new Map(); + const visit = (element: any, parentAbsX: number, parentAbsY: number): void => { + if (!element) { + return; + } + const absX = parentAbsX + (element.position?.x ?? 0); + const absY = parentAbsY + (element.position?.y ?? 0); + if (isMovableNode(element)) { + const args = element.args as Record | undefined; + const key = (args?.[WorkflowDiagramMetadata.AST_PATH] + ?? args?.[WorkflowDiagramMetadata.ENTITY_NAME] + ?? args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; + if (typeof key === 'string' && key.trim() !== '' && element.position) { + result.set(key, { x: absX, y: absY }); + } + } + const children = element.children as any[] | undefined; + if (Array.isArray(children)) { + for (const child of children) { + visit(child, absX, absY); + } + } + }; + visit(root, 0, 0); + return result; +} + +/** + * Every routed edge's polyline, keyed as `applyPersistedEdgeRoutes` looks it + * up: by endpoint signature, with the AST path added where the signature + * repeats. A route needs two points to be one. + */ +export function collectEdgeRoutes(root: GModelRoot): Map { + const result = new Map(); + const signatureCounts = buildEdgeSignatureCounts(root); + walk(root, element => { + if (element instanceof GEdge) { + const key = edgeRouteKeyFor(element.args as Record | undefined, signatureCounts); + const routingPoints = (element as any).routingPoints as Point[] | undefined; + if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length >= 2) { + result.set(key, routingPoints); + } + } + }); + return result; +} + +/** + * Save the model's layout to the diagram's layout file and note it in the + * model state. `layoutPerformed`: the whole layout is now persisted, not only + * the routes. `keepPersistedPositions`: a route-only change, so the positions + * already on disk, under their stable keys, stand where there are any. + */ +export async function persistLayout( + modelState: ModelState, + layoutPersistence: LayoutPersistenceService, + options: { layoutPerformed?: boolean; keepPersistedPositions?: boolean } = {} +): Promise { + const diagramModel = modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; + if (!diagramModel) { + return; + } + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); + const persisted = options.keepPersistedPositions ? await layoutPersistence.loadLayout(workflowFilePath, networkId) : undefined; + const positions = persisted ?? collectNodePositions(modelState.root); + const routes = collectEdgeRoutes(modelState.root); + await layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); + + const layoutMeta = modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as LayoutPersistenceMeta | undefined; + if (layoutMeta) { + if (options.layoutPerformed) { + layoutMeta.hasPersistedLayout = true; + } + layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; + modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); + } +} diff --git a/packages/diagram-server/test/layout-snapshot.test.ts b/packages/diagram-server/test/layout-snapshot.test.ts new file mode 100644 index 0000000..370ca0b --- /dev/null +++ b/packages/diagram-server/test/layout-snapshot.test.ts @@ -0,0 +1,150 @@ +/** + * The layout as the layout file stores it, read off the GModel once for every + * handler. Pins the keys (a node by AST path, an edge by its signature as the + * reader looks it up), the selection's movable nodes, the restore of the + * unselected part, and that a route-only save notes itself under the shared + * model-state key. + */ +import { describe, expect, it, vi } from 'vitest'; +import { GEdge, GGraph, GModelIndex, GNode, GPort } from '@eclipse-glsp/server'; +import { WorkflowDiagramMetadata, WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; +import { + collectEdgeRoutes, + collectNodePositions, + movableNodeIdsIn, + persistLayout, + restoreUnselectedLayout, + snapshotEdgeRoutesById, + snapshotNodePositionsById +} from '../src/server/layout-snapshot'; + +function node(id: string, x: number, y: number, args: Record, ...children: any[]) { + const builder = GNode.builder().id(id).type('node:actor').position(x, y); + for (const [k, v] of Object.entries(args)) { + builder.addArg(k, v as any); + } + return builder.addChildren(...children).build(); +} + +function port(id: string, x: number, y: number) { + return GPort.builder().id(id).type('port:output').position(x, y).size(8, 8).build(); +} + +function edge(id: string, sourceId: string, targetId: string, args: Record, routingPoints?: Array<{ x: number; y: number }>) { + const builder = GEdge.builder().id(id).type('edge:connection').sourceId(sourceId).targetId(targetId); + for (const [k, v] of Object.entries(args)) { + builder.addArg(k, v as any); + } + const built = builder.build(); + if (routingPoints) { + (built as any).routingPoints = routingPoints; + } + return built; +} + +function diagram() { + const a = node('a', 10, 20, { [WorkflowDiagramMetadata.AST_PATH]: 'main.a' }, port('a.out', 50, 5)); + const b = node('b', 200, 20, { [WorkflowDiagramMetadata.ENTITY_NAME]: 'b' }, port('b.in', 0, 5)); + const group = GNode.builder().id('g').type('node:network').position(100, 100).addChildren( + node('c', 5, 5, { [WorkflowDiagramMetadata.AST_PATH]: 'main.g.c' }) + ).build(); + const wire = edge('e1', 'a.out', 'b.in', { 'wf:from': 'a', 'wf:to': 'b', 'wf:outPort': 'out', 'wf:inPort': 'in', [WorkflowDiagramMetadata.AST_PATH]: 'main.e1' }, [{ x: 58, y: 29 }, { x: 200, y: 29 }]); + const stub = edge('e2', 'a.out', 'b.in', { [WorkflowDiagramMetadata.AST_PATH]: 'main.e2' }, [{ x: 1, y: 1 }]); + const root = GGraph.builder().id('root').addChildren(a, b, group, wire, stub).build(); + const index = new GModelIndex(); + index.indexRoot(root); + return { root, index, a, b, group, wire }; +} + +describe('collectNodePositions', () => { + it('keys each node by AST path, else name, at its absolute position', () => { + const { root } = diagram(); + expect(collectNodePositions(root)).toEqual(new Map([ + ['main.a', { x: 10, y: 20 }], + ['b', { x: 200, y: 20 }], + ['main.g.c', { x: 105, y: 105 }] + ])); + }); +}); + +describe('collectEdgeRoutes', () => { + it('keys a route by the edge signature the reader looks up, and needs two points', () => { + const { root } = diagram(); + const routes = collectEdgeRoutes(root); + expect(Array.from(routes.keys())).toEqual(['a|out|b|in']); + expect(routes.get('a|out|b|in')).toEqual([{ x: 58, y: 29 }, { x: 200, y: 29 }]); + }); +}); + +describe('movableNodeIdsIn', () => { + it('names the node of each selected element and both ends of a selected edge', () => { + const { index } = diagram(); + expect(movableNodeIdsIn(index, ['a.out'])).toEqual(new Set(['a'])); + expect(movableNodeIdsIn(index, ['e1'])).toEqual(new Set(['a', 'b'])); + expect(movableNodeIdsIn(index, ['c'])).toEqual(new Set(['c'])); + expect(movableNodeIdsIn(index, undefined)).toEqual(new Set()); + }); +}); + +describe('restoreUnselectedLayout', () => { + it('puts the nodes and edges outside the selection back where the snapshots had them', () => { + const { root, index, a, b, group, wire } = diagram(); + const positions = snapshotNodePositionsById(root); + const routes = snapshotEdgeRoutesById(root); + a.position = { x: 999, y: 999 }; + b.position = { x: 888, y: 888 }; + group.position = { x: 777, y: 777 }; + (wire as any).routingPoints = [{ x: 0, y: 0 }, { x: 1, y: 1 }]; + + restoreUnselectedLayout(index, root, new Set(['a']), positions, routes); + + expect(a.position).toEqual({ x: 999, y: 999 }); + expect(b.position).toEqual({ x: 200, y: 20 }); + expect(group.position).toEqual({ x: 100, y: 100 }); + // The wire ends on the selected node, so its new route stands. + expect((wire as any).routingPoints).toEqual([{ x: 0, y: 0 }, { x: 1, y: 1 }]); + }); +}); + +describe('persistLayout', () => { + function modelStateFor(root: any, meta: Record | undefined) { + const state = new Map([[WORKFLOW_NETWORK_MODEL_KEY, { documentUri: 'file:///wf/flow.py', workflowName: 'main' }]]); + if (meta) { + state.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, meta); + } + return { root, get: (key: string) => state.get(key), set: (key: string, value: unknown) => state.set(key, value) } as any; + } + + it('saves the model positions and routes and marks the layout persisted', async () => { + const { root } = diagram(); + const meta = { hasPersistedLayout: false, hasPersistedEdgeRoutes: false }; + const persistence = { loadLayout: vi.fn(), saveLayoutImmediate: vi.fn(async () => undefined) } as any; + + await persistLayout(modelStateFor(root, meta), persistence, { layoutPerformed: true }); + + expect(persistence.loadLayout).not.toHaveBeenCalled(); + const [, , positions, routes] = persistence.saveLayoutImmediate.mock.calls[0]; + expect(positions.get('main.a')).toEqual({ x: 10, y: 20 }); + expect(routes.size).toBe(1); + expect(meta).toEqual({ hasPersistedLayout: true, hasPersistedEdgeRoutes: true }); + }); + + it('keeps the positions already on disk for a route-only change, and notes the routes under the shared key', async () => { + const { root } = diagram(); + const meta = { hasPersistedLayout: true, hasPersistedEdgeRoutes: false }; + const onDisk = new Map([['main.a', { x: 1, y: 2 }]]); + const persistence = { loadLayout: vi.fn(async () => onDisk), saveLayoutImmediate: vi.fn(async () => undefined) } as any; + + await persistLayout(modelStateFor(root, meta), persistence, { keepPersistedPositions: true }); + + const [, , positions] = persistence.saveLayoutImmediate.mock.calls[0]; + expect(positions).toBe(onDisk); + expect(meta).toEqual({ hasPersistedLayout: true, hasPersistedEdgeRoutes: true }); + }); + + it('does nothing for a model without a diagram', async () => { + const persistence = { loadLayout: vi.fn(), saveLayoutImmediate: vi.fn() } as any; + await persistLayout({ root: {}, get: () => undefined, set: () => undefined } as any, persistence); + expect(persistence.saveLayoutImmediate).not.toHaveBeenCalled(); + }); +}); From 74fdcb9e59ea4951f2f630c91758a092e804fece Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Sun, 8 Nov 2026 09:10:33 +0100 Subject: [PATCH 2/2] fix: what the review of the layout snapshot turned up Reset Routes and the reroute leave the layout meta alone on purpose: their old note went to a key nothing read, and writing it where the rest of the server reads it would have lowered "has persisted edge routes" and armed the bootstrap reroute, which redraws what was just cleared. A lone bend is saved as before: the loader rebuilds it into a full polyline. The bend handler keeps its own port-anchor helper, which accepts any end with a node above it where the loader's wants a port. One save path: runBoundaryFlowAndPersist goes through persistLayout, placeNewNodes keys nodes with the shared nodeLayoutKeyOf, the inline layout-meta types are the one LayoutPersistenceMeta, Point comes from the routes module, and the reroute's persist drops a parameter it no longer read. Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv --- CHANGELOG.md | 13 ++-- .../change-routing-points-handler.ts | 64 ++++++++++++++- .../reroute-edges-avoid-overlaps-handler.ts | 10 ++- .../operations/reset-edge-routes-handler.ts | 6 +- .../src/server/diagram-action-handlers.ts | 77 ++++--------------- .../src/server/layout-snapshot.ts | 39 +++++++--- .../test/layout-snapshot.test.ts | 18 ++++- 7 files changed, 134 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b80149..60a1b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,12 @@ platform API version. holds them once (`collectNodePositions`, `collectEdgeRoutes`, `movableNodeIdsIn`, `snapshot*ById`, `restoreUnselectedLayout`, `persistLayout`), and `persisted-edge-routes.ts` exports the route-key and - anchor helpers it always had. + anchor helpers it always had. Every handler keys a saved route the same way + (endpoint signature, AST path where it repeats; four keyed by path alone, + which the loader read through its fallback). Reset Routes and the reroute, + which noted their save under a model-state key nothing read, now leave the + layout meta alone on purpose: lowering "has persisted edge routes" would arm + the bootstrap reroute that redraws what was just cleared. - **After a drop, the routes an edge cannot keep are drawn by libavoid, with the edges on one port as one shared path.** The layout draws the edges into a port as a trunk; the built-in router gives every edge its own channel and @@ -99,12 +104,6 @@ platform API version. ### Fixed -- **Reset Routes and the reroute note their save where the rest of the server - reads it.** Both wrote "has persisted edge routes" under a model-state key - nothing else used, so after them the flag stayed what the last layout left. - Every handler also keys a saved route the same way now (endpoint signature, - AST path where it repeats); four keyed by path alone, which the loader's - fallback read but the other writers overwrote. - A layout change still on its 500 ms debounce timer when the window closes is written out: the host flushes every diagram's pending layout saves on deactivation. diff --git a/packages/diagram-server/src/operations/change-routing-points-handler.ts b/packages/diagram-server/src/operations/change-routing-points-handler.ts index 30d78a6..e466dde 100644 --- a/packages/diagram-server/src/operations/change-routing-points-handler.ts +++ b/packages/diagram-server/src/operations/change-routing-points-handler.ts @@ -12,9 +12,9 @@ import { import { inject, injectable } from 'inversify'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; +import { WorkflowDiagramConstants, portSide } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; import { persistLayout } from '../server/layout-snapshot'; -import { portAnchorAbsolute } from '../routing/persisted-edge-routes'; @injectable() export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandler { @@ -101,9 +101,67 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle return Math.sqrt(dx * dx + dy * dy); } + private absolutePosition(element: any): { x: number; y: number } { + let x = 0; + let y = 0; + let cur: any = element; + while (cur) { + const p = cur.position as { x?: number; y?: number } | undefined; + if (p) { + x += p.x ?? 0; + y += p.y ?? 0; + } + cur = cur.parent; + } + return { x, y }; + } + + private portAnchorAbsolute(portId: string): { x: number; y: number } | undefined { + const port: any = this.modelState.index.find(portId); + if (!port) { + return undefined; + } + // Find containing node (ports are typically children of nodes). + let node: any = port; + while (node && !(node as any).type?.startsWith?.('node:')) { + node = node.parent; + } + if (!node) { + return undefined; + } + + const nodeAbs = this.absolutePosition(node); + const portRel = (port.position as { x?: number; y?: number } | undefined) ?? { x: 0, y: 0 }; + const fallbackPortWidth = WorkflowDiagramConstants.PORT_WIDTH_PX; + const fallbackPortHeight = WorkflowDiagramConstants.PORT_HEIGHT_PX; + const w = (port.size?.width && port.size.width > 0) ? port.size.width : fallbackPortWidth; + const h = (port.size?.height && port.size.height > 0) ? port.size.height : fallbackPortHeight; + + const side = portSide({ + absolute: nodeAbs, + direction: (port as any).args?.['cal:portDirection'] as string | undefined, + side: (port as any).args?.['cal:portSide'] as string | undefined, + type: port.type as string | undefined + }); + + const absPortX = nodeAbs.x + (portRel.x ?? 0); + const absPortY = nodeAbs.y + (portRel.y ?? 0); + const cx = absPortX + w / 2; + const cy = absPortY + h / 2; + + switch (side) { + case 'WEST': + return { x: absPortX, y: cy }; + case 'EAST': + return { x: absPortX + w, y: cy }; + default: + return { x: cx, y: cy }; + } + } + private normalizeToFullPolyline(edge: any, maybeIntermediate: { x: number; y: number }[]): { x: number; y: number }[] { - const src = portAnchorAbsolute(this.modelState.index, edge?.sourceId); - const tgt = portAnchorAbsolute(this.modelState.index, edge?.targetId); + const src = typeof edge?.sourceId === 'string' ? this.portAnchorAbsolute(edge.sourceId) : undefined; + const tgt = typeof edge?.targetId === 'string' ? this.portAnchorAbsolute(edge.targetId) : undefined; if (!src || !tgt) { return maybeIntermediate; } diff --git a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts index 8d5060f..07e5df6 100644 --- a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts +++ b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts @@ -1049,7 +1049,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation if (this.debugReroute >= 1) { console.info('[cal-reroute] slide — ' + slidEdgeIds.size + ' edges kept their routes'); } - await this.persistRoutes(root, operation); + await this.persistRoutes(operation); this.logPerf(operation, startedAt, slidEdgeIds.size, 0, 'slide'); return; } @@ -1174,7 +1174,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation }); } - await this.persistRoutes(root, operation); + await this.persistRoutes(operation); this.logPerf(operation, startedAt, slidEdgeIds.size, routed, usedLibavoid ? 'libavoid' : 'manhattan'); } @@ -1186,9 +1186,11 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation } /** Persist the layout, positions and routes; not for a preview (a drag). */ - private async persistRoutes(root: GGraph, operation: WorkflowRerouteEdgesAvoidOverlapsOperation): Promise { + private async persistRoutes(operation: WorkflowRerouteEdgesAvoidOverlapsOperation): Promise { if (operation.preview) { return; } - await persistLayout(this.modelState, this.layoutPersistence); + // The layout meta is left alone: a reroute that routed nothing must not + // lower "has persisted edge routes" and arm the bootstrap reroute. + await persistLayout(this.modelState, this.layoutPersistence, { noteInMeta: false }); } private routingSignature(points: { x: number; y: number }[] | undefined): string { diff --git a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts index 149efe5..dbe59b3 100644 --- a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts +++ b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts @@ -110,7 +110,9 @@ export class WorkflowResetEdgeRoutesOperationHandler extends OperationHandler { visit(root); - // Persist the updated routing points; the node positions already on disk stand. - await persistLayout(this.modelState, this.layoutPersistence, { keepPersistedPositions: true }); + // Persist the cleared routes; the node positions already on disk stand. The + // layout meta is left alone: noting "no persisted routes" would arm the + // bootstrap reroute, which would redraw what was just reset. + await persistLayout(this.modelState, this.layoutPersistence, { keepPersistedPositions: true, noteInMeta: false }); } } diff --git a/packages/diagram-server/src/server/diagram-action-handlers.ts b/packages/diagram-server/src/server/diagram-action-handlers.ts index 12de3be..58a5d1c 100644 --- a/packages/diagram-server/src/server/diagram-action-handlers.ts +++ b/packages/diagram-server/src/server/diagram-action-handlers.ts @@ -59,8 +59,9 @@ import { clearAllEdgeRoutingPoints } from '../routing/clear-edge-routes'; import { releaseBoundaryNodeConstraints, restoreNodeLayoutOptions, runBoundaryFlowLayout } from '../operations/boundary-flow-layout'; import { perfNow } from './graph-load-perf'; import { - collectEdgeRoutes, - collectNodePositions, + isMovableNode, + type LayoutPersistenceMeta, + nodeLayoutKeyOf, persistLayout, restoreUnselectedLayout, movableNodeIdsIn, @@ -96,18 +97,7 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { // 1) Optional automatic layout on first open when no persisted layout exists. // We do this even though layoutKind is MANUAL, but only once per session // and we persist the result immediately so further edits won't trigger layout. - const layoutMeta = modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - unpositionedNodeCount?: number; - } - | undefined; + const layoutMeta = modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as LayoutPersistenceMeta | undefined; if ( layoutEngine && @@ -129,7 +119,7 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { // run the exact same boundary-flow routine (pin one-sided task nodes to outer // layers + clear stale edge routes + ELK), so the fresh-open render matches // what manual layout produces instead of diverging (plain ELK looked worse). - await this.runBoundaryFlowAndPersist(layoutMeta, modelState, layoutEngine); + await this.runBoundaryFlowAndPersist(modelState, layoutEngine); // Always-on breadcrumb: initial ELK layout is the dominant post-load server cost on a // fresh open, and it runs here (in the client-bounds round-trip), not in the load path. // eslint-disable-next-line no-console @@ -198,7 +188,7 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { } console.log('[WorkflowModelSubmissionHandler] Auto-layout after agent structural edit for:', layoutMeta.networkId); try { - await this.runBoundaryFlowAndPersist(layoutMeta, modelState, layoutEngine); + await this.runBoundaryFlowAndPersist(modelState, layoutEngine); console.log('[WorkflowModelSubmissionHandler] Agent auto-layout completed'); } catch (error) { console.error('[WorkflowModelSubmissionHandler] Agent auto-layout failed:', error); @@ -377,25 +367,10 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { * and the agent-structural-edit auto-layout (branch 1c) so both produce identical geometry * and persistence — no divergence between them. */ - private async runBoundaryFlowAndPersist( - layoutMeta: { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - [key: string]: unknown; - }, - modelState: ModelState, - layoutEngine: LayoutEngine - ): Promise { + private async runBoundaryFlowAndPersist(modelState: ModelState, layoutEngine: LayoutEngine): Promise { await runBoundaryFlowLayout(modelState.root, layoutEngine); - const positions = collectNodePositions(modelState.root); - const routes = collectEdgeRoutes(modelState.root); - await this.layoutPersistence.saveLayoutImmediate(layoutMeta.workflowFilePath, layoutMeta.networkId, positions, routes); // From now on, treat it as persisted. - layoutMeta.hasPersistedLayout = true; - layoutMeta.hasPersistedEdgeRoutes = routes.size > 0; - modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, layoutMeta); + await persistLayout(modelState, this.layoutPersistence, { layoutPerformed: true }); } /** @@ -432,13 +407,9 @@ export class WorkflowModelSubmissionHandler extends ModelSubmissionHandler { const newNodes: Array<{ key: string; element: any; width: number; height: number }> = []; const collect = (element: any): void => { if (!element) { return; } - const type = element.type as string | undefined; - if (typeof type === 'string' && type.startsWith('node:')) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] ?? - args?.[WorkflowDiagramMetadata.ENTITY_NAME] ?? - args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && !existingPositions.has(key)) { + if (isMovableNode(element)) { + const key = nodeLayoutKeyOf(element); + if (key !== undefined && !existingPositions.has(key)) { const size = (element as any).size as { width: number; height: number } | undefined; newNodes.push({ key, @@ -737,20 +708,7 @@ export class WorkflowComputedBoundsActionHandler implements ActionHandler { }; await this.applyBounds(model, action, passResult); - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath: string; - networkId: string; - hasPersistedLayout: boolean; - hasPersistedEdgeRoutes: boolean; - didInitialLayout: boolean; - hasClientBounds: boolean; - allowInitialLayoutPersistence: boolean; - unpositionedNodeCount?: number; - sizeMeasurePassCount?: number; - lastClientNodeSizes?: Record; - } - | undefined; + const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as LayoutPersistenceMeta | undefined; // Node sizes are SERVER-AUTHORITATIVE on every path. // @@ -855,16 +813,7 @@ export class WorkflowComputedBoundsActionHandler implements ActionHandler { protected async applyBounds(root: GModelRoot, action: ComputedBoundsAction, passResult?: BoundsPassResult): Promise { const index = this.modelState.index; - const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as - | { - workflowFilePath?: string; - networkId?: string; - hasPersistedLayout?: boolean; - hasClientBounds?: boolean; - hasPersistedEdgeRoutes: boolean; - rerouteEdgeIds?: string[]; - } - | undefined; + const layoutMeta = this.modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as LayoutPersistenceMeta | undefined; const hasPersistedLayout = layoutMeta?.hasPersistedLayout === true; diff --git a/packages/diagram-server/src/server/layout-snapshot.ts b/packages/diagram-server/src/server/layout-snapshot.ts index 176a67e..fc0aa0c 100644 --- a/packages/diagram-server/src/server/layout-snapshot.ts +++ b/packages/diagram-server/src/server/layout-snapshot.ts @@ -10,11 +10,11 @@ import { GEdge, GModelIndex, GModelRoot, ModelState } from '@eclipse-glsp/server'; import { WorkflowDiagramMetadata, WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; import type { WorkflowDiagramModel } from '@dialogram/shared'; -import { buildEdgeSignatureCounts, edgeRouteKeyFor } from '../routing/persisted-edge-routes'; +import { buildEdgeSignatureCounts, edgeRouteKeyFor, type Point } from '../routing/persisted-edge-routes'; import type { LayoutPersistenceService } from '../services/layout-persistence-service'; import { layoutTargetOf } from './layout-target'; -export type Point = { x: number; y: number }; +export type { Point }; /** What the model state keeps about the diagram's layout file. */ export interface LayoutPersistenceMeta { @@ -26,9 +26,24 @@ export interface LayoutPersistenceMeta { hasClientBounds: boolean; allowInitialLayoutPersistence: boolean; unpositionedNodeCount?: number; + sizeMeasurePassCount?: number; + lastClientNodeSizes?: Record; + rerouteEdgeIds?: string[]; [key: string]: unknown; } +/** + * The key the layout file stores a node's position under: its AST path, else + * its entity name, else its port name; `undefined` for a node with none. + */ +export function nodeLayoutKeyOf(element: any): string | undefined { + const args = element?.args as Record | undefined; + const key = (args?.[WorkflowDiagramMetadata.AST_PATH] + ?? args?.[WorkflowDiagramMetadata.ENTITY_NAME] + ?? args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; + return typeof key === 'string' && key.trim() !== '' ? key : undefined; +} + /** A node the layout may move: any `node:*` element. */ export function isMovableNode(element: any): boolean { const type = element?.type as string | undefined; @@ -161,11 +176,8 @@ export function collectNodePositions(root: GModelRoot): Map { const absX = parentAbsX + (element.position?.x ?? 0); const absY = parentAbsY + (element.position?.y ?? 0); if (isMovableNode(element)) { - const args = element.args as Record | undefined; - const key = (args?.[WorkflowDiagramMetadata.AST_PATH] - ?? args?.[WorkflowDiagramMetadata.ENTITY_NAME] - ?? args?.[WorkflowDiagramMetadata.PORT_NAME]) as unknown; - if (typeof key === 'string' && key.trim() !== '' && element.position) { + const key = nodeLayoutKeyOf(element); + if (key !== undefined && element.position) { result.set(key, { x: absX, y: absY }); } } @@ -183,7 +195,8 @@ export function collectNodePositions(root: GModelRoot): Map { /** * Every routed edge's polyline, keyed as `applyPersistedEdgeRoutes` looks it * up: by endpoint signature, with the AST path added where the signature - * repeats. A route needs two points to be one. + * repeats. Any routing points count: the loader rebuilds a lone bend into a + * full polyline between the ports. */ export function collectEdgeRoutes(root: GModelRoot): Map { const result = new Map(); @@ -192,7 +205,7 @@ export function collectEdgeRoutes(root: GModelRoot): Map { if (element instanceof GEdge) { const key = edgeRouteKeyFor(element.args as Record | undefined, signatureCounts); const routingPoints = (element as any).routingPoints as Point[] | undefined; - if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length >= 2) { + if (typeof key === 'string' && Array.isArray(routingPoints) && routingPoints.length > 0) { result.set(key, routingPoints); } } @@ -205,11 +218,14 @@ export function collectEdgeRoutes(root: GModelRoot): Map { * model state. `layoutPerformed`: the whole layout is now persisted, not only * the routes. `keepPersistedPositions`: a route-only change, so the positions * already on disk, under their stable keys, stand where there are any. + * `noteInMeta: false`: save without touching the model state's layout meta. + * Reset Routes and the reroute save that way: lowering "has persisted edge + * routes" would arm the bootstrap reroute that redraws what was just cleared. */ export async function persistLayout( modelState: ModelState, layoutPersistence: LayoutPersistenceService, - options: { layoutPerformed?: boolean; keepPersistedPositions?: boolean } = {} + options: { layoutPerformed?: boolean; keepPersistedPositions?: boolean; noteInMeta?: boolean } = {} ): Promise { const diagramModel = modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; if (!diagramModel) { @@ -221,6 +237,9 @@ export async function persistLayout( const routes = collectEdgeRoutes(modelState.root); await layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); + if (options.noteInMeta === false) { + return; + } const layoutMeta = modelState.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as LayoutPersistenceMeta | undefined; if (layoutMeta) { if (options.layoutPerformed) { diff --git a/packages/diagram-server/test/layout-snapshot.test.ts b/packages/diagram-server/test/layout-snapshot.test.ts index 370ca0b..ae5a384 100644 --- a/packages/diagram-server/test/layout-snapshot.test.ts +++ b/packages/diagram-server/test/layout-snapshot.test.ts @@ -68,11 +68,12 @@ describe('collectNodePositions', () => { }); describe('collectEdgeRoutes', () => { - it('keys a route by the edge signature the reader looks up, and needs two points', () => { + it('keys a route by the edge signature the reader looks up, and keeps a lone bend', () => { const { root } = diagram(); const routes = collectEdgeRoutes(root); - expect(Array.from(routes.keys())).toEqual(['a|out|b|in']); + expect(Array.from(routes.keys())).toEqual(['a|out|b|in', 'main.e2']); expect(routes.get('a|out|b|in')).toEqual([{ x: 58, y: 29 }, { x: 200, y: 29 }]); + expect(routes.get('main.e2')).toEqual([{ x: 1, y: 1 }]); }); }); @@ -125,7 +126,7 @@ describe('persistLayout', () => { expect(persistence.loadLayout).not.toHaveBeenCalled(); const [, , positions, routes] = persistence.saveLayoutImmediate.mock.calls[0]; expect(positions.get('main.a')).toEqual({ x: 10, y: 20 }); - expect(routes.size).toBe(1); + expect(routes.size).toBe(2); expect(meta).toEqual({ hasPersistedLayout: true, hasPersistedEdgeRoutes: true }); }); @@ -142,6 +143,17 @@ describe('persistLayout', () => { expect(meta).toEqual({ hasPersistedLayout: true, hasPersistedEdgeRoutes: true }); }); + it('leaves the layout meta alone when asked, as Reset Routes and the reroute do', async () => { + const { root } = diagram(); + const meta = { hasPersistedLayout: true, hasPersistedEdgeRoutes: true }; + const persistence = { loadLayout: vi.fn(), saveLayoutImmediate: vi.fn(async () => undefined) } as any; + + await persistLayout(modelStateFor(root, meta), persistence, { noteInMeta: false }); + + expect(persistence.saveLayoutImmediate).toHaveBeenCalledOnce(); + expect(meta).toEqual({ hasPersistedLayout: true, hasPersistedEdgeRoutes: true }); + }); + it('does nothing for a model without a diagram', async () => { const persistence = { loadLayout: vi.fn(), saveLayoutImmediate: vi.fn() } as any; await persistLayout({ root: {}, get: () => undefined, set: () => undefined } as any, persistence);