import { nextTick } from "vue"; import { v4 as uuid } from "uuid"; import { MarkerType } from "@vue-flow/core"; const clone = (value) => JSON.parse(JSON.stringify(value ?? {})); const GROUP_TYPES = new Set(["loop", "selectArea"]); const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]); const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]); const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 }; const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 }; const LOOP_MIN_SIZE = { width: 520, height: 360 }; const loopChildExtent = () => ({ range: "parent", padding: [ LOOP_BODY_INSET.top, LOOP_BODY_INSET.right, LOOP_BODY_INSET.bottom, LOOP_BODY_INSET.left, ], }); const groupChildExtent = () => ({ range: "parent", padding: [ GROUP_BODY_INSET.top, GROUP_BODY_INSET.right, GROUP_BODY_INSET.bottom, GROUP_BODY_INSET.left, ], }); const nodeSize = (node) => { const width = Number(node.properties?.width || node.width); const height = Number(node.properties?.height || node.height); if (Number.isFinite(width) && Number.isFinite(height)) { return { width, height }; } if (node.type === "branch") return { width: 748, height: 420 }; if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 }; if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) { return { width: 328, height: 120 }; } return { width: 372, height: 180 }; }; const edgeId = () => `edge_${uuid().replace(/-/g, "")}`; const nodeId = () => uuid().replace(/-/g, ""); const toVueNode = (node, sizeOverride, positionOverride) => { const normalized = clone(node); normalized.id ||= nodeId(); normalized.properties ||= {}; const overrideWidth = Number(sizeOverride?.width); const overrideHeight = Number(sizeOverride?.height); const size = Number.isFinite(overrideWidth) && overrideWidth > 0 && Number.isFinite(overrideHeight) && overrideHeight > 0 ? { width: overrideWidth, height: overrideHeight } : nodeSize(normalized); const center = { x: Number(normalized.x) || 0, y: Number(normalized.y) || 0, }; const overrideX = Number(positionOverride?.x); const overrideY = Number(positionOverride?.y); const position = Number.isFinite(overrideX) && Number.isFinite(overrideY) ? { x: overrideX, y: overrideY } : { x: center.x - size.width / 2, y: center.y - size.height / 2, }; const dimensions = GROUP_TYPES.has(normalized.type) ? { width: size.width, height: size.height } : { width: size.width }; return { id: normalized.id, type: "flow-node", position, ...dimensions, draggable: normalized.draggable !== false, selectable: true, deletable: !SOURCE_ONLY_TYPES.has(normalized.type) && normalized.type !== "end", connectable: true, zIndex: GROUP_TYPES.has(normalized.type) ? 0 : 2, data: { flowType: normalized.type, properties: normalized.properties, children: Array.isArray(normalized.children) ? normalized.children : [], text: normalized.text, backend: normalized, originalCenter: center, originalPosition: position, size, }, }; }; const toVueEdge = (edge) => { const normalized = clone(edge); normalized.id ||= edgeId(); return { id: normalized.id, source: normalized.sourceNodeId, target: normalized.targetNodeId, sourceHandle: normalized.sourceAnchorId || null, targetHandle: normalized.targetAnchorId || null, type: "flow-edge", animated: true, selectable: true, focusable: true, deletable: true, interactionWidth: 32, markerEnd: { type: MarkerType.ArrowClosed, color: "#ff6b35", width: 20, height: 20, }, style: { stroke: "#ff6b35", strokeWidth: 2 }, data: { backend: normalized }, }; }; const attachChildrenToGroups = (nodes) => { const nodeLookup = new Map(nodes.map((node) => [node.id, node])); const declaredParents = new Map(); nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => { (group.data.children || []).forEach((childId) => { if (!declaredParents.has(childId)) declaredParents.set(childId, group.id); }); }); nodes.forEach((child) => { const parentId = child.data.properties?.parentId || declaredParents.get(child.id); const parent = nodeLookup.get(parentId); if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return; const isLoop = parent.data.flowType === "loop"; const relativePosition = { x: isLoop ? Math.max(child.position.x - parent.position.x, LOOP_BODY_INSET.left) : child.position.x - parent.position.x, y: isLoop ? Math.max(child.position.y - parent.position.y, LOOP_BODY_INSET.top) : child.position.y - parent.position.y, }; child.parentNode = parentId; child.extent = isLoop ? loopChildExtent() : groupChildExtent(); child.expandParent = false; child.position = relativePosition; child.zIndex = 5; child.data.properties = { ...child.data.properties, parentId }; parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id])); }); nodes.filter((node) => node.data.flowType === "loop").forEach((group) => { const children = nodes.filter((node) => node.parentNode === group.id); if (!children.length) return; const requiredWidth = Math.max(...children.map((child) => ( child.position.x + Number(child.data.size?.width || 0) + LOOP_BODY_INSET.right ))); const requiredHeight = Math.max(...children.map((child) => ( child.position.y + Number(child.data.size?.height || 0) + LOOP_BODY_INSET.bottom ))); const width = Math.max(Number(group.data.size?.width || 0), LOOP_MIN_SIZE.width, requiredWidth); const height = Math.max(Number(group.data.size?.height || 0), LOOP_MIN_SIZE.height, requiredHeight); group.width = width; group.height = height; group.data.size = { width, height }; group.data.properties = { ...group.data.properties, width, height }; }); return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode))); }; export const toVueFlowData = (graph = {}, options = {}) => { const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => ( toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id)) ))); const nodeLookup = new Map(nodes.map((node) => [node.id, node])); const edges = (graph.edges || []).map((edge) => { const vueEdge = toVueEdge(edge); const source = nodeLookup.get(vueEdge.source); const target = nodeLookup.get(vueEdge.target); if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4; return vueEdge; }); return { nodes, edges }; }; export const createFlowFacade = (store) => { const handlers = new Map(); const componentInstances = new Map(); const loopLayoutStates = new Map(); let historySuspended = false; let history = []; let historyIndex = -1; const getNodes = () => store.getNodes.value || []; const getEdges = () => store.getEdges.value || []; const backendNode = (node) => { const result = clone(node.data.backend); const size = { width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0), height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0), }; const parent = node.parentNode ? store.findNode(node.parentNode) : null; const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 }; const absolutePosition = parent ? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y } : (node.position || node.computedPosition); result.id = node.id; result.type = node.data.flowType; if (GROUP_TYPES.has(result.type)) { result.x = absolutePosition.x + size.width / 2; result.y = absolutePosition.y + size.height / 2; } else { const originalCenter = node.data.originalCenter || { x: absolutePosition.x + size.width / 2, y: absolutePosition.y + size.height / 2, }; const originalPosition = node.data.originalPosition || absolutePosition; result.x = originalCenter.x + absolutePosition.x - originalPosition.x; result.y = originalCenter.y + absolutePosition.y - originalPosition.y; } result.properties = clone(node.data.properties || {}); if (parent?.data?.flowType === "selectArea") delete result.properties.parentId; if (GROUP_TYPES.has(result.type)) { result.properties.width = size.width; result.properties.height = size.height; } if (node.data.children?.length || GROUP_TYPES.has(result.type)) { result.children = [...(node.data.children || [])]; } else { delete result.children; } return result; }; const backendEdge = (edge) => ({ ...clone(edge.data?.backend), id: edge.id, type: edge.data?.backend?.type || "bezier", sourceNodeId: edge.source, targetNodeId: edge.target, sourceAnchorId: edge.sourceHandle || `${edge.source}_1`, targetAnchorId: edge.targetHandle || `${edge.target}_3`, }); const emit = (name, payload) => { (handlers.get(name) || []).forEach((handler) => handler(payload)); }; const recordHistory = () => { if (historySuspended) return; const graph = facade.getGraphData(); const serialized = JSON.stringify(graph); if (history[historyIndex]?.serialized === serialized) return; history = history.slice(0, historyIndex + 1); history.push({ serialized, graph: clone(graph) }); if (history.length > 100) history.shift(); historyIndex = history.length - 1; }; const changed = () => { if (historySuspended) return; recordHistory(); emit("history:change", { data: facade.getGraphData() }); }; const getNodeRect = (node) => { const parent = node.parentNode ? store.findNode(node.parentNode) : null; const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 }; const position = parent ? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y } : (node.position || node.computedPosition); return { x: Number(position?.x || 0), y: Number(position?.y || 0), width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0), height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0), }; }; const getLoopLayoutState = (group) => { const current = getNodeRect(group); const existing = loopLayoutStates.get(group.id); if (!existing) { const state = { base: { ...current }, last: { ...current } }; loopLayoutStates.set(group.id, state); return state; } const sizeChanged = Math.abs(current.width - existing.last.width) > 1 || Math.abs(current.height - existing.last.height) > 1; if (sizeChanged) { existing.base = { ...current }; } else { const dx = current.x - existing.last.x; const dy = current.y - existing.last.y; existing.base.x += dx; existing.base.y += dy; } existing.last = { ...current }; return existing; }; const layoutLoop = (group, activeChildId, activePosition) => { const state = getLoopLayoutState(group); const children = getNodes().filter((node) => node.parentNode === group.id); if (!children.length) return false; const snapshots = children.map((child) => { const rect = getNodeRect(child); const position = child.id === activeChildId && activePosition ? { x: activePosition.x, y: activePosition.y } : { x: rect.x, y: rect.y }; return { child, position, width: rect.width, height: rect.height }; }); const contentLeft = Math.min(...snapshots.map(({ position }) => position.x)); const contentTop = Math.min(...snapshots.map(({ position }) => position.y)); const contentRight = Math.max(...snapshots.map(({ position, width }) => position.x + width)); const contentBottom = Math.max(...snapshots.map(({ position, height }) => position.y + height)); const left = Math.min(state.base.x, contentLeft - LOOP_BODY_INSET.left); const top = Math.min(state.base.y, contentTop - LOOP_BODY_INSET.top); const right = Math.max( state.base.x + Math.max(state.base.width, LOOP_MIN_SIZE.width), contentRight + LOOP_BODY_INSET.right, ); const bottom = Math.max( state.base.y + Math.max(state.base.height, LOOP_MIN_SIZE.height), contentBottom + LOOP_BODY_INSET.bottom, ); const width = right - left; const height = bottom - top; const properties = { ...group.data.properties, width, height }; store.updateNode(group.id, { position: { x: left, y: top }, computedPosition: { ...group.computedPosition, x: left, y: top }, dimensions: { width, height }, width, height, }); store.updateNodeData(group.id, { ...group.data, properties, size: { width, height }, }, { replace: true }); snapshots.forEach(({ child, position }) => { store.updateNode(child.id, { position: { x: position.x - left, y: position.y - top }, computedPosition: { ...child.computedPosition, x: position.x, y: position.y }, extent: loopChildExtent(), expandParent: false, }); }); state.last = { x: left, y: top, width, height }; nextTick(() => store.updateNodeInternals([group.id, ...children.map((child) => child.id)])); return true; }; const getAnchors = (node) => { const type = node.data.flowType; if (type === "branch") { return [ { id: `${node.id}_entry`, name: "left" }, ...(node.data.properties?.anchor || []).map((item) => ({ ...item, name: "right" })), ]; } if (TARGET_ONLY_TYPES.has(type)) return [{ id: `${node.id}_3`, name: "left" }]; if (SOURCE_ONLY_TYPES.has(type)) return [{ id: `${node.id}_1`, name: "right" }]; if (type === "selectArea") return []; return [ { id: `${node.id}_1`, name: "right" }, { id: `${node.id}_3`, name: "left" }, ]; }; const modelFor = (id) => { const node = store.findNode(id); if (!node) return null; const model = { id, type: node.data.flowType, get x() { return backendNode(node).x; }, get y() { return backendNode(node).y; }, get width() { return node.dimensions?.width || node.data.size?.width || 0; }, get height() { return node.dimensions?.height || node.data.size?.height || 0; }, get left() { return getNodeRect(node).x; }, get top() { return getNodeRect(node).y; }, get parentId() { return node.parentNode || null; }, get anchors() { return getAnchors(node); }, get children() { return new Set(node.data.children || []); }, set children(value) { const children = Array.from(value || []); store.updateNodeData(id, { ...node.data, children }, { replace: true }); changed(); }, get draggable() { return node.draggable !== false; }, set draggable(value) { store.updateNode(id, { draggable: Boolean(value) }); }, getProperties: () => facade.getProperties(id), setProperties: (properties) => facade.setProperties(id, properties), setComponentInstance: (instance) => componentInstances.set(id, instance), closePopover: () => componentInstances.get(id)?.closePopover?.(), updateSize: () => store.updateNodeInternals([id]), setCustomProperties: () => store.updateNodeInternals([id]), }; return model; }; const facade = { on(name, handler) { const list = handlers.get(name) || []; list.push(handler); handlers.set(name, list); return () => facade.off(name, handler); }, once(name, handler) { const wrapped = (payload) => { facade.off(name, wrapped); handler(payload); }; facade.on(name, wrapped); }, off(name, handler) { if (!handler) handlers.delete(name); else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler)); }, emit, markChanged: changed, getGraphData() { return { nodes: getNodes().map(backendNode), edges: getEdges().map(backendEdge), }; }, render(graph, options = {}) { historySuspended = true; loopLayoutStates.clear(); const nodeSizes = options.preserveNodeSizes === true ? new Map(getNodes().map((node) => [node.id, { width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0), height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0), }])) : options.nodeSizes; const nodePositions = options.preserveNodePositions === true ? new Map(getNodes().map((node) => { const rect = getNodeRect(node); return [node.id, { x: rect.x, y: rect.y }]; })) : options.nodePositions; const data = toVueFlowData(graph, { nodeSizes, nodePositions }); const finishRender = () => { historySuspended = false; if (options.resetHistory !== false) { history = []; historyIndex = -1; recordHistory(); } nextTick(() => store.updateNodeInternals()); }; const applyData = () => { store.setNodes(data.nodes); store.setEdges(data.edges); finishRender(); }; if (options.replaceNodes === true) { // Changing parentNode is structural in Vue Flow. Unmount in one tick // and remount in the next so node wrappers and edges cannot retain // different graph-node instances for the same id. store.setEdges([]); store.setNodes([]); nextTick(applyData); } else { applyData(); } }, addNode(node) { const vueNode = toVueNode(node); store.addNodes(vueNode); changed(); return backendNode(vueNode); }, deleteNode(id) { const target = store.findNode(id); if (target?.data?.flowType === "selectArea") { facade.ungroup(id); return; } const parentId = target?.parentNode; const childIds = target?.data?.children || []; const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0; const nodeIds = removeChildren ? [id, ...childIds] : id; const removedIds = new Set(Array.isArray(nodeIds) ? nodeIds : [nodeIds]); store.removeNodes(nodeIds, true, removeChildren); getNodes().forEach((node) => { if (node.data.children?.includes(id) && !removedIds.has(node.id)) { store.updateNodeData(node.id, { ...node.data, children: node.data.children.filter((childId) => childId !== id), }, { replace: true }); } }); componentInstances.delete(id); childIds.forEach((childId) => componentInstances.delete(childId)); changed(); if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId)); }, addEdge(edge) { const vueEdge = toVueEdge(edge); const source = store.findNode(vueEdge.source); const target = store.findNode(vueEdge.target); if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4; store.addEdges(vueEdge); changed(); return backendEdge(vueEdge); }, deleteEdge(id) { store.removeEdges(id); changed(); }, deleteEdgeByNodeId(id) { store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id)); changed(); }, getProperties(id) { return clone(store.findNode(id)?.data?.properties || {}); }, setProperties(id, properties) { const node = store.findNode(id); if (!node) return; store.updateNodeData(id, { ...node.data, properties: clone(properties) }, { replace: true }); nextTick(() => store.updateNodeInternals([id])); changed(); }, getNodeModelById: modelFor, getSelectElements() { return { nodes: (store.getSelectedNodes.value || []).map(backendNode), edges: (store.getSelectedEdges.value || []).map(backendEdge), }; }, clearSelectElements() { store.removeSelectedElements(); }, selectElementById(id) { store.removeSelectedElements(); if (!id) return; const node = store.findNode(id); if (node) store.addSelectedNodes([node]); const edge = store.findEdge(id); if (edge) store.addSelectedEdges([edge]); }, selectElementsByIds(ids = []) { store.removeSelectedElements(); const nodes = ids.map((id) => store.findNode(id)).filter(Boolean); if (nodes.length) store.addSelectedNodes(nodes); }, focusOn({ id }) { const node = store.findNode(id); if (!node) return; const center = backendNode(node); store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 }); }, fitView() { return store.fitView({ padding: 0.18, duration: 300 }); }, translateCenter() { return facade.fitView(); }, zoom(scale, center) { if (Array.isArray(center) && center.length === 2) { return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 }); } return store.zoomTo(scale, { duration: 250 }); }, getPointByClient(x, y) { const point = store.screenToFlowCoordinate({ x, y }); return { canvasOverlayPosition: point, domOverlayPosition: point }; }, addToGroup(groupId, childId, dropCenter) { const group = store.findNode(groupId); const child = store.findNode(childId); if (!group || !child || GROUP_TYPES.has(child.data.flowType)) return; const graph = facade.getGraphData(); const groupData = graph.nodes.find((node) => node.id === groupId); const childData = graph.nodes.find((node) => node.id === childId); if (!groupData || !childData) return; groupData.children = Array.from(new Set([...(groupData.children || []), childId])); childData.properties = { ...(childData.properties || {}), parentId: groupId }; if (dropCenter) { childData.x = dropCenter.x; childData.y = dropCenter.y; } facade.render(graph, { resetHistory: false, replaceNodes: true, preserveNodeSizes: true, preserveNodePositions: true, }); nextTick(() => { store.updateNodeInternals([groupId, childId]); changed(); }); }, groupNodes(groupNode, childIds = []) { const graph = facade.getGraphData(); const selectedIds = new Set(childIds); const children = graph.nodes.filter((node) => ( selectedIds.has(node.id) && !GROUP_TYPES.has(node.type) && !store.findNode(node.id)?.parentNode )); if (!children.length) return null; const group = clone(groupNode); group.id ||= nodeId(); group.type = "selectArea"; group.properties = { ...(group.properties || {}), action: "NONE" }; group.children = children.map((node) => node.id); children.forEach((node) => { node.properties = { ...(node.properties || {}), parentId: group.id }; }); graph.nodes.push(group); facade.render(graph, { resetHistory: false, replaceNodes: true, preserveNodeSizes: true, preserveNodePositions: true, }); nextTick(() => { store.updateNodeInternals([group.id, ...group.children]); changed(); }); return clone(group); }, ungroup(groupId) { const graph = facade.getGraphData(); const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea"); if (!group) return false; const childIds = new Set([ ...(group.children || []), ...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id), ]); graph.nodes = graph.nodes.filter((node) => node.id !== groupId); graph.nodes.forEach((node) => { if (!childIds.has(node.id)) return; const properties = { ...(node.properties || {}) }; delete properties.parentId; node.properties = properties; }); facade.render(graph, { resetHistory: false, replaceNodes: true, preserveNodeSizes: true, preserveNodePositions: true, }); nextTick(() => { store.updateNodeInternals([...childIds]); changed(); }); return true; }, prepareLoopChildDrag(childId) { const child = store.findNode(childId); const group = child?.parentNode ? store.findNode(child.parentNode) : null; if (!group || group.data.flowType !== "loop") return; store.updateNode(childId, { extent: loopChildExtent(), expandParent: false, }); getLoopLayoutState(group); const rect = getNodeRect(child); return { groupId: group.id, position: { x: rect.x, y: rect.y } }; }, layoutLoopDuringDrag(groupId, childId, position) { const group = store.findNode(groupId); if (!group || group.data.flowType !== "loop" || !position) return; layoutLoop(group, childId, position); }, resizeGroupToChildren(groupId, options = {}) { const group = store.findNode(groupId); if (!group || group.data.flowType !== "loop") return; if (layoutLoop(group) && options.markChanged !== false) changed(); }, moveNodeBy(id, dx, dy) { const node = store.findNode(id); if (!node) return; store.updateNode(id, { position: { x: node.position.x + dx, y: node.position.y + dy, }, }); changed(); }, undo() { if (historyIndex <= 0) return false; historyIndex -= 1; const snapshot = clone(history[historyIndex].graph); facade.render(snapshot, { resetHistory: false, replaceNodes: true }); nextTick(() => emit("history:change", { data: facade.getGraphData() })); return true; }, redo() { if (historyIndex >= history.length - 1) return false; historyIndex += 1; const snapshot = clone(history[historyIndex].graph); facade.render(snapshot, { resetHistory: false, replaceNodes: true }); nextTick(() => emit("history:change", { data: facade.getGraphData() })); return true; }, registerComponentInstance(id, instance) { if (instance) componentInstances.set(id, instance); }, extension: { selectionSelect: { openSelectionSelect: () => emit("selection:open"), closeSelectionSelect: () => emit("selection:close"), }, }, }; return facade; };