diff --git a/CHANGELOG.md b/CHANGELOG.md index d93ed18..60a1b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,20 @@ 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. 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 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..e466dde 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'; @injectable() export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandler { @@ -207,65 +204,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 +213,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..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 @@ -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 // ═══════════════════════════════════════════════════════════════════ @@ -1139,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; } @@ -1264,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'); } @@ -1276,28 +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; } - 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); - } + // 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 8103dcd..dbe59b3 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,9 @@ 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 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/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..58a5d1c 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,16 @@ 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 { + isMovableNode, + type LayoutPersistenceMeta, + nodeLayoutKeyOf, + persistLayout, + restoreUnselectedLayout, + movableNodeIdsIn, + snapshotEdgeRoutesById, + snapshotNodePositionsById +} from './layout-snapshot'; /** * Custom ModelSubmissionHandler - minimal logging version. @@ -89,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 && @@ -122,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 @@ -191,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); @@ -370,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 = this.collectNodePositions(modelState.root); - const routes = this.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 }); } /** @@ -425,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, @@ -482,138 +460,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; - } } /** @@ -862,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. // @@ -980,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; @@ -1325,59 +1149,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 +1295,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 +1334,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 +1362,12 @@ export class WorkflowLayoutOperationHandler extends OperationHandler { } if (hasSelection && preNodePositionsById && preEdgeRoutesById) { - this.restoreUnselectedLayout(this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); + restoreUnselectedLayout(this.modelState.index, this.modelState.root, selectedMovableNodeIds, preNodePositionsById, preEdgeRoutesById); } - 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; - 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..fc0aa0c --- /dev/null +++ b/packages/diagram-server/src/server/layout-snapshot.ts @@ -0,0 +1,251 @@ +/** + * 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, type Point } from '../routing/persisted-edge-routes'; +import type { LayoutPersistenceService } from '../services/layout-persistence-service'; +import { layoutTargetOf } from './layout-target'; + +export type { Point }; + +/** 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; + 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; + 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 key = nodeLayoutKeyOf(element); + if (key !== undefined && 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. 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(); + 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 > 0) { + 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. + * `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; noteInMeta?: 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); + + if (options.noteInMeta === false) { + return; + } + 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..ae5a384 --- /dev/null +++ b/packages/diagram-server/test/layout-snapshot.test.ts @@ -0,0 +1,162 @@ +/** + * 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 keeps a lone bend', () => { + const { root } = diagram(); + const routes = collectEdgeRoutes(root); + 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 }]); + }); +}); + +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(2); + 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('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); + expect(persistence.saveLayoutImmediate).not.toHaveBeenCalled(); + }); +});