@dou-ui/flow 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/dist/collaboration/index.js +360 -0
  4. package/dist/collaboration/index.js.map +1 -0
  5. package/dist/collaboration/peers.js +76 -0
  6. package/dist/collaboration/peers.js.map +1 -0
  7. package/dist/components/default-node.js +6 -0
  8. package/dist/components/default-node.js.map +1 -0
  9. package/dist/components/default-node.vue_vue_type_script_setup_true_lang.js +87 -0
  10. package/dist/components/default-node.vue_vue_type_script_setup_true_lang.js.map +1 -0
  11. package/dist/components/default-node2.js +116 -0
  12. package/dist/components/default-node2.js.map +1 -0
  13. package/dist/components/flow-controls.js +6 -0
  14. package/dist/components/flow-controls.js.map +1 -0
  15. package/dist/components/flow-controls.vue_vue_type_script_setup_true_lang.js +148 -0
  16. package/dist/components/flow-controls.vue_vue_type_script_setup_true_lang.js.map +1 -0
  17. package/dist/components/flow-menu.js +6 -0
  18. package/dist/components/flow-menu.js.map +1 -0
  19. package/dist/components/flow-menu.vue_vue_type_script_setup_true_lang.js +52 -0
  20. package/dist/components/flow-menu.vue_vue_type_script_setup_true_lang.js.map +1 -0
  21. package/dist/components/flow-minimap.js +6 -0
  22. package/dist/components/flow-minimap.js.map +1 -0
  23. package/dist/components/flow-minimap.vue_vue_type_script_setup_true_lang.js +238 -0
  24. package/dist/components/flow-minimap.vue_vue_type_script_setup_true_lang.js.map +1 -0
  25. package/dist/components/flow-port.js +6 -0
  26. package/dist/components/flow-port.js.map +1 -0
  27. package/dist/components/flow-port.vue_vue_type_script_setup_true_lang.js +57 -0
  28. package/dist/components/flow-port.vue_vue_type_script_setup_true_lang.js.map +1 -0
  29. package/dist/components/flow-quick-add.js +6 -0
  30. package/dist/components/flow-quick-add.js.map +1 -0
  31. package/dist/components/flow-quick-add.vue_vue_type_script_setup_true_lang.js +126 -0
  32. package/dist/components/flow-quick-add.vue_vue_type_script_setup_true_lang.js.map +1 -0
  33. package/dist/components/flow-toolbar.js +6 -0
  34. package/dist/components/flow-toolbar.js.map +1 -0
  35. package/dist/components/flow-toolbar.vue_vue_type_script_setup_true_lang.js +161 -0
  36. package/dist/components/flow-toolbar.vue_vue_type_script_setup_true_lang.js.map +1 -0
  37. package/dist/components/group-node.js +6 -0
  38. package/dist/components/group-node.js.map +1 -0
  39. package/dist/components/group-node.vue_vue_type_script_setup_true_lang.js +84 -0
  40. package/dist/components/group-node.vue_vue_type_script_setup_true_lang.js.map +1 -0
  41. package/dist/components/group-node2.js +42 -0
  42. package/dist/components/group-node2.js.map +1 -0
  43. package/dist/components/node-view.js +6 -0
  44. package/dist/components/node-view.js.map +1 -0
  45. package/dist/components/node-view.vue_vue_type_script_setup_true_lang.js +179 -0
  46. package/dist/components/node-view.vue_vue_type_script_setup_true_lang.js.map +1 -0
  47. package/dist/context.js +13 -0
  48. package/dist/context.js.map +1 -0
  49. package/dist/core/clipboard.js +37 -0
  50. package/dist/core/clipboard.js.map +1 -0
  51. package/dist/core/connect.js +74 -0
  52. package/dist/core/connect.js.map +1 -0
  53. package/dist/core/edge-types.js +240 -0
  54. package/dist/core/edge-types.js.map +1 -0
  55. package/dist/core/extension.js +63 -0
  56. package/dist/core/extension.js.map +1 -0
  57. package/dist/core/geometry.js +232 -0
  58. package/dist/core/geometry.js.map +1 -0
  59. package/dist/core/groups.js +76 -0
  60. package/dist/core/groups.js.map +1 -0
  61. package/dist/core/history.js +79 -0
  62. package/dist/core/history.js.map +1 -0
  63. package/dist/core/model.js +21 -0
  64. package/dist/core/model.js.map +1 -0
  65. package/dist/core/node-types.js +32 -0
  66. package/dist/core/node-types.js.map +1 -0
  67. package/dist/core/order.js +42 -0
  68. package/dist/core/order.js.map +1 -0
  69. package/dist/core/transaction.js +288 -0
  70. package/dist/core/transaction.js.map +1 -0
  71. package/dist/document.js +220 -0
  72. package/dist/document.js.map +1 -0
  73. package/dist/export/color.js +38 -0
  74. package/dist/export/color.js.map +1 -0
  75. package/dist/export/context.js +223 -0
  76. package/dist/export/context.js.map +1 -0
  77. package/dist/export/draw.js +249 -0
  78. package/dist/export/draw.js.map +1 -0
  79. package/dist/flow-editor.js +6 -0
  80. package/dist/flow-editor.js.map +1 -0
  81. package/dist/flow-editor.vue_vue_type_script_setup_true_lang.js +3721 -0
  82. package/dist/flow-editor.vue_vue_type_script_setup_true_lang.js.map +1 -0
  83. package/dist/index.js +31 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/kit.js +234 -0
  86. package/dist/kit.js.map +1 -0
  87. package/dist/layers.js +150 -0
  88. package/dist/layers.js.map +1 -0
  89. package/dist/locale.js +426 -0
  90. package/dist/locale.js.map +1 -0
  91. package/dist/style.css +1 -0
  92. package/dist/style.d.ts +1 -0
  93. package/dist/style.js +8 -0
  94. package/dist/types/collaboration/index.d.ts +42 -0
  95. package/dist/types/collaboration/peers.d.ts +27 -0
  96. package/dist/types/components/default-node.d.ts +18 -0
  97. package/dist/types/components/default-node.vue.d.ts +6 -0
  98. package/dist/types/components/flow-controls.vue.d.ts +8 -0
  99. package/dist/types/components/flow-menu.vue.d.ts +29 -0
  100. package/dist/types/components/flow-minimap.vue.d.ts +8 -0
  101. package/dist/types/components/flow-port.vue.d.ts +9 -0
  102. package/dist/types/components/flow-quick-add.vue.d.ts +31 -0
  103. package/dist/types/components/flow-toolbar.vue.d.ts +25 -0
  104. package/dist/types/components/group-node.d.ts +13 -0
  105. package/dist/types/components/group-node.vue.d.ts +6 -0
  106. package/dist/types/components/node-view.vue.d.ts +28 -0
  107. package/dist/types/context.d.ts +362 -0
  108. package/dist/types/core/clipboard.d.ts +9 -0
  109. package/dist/types/core/connect.d.ts +63 -0
  110. package/dist/types/core/edge-types.d.ts +50 -0
  111. package/dist/types/core/extension.d.ts +141 -0
  112. package/dist/types/core/geometry.d.ts +63 -0
  113. package/dist/types/core/groups.d.ts +23 -0
  114. package/dist/types/core/history.d.ts +24 -0
  115. package/dist/types/core/menu.d.ts +40 -0
  116. package/dist/types/core/model.d.ts +87 -0
  117. package/dist/types/core/node-types.d.ts +103 -0
  118. package/dist/types/core/order.d.ts +7 -0
  119. package/dist/types/core/transaction.d.ts +97 -0
  120. package/dist/types/document.d.ts +57 -0
  121. package/dist/types/export/color.d.ts +7 -0
  122. package/dist/types/export/context.d.ts +90 -0
  123. package/dist/types/export/draw.d.ts +72 -0
  124. package/dist/types/flow-editor.vue.d.ts +63 -0
  125. package/dist/types/index.d.ts +202 -0
  126. package/dist/types/kit.d.ts +22 -0
  127. package/dist/types/layers.d.ts +24 -0
  128. package/dist/types/locale.d.ts +172 -0
  129. package/dist/types/whiteboard/board-draft.vue.d.ts +3 -0
  130. package/dist/types/whiteboard/board-node.vue.d.ts +19 -0
  131. package/dist/types/whiteboard/connector.d.ts +12 -0
  132. package/dist/types/whiteboard/draw.d.ts +5 -0
  133. package/dist/types/whiteboard/image.d.ts +27 -0
  134. package/dist/types/whiteboard/index.d.ts +6 -0
  135. package/dist/types/whiteboard/ink.d.ts +30 -0
  136. package/dist/types/whiteboard/kit.d.ts +36 -0
  137. package/dist/types/whiteboard/model.d.ts +79 -0
  138. package/dist/types/whiteboard/pen.d.ts +7 -0
  139. package/dist/types/whiteboard/state.d.ts +37 -0
  140. package/dist/types/whiteboard/transform.d.ts +67 -0
  141. package/dist/types/whiteboard/whiteboard-toolbar.vue.d.ts +3 -0
  142. package/dist/whiteboard/board-draft.js +6 -0
  143. package/dist/whiteboard/board-draft.js.map +1 -0
  144. package/dist/whiteboard/board-draft.vue_vue_type_script_setup_true_lang.js +58 -0
  145. package/dist/whiteboard/board-draft.vue_vue_type_script_setup_true_lang.js.map +1 -0
  146. package/dist/whiteboard/board-node.js +6 -0
  147. package/dist/whiteboard/board-node.js.map +1 -0
  148. package/dist/whiteboard/board-node.vue_vue_type_script_setup_true_lang.js +332 -0
  149. package/dist/whiteboard/board-node.vue_vue_type_script_setup_true_lang.js.map +1 -0
  150. package/dist/whiteboard/connector.js +180 -0
  151. package/dist/whiteboard/connector.js.map +1 -0
  152. package/dist/whiteboard/draw.js +68 -0
  153. package/dist/whiteboard/draw.js.map +1 -0
  154. package/dist/whiteboard/image.js +104 -0
  155. package/dist/whiteboard/image.js.map +1 -0
  156. package/dist/whiteboard/index.js +5 -0
  157. package/dist/whiteboard/ink.js +168 -0
  158. package/dist/whiteboard/ink.js.map +1 -0
  159. package/dist/whiteboard/kit.js +406 -0
  160. package/dist/whiteboard/kit.js.map +1 -0
  161. package/dist/whiteboard/model.js +52 -0
  162. package/dist/whiteboard/model.js.map +1 -0
  163. package/dist/whiteboard/pen.js +88 -0
  164. package/dist/whiteboard/pen.js.map +1 -0
  165. package/dist/whiteboard/state.js +26 -0
  166. package/dist/whiteboard/state.js.map +1 -0
  167. package/dist/whiteboard/transform.js +256 -0
  168. package/dist/whiteboard/transform.js.map +1 -0
  169. package/dist/whiteboard/whiteboard-toolbar.js +6 -0
  170. package/dist/whiteboard/whiteboard-toolbar.js.map +1 -0
  171. package/dist/whiteboard/whiteboard-toolbar.vue_vue_type_script_setup_true_lang.js +171 -0
  172. package/dist/whiteboard/whiteboard-toolbar.vue_vue_type_script_setup_true_lang.js.map +1 -0
  173. package/package.json +74 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flow-editor.js","names":["_sfc_main"],"sources":["../src/flow-editor.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ContextMenuItem } from 'dou-ui'\nimport {\n DouFindReplace,\n DouToggle,\n DouTooltip,\n fitPixelRatio,\n holdPointer,\n layoutGraph,\n matchesHotkey,\n svgToPNG,\n useLocale,\n useReducedMotion,\n type Tone,\n} from 'dou-ui'\nimport { LetterCaseIcon } from '@dou-ui/icons'\nimport {\n computed,\n customRef,\n nextTick,\n onBeforeUnmount,\n onMounted,\n provide,\n ref,\n shallowReactive,\n shallowRef,\n toRaw,\n useTemplateRef,\n watch,\n} from 'vue'\nimport type { CSSProperties } from 'vue'\nimport NodeView from './components/node-view.vue'\nimport FlowMenu from './components/flow-menu.vue'\nimport FlowQuickAdd from './components/flow-quick-add.vue'\nimport FlowToolbar from './components/flow-toolbar.vue'\nimport { defaultNode } from './components/default-node'\nimport {\n flowEditorKey,\n type FlowEditorContext,\n type FlowEditorEmits,\n type FlowEditorExpose,\n type FlowEditorProps,\n type FlowEditorSlots,\n type FlowInsertOptions,\n type FlowNodePreview,\n type FlowSearchQuery,\n type NodeMetrics,\n type PortAnchor,\n type PortState,\n} from './context'\nimport { bezierEdge, type EdgeAnchor, type EdgeRoute, type EdgeTypeSpec } from './core/edge-types'\nimport {\n boundsOf,\n clampPan,\n intersects,\n intersectsTurned,\n rectFrom,\n snapBox,\n type Guide,\n type SnapLink,\n} from './core/geometry'\nimport {\n canJoin,\n displacedEdges,\n joinRefusal,\n primaryType,\n typesMatch,\n type FlowConnection,\n type JoinRefusal,\n type PortTypeSpec,\n} from './core/connect'\nimport { FlowHistory } from './core/history'\nimport { flowOrder } from './core/order'\nimport {\n resolveFlowExtensions,\n type FlowHistoryHandler,\n type FlowPressHandler,\n} from './core/extension'\nimport { flowKit } from './kit'\nimport type { FlowMenuTarget, FlowToolbarItem } from './core/menu'\nimport { readClipboard, writeClipboard } from './core/clipboard'\nimport {\n createId,\n emptyDocument,\n emptySelection,\n type FlowDocument,\n type FlowEdge,\n type FlowEndpoint,\n type FlowNode,\n type FlowPortSpec,\n type FlowSelection,\n type FlowViewport,\n type Point,\n type Rect,\n} from './core/model'\nimport { readDocument } from './document'\nimport { containerAt, FlowTree } from './core/groups'\nimport { labelOf, nodeBounds, portsOf, resizeOf, type NodeTypeSpec } from './core/node-types'\nimport { FlowTransaction, type FlowTransactionMeta } from './core/transaction'\nimport { format, useFlowMessages } from './locale'\nimport { drawTools, type DrawTools, type FlowDrawContext } from './export/context'\nimport {\n drawFlow,\n pathPoints,\n readImage,\n type DrawEdge,\n type DrawNode,\n type FlowExportOptions,\n type FlowImageOptions,\n} from './export/draw'\n\ndefineOptions({ name: 'DouFlowEditor' })\n\nconst props = withDefaults(defineProps<FlowEditorProps>(), {\n grid: 8,\n guides: true,\n minZoom: 0.1,\n maxZoom: 4,\n fitView: false,\n direction: 'horizontal',\n fitMinZoom: 0.5,\n readonly: false,\n searchable: true,\n panOnScroll: true,\n edgeType: undefined,\n simpleZoom: 0.4,\n})\nconst model = defineModel<FlowDocument>({ default: emptyDocument })\n/** The `v-model`, with a wrongly shaped one read as well as it can be. */\nconst doc = computed({\n get: () => readDocument(model.value),\n set: (value: FlowDocument) => (model.value = value),\n})\nconst selection = defineModel<FlowSelection>('selection', { default: emptySelection })\nconst viewport = defineModel<FlowViewport>('viewport', {\n default: () => ({ x: 0, y: 0, zoom: 1 }),\n})\nconst emit = defineEmits<FlowEditorEmits>()\ndefineSlots<FlowEditorSlots>()\n\nconst messages = useFlowMessages(() => props.messages)\nconst { t: common } = useLocale()\nconst reducedMotion = useReducedMotion()\nconst root = useTemplateRef<HTMLElement>('root')\n\n/** Movement in screen px before a press becomes a drag. */\nconst THRESHOLD = { mouse: 3, touch: 6 }\nconst LONG_PRESS = 500\n/** How long a finger rests on a palette item before it picks the node up, in ms. */\nconst PICK_UP = 300\n/** How close to the edge a drag starts scrolling the canvas, in px. */\nconst EDGE = 24\n/** Auto-scroll speed in px per frame, at `d` px from the edge. */\nconst edgeSpeed = (d: number) => (d < EDGE ? Math.min(1, (EDGE - d) / EDGE) * 12 : 0)\n\n/** The axis that levels two ports on this side. */\nconst level = (side: string) => (side === 'left' || side === 'right' ? 'y' : 'x')\n\nconst same = (a: string[], b: string[]) => a.length === b.length && a.every((id, i) => id === b[i])\n\n// Node types --------------------------------------------------------------------\n\nconst resolved = computed(() => resolveFlowExtensions(props.extensions ?? flowKit()))\n\n// Types given as props add to the extensions' and win over them.\nconst types = computed(() => {\n const map = new Map(resolved.value.nodeTypes)\n for (const spec of props.nodeTypes ?? []) map.set(spec.type, spec as NodeTypeSpec)\n return map\n})\nconst portTypes = computed(() => ({ ...resolved.value.portTypes, ...props.portTypes }))\nconst edgeTypes = computed(() => {\n const map = new Map(resolved.value.edgeTypes)\n for (const spec of props.edgeTypes ?? []) map.set(spec.type, spec as EdgeTypeSpec)\n return map\n})\n/** The type of edges without one: the prop, else the extensions', else bezier. */\nconst defaultEdgeType = computed(() => props.edgeType ?? resolved.value.edgeType ?? 'bezier')\nconst edgeTypeOf = (type?: string) =>\n edgeTypes.value.get(type ?? defaultEdgeType.value) ??\n edgeTypes.value.get(defaultEdgeType.value) ??\n bezierEdge\nconst specOf = (node: FlowNode) =>\n types.value.get(node.type ?? 'default') ??\n types.value.get('default') ??\n (defaultNode as NodeTypeSpec)\n\n/** Each node's ports by id, worked out once per change rather than per edge. */\nconst portsByNode = computed(() => {\n const map = new Map<string, Map<string, FlowPortSpec>>()\n for (const node of nodeById.value.values())\n map.set(node.id, new Map(portsOf(specOf(node), node).map((port) => [port.id, port])))\n return map\n})\n\nfunction portSpec(end: FlowEndpoint) {\n if (end.port == null) return undefined\n return portsByNode.value.get(end.node)?.get(end.port)\n}\n\nconst portTypeOf = (port: FlowPortSpec): PortTypeSpec | undefined => {\n const type = primaryType(port)\n return type ? portTypes.value[type] : undefined\n}\n\nconst tones = new Set<string>([\n 'neutral',\n 'primary',\n 'success',\n 'warning',\n 'danger',\n] satisfies Tone[])\n/** A port type's color as CSS: theme tones by name, anything else as is. */\nconst colorOf = (spec: PortTypeSpec | undefined) =>\n spec?.color ? (tones.has(spec.color) ? `var(--dou-color-${spec.color})` : spec.color) : undefined\n\n// Document and history ------------------------------------------------------------\n\nconst history = new FlowHistory()\n/** An extension's history, used instead of the editor's own. */\nlet customHistory: FlowHistoryHandler | null = null\nconst canUndo = ref(false)\nconst canRedo = ref(false)\nconst syncHistory = () => {\n canUndo.value = customHistory ? customHistory.canUndo() : history.canUndo\n canRedo.value = customHistory ? customHistory.canRedo() : history.canRedo\n}\n\nfunction setSelection(next: FlowSelection) {\n const current = selection.value\n if (same(current.nodes, next.nodes) && same(current.edges, next.edges)) return\n selection.value = next\n}\n\n/** Drops selected ids that are no longer in the document. */\nfunction pruneSelection(next: FlowDocument, sel = selection.value): FlowSelection {\n const nodes = new Set(next.nodes.map((n) => n.id))\n const edges = new Set(next.edges.map((e) => e.id))\n return {\n nodes: sel.nodes.filter((id) => nodes.has(id)),\n edges: sel.edges.filter((id) => edges.has(id)),\n }\n}\n\n/*\n * A bound v-model only catches up when the parent renders, so changes made\n * in the same tick (a drag frame, then a change from another person) build\n * on the latest commit instead.\n */\nlet latest: FlowDocument | null = null\nconst docOf = (): FlowDocument => latest ?? toRaw(doc.value)\n/** The document as extensions see it: the latest commit, even before `v-model` catches up. */\nconst currentDoc = customRef<FlowDocument>(() => ({\n get: () => {\n const value = doc.value\n return latest ?? value\n },\n set: (value) => (doc.value = value),\n}))\n\n/** Runs the extensions' `transaction` hooks; `false` when one cancelled the change. */\nfunction check(tr: FlowTransaction) {\n for (const hook of resolved.value.transactions) {\n hook(tr, context)\n if (tr.cancelled) {\n emit('transactionCancel', tr)\n refuse(tr.cancelReason ?? messages.value.changeCancelled)\n return false\n }\n }\n return true\n}\n\nfunction commit(tr: FlowTransaction) {\n if (!check(tr)) return false\n if (!latest) void nextTick(() => (latest = null))\n latest = tr.doc\n doc.value = tr.doc\n const removes = tr.steps.some((s) => s.type === 'removeNode' || s.type === 'removeEdge')\n if (removes) setSelection(pruneSelection(tr.doc))\n emit('transaction', tr)\n return true\n}\n\nfunction transact(fn: (tr: FlowTransaction) => void, meta?: FlowTransactionMeta) {\n const tr = new FlowTransaction(docOf())\n if (meta) tr.setMeta(meta)\n fn(tr)\n if (!tr.changed) return tr\n const before = selection.value\n if (!commit(tr)) return tr\n if (!customHistory) history.record(tr, before, selection.value)\n syncHistory()\n return tr\n}\n\nfunction undo() {\n if (customHistory) return travel(customHistory.undo, messages.value.undone)\n const result = history.undo(docOf())\n if (!result) return false\n result.tr.setMeta({ origin: 'undo', history: false })\n commit(result.tr)\n setSelection(pruneSelection(result.tr.doc, result.selection))\n syncHistory()\n announce(messages.value.undone)\n return true\n}\n\n/** Undo or redo through an extension's history. */\nfunction travel(fn: () => boolean, done: string) {\n if (!fn()) return false\n syncHistory()\n announce(done)\n return true\n}\n\nfunction redo() {\n if (customHistory) return travel(customHistory.redo, messages.value.redone)\n const result = history.redo(docOf())\n if (!result) return false\n result.tr.setMeta({ origin: 'redo', history: false })\n commit(result.tr)\n setSelection(pruneSelection(result.tr.doc, result.selection))\n syncHistory()\n announce(messages.value.redone)\n return true\n}\n\n// Measuring ----------------------------------------------------------------------\n\nconst metrics = shallowReactive(new Map<string, NodeMetrics>())\n/** Where nodes are drawn while tidying animates them; their real place is already in `doc`. */\nconst tween = shallowRef<Map<string, Point> | null>(null)\n/** Nodes shown changed while a handle is dragged, see `previewNode`; the document changes when it is let go. */\nconst previews = shallowRef<ReadonlyMap<string, FlowNodePreview>>(new Map())\nconst shownNodes = computed(() => {\n const moving = tween.value\n const changed = previews.value\n if (!moving && !changed.size) return doc.value.nodes\n return doc.value.nodes.map((node) => {\n const at = moving?.get(node.id)\n const patch = changed.get(node.id)\n if (!at && !patch) return node\n return { ...node, ...(at && { x: at.x, y: at.y }), ...patch }\n })\n})\n\nfunction previewNode(id: string, patch: FlowNodePreview | null) {\n const next = new Map(previews.value)\n if (patch) next.set(id, patch)\n else if (!next.delete(id)) return\n previews.value = next\n}\nconst nodeById = computed(() => new Map(shownNodes.value.map((node) => [node.id, node])))\n\n// Containers ----------------------------------------------------------------------\n\nconst tree = computed(() => new FlowTree(doc.value.nodes))\nconst isContainer = (node: FlowNode) => !!specOf(node).container\n/** How far a node is drawn turned, as its type says, in degrees. */\nfunction nodeRotation(id: string) {\n const node = nodeById.value.get(id)\n return (node && specOf(node).rotation?.(node)) || 0\n}\n\n/** For each node inside a collapsed container: the outermost collapsed one, where its edges end. */\nconst hiddenIn = computed(() => {\n const map = new Map<string, string>()\n for (const node of doc.value.nodes) {\n if (!node.data?.collapsed || !isContainer(node)) continue\n for (const id of tree.value.descendants(node.id)) {\n let outer: string | undefined\n for (const a of tree.value.ancestors(id))\n if (nodeById.value.get(a)?.data?.collapsed) outer = a\n if (outer) map.set(id, outer)\n }\n }\n return map\n})\n\n// Nodes folded away can't stay selected.\nwatch(hiddenIn, (hidden) => {\n const { nodes, edges } = selection.value\n if (nodes.some((id) => hidden.has(id)))\n setSelection({ nodes: nodes.filter((id) => !hidden.has(id)), edges })\n})\n\n/** Containers with a size, innermost last; `skip` leaves some out, e.g. those being dragged. */\nfunction containerRects(skip?: Set<string>) {\n return doc.value.nodes.flatMap((node) =>\n isContainer(node) && !hiddenIn.value.has(node.id) && !skip?.has(node.id) && metrics.has(node.id)\n ? [{ id: node.id, rect: rectOf(node) }]\n : [],\n )\n}\n\n/** Two measurements of a node that place it and its ports the same. */\nfunction sameMetrics(a: NodeMetrics | undefined, b: NodeMetrics) {\n if (!a || a.width !== b.width || a.height !== b.height || a.ports.size !== b.ports.size)\n return false\n for (const [id, p] of b.ports) {\n const q = a.ports.get(id)\n if (!q || q.x !== p.x || q.y !== p.y || q.side !== p.side) return false\n }\n return true\n}\n\nfunction onMeasure(id: string, value: NodeMetrics) {\n // A node drawn again after scrolling back into view measures the same.\n if (!sameMetrics(metrics.get(id), value)) metrics.set(id, value)\n const wait = pending.get(id)\n if (!wait) return\n pending.delete(id)\n // Now that the node has a size: its centre, or the port taking the edge, goes on the point.\n const port = wait.port ? value.ports.get(wait.port) : undefined\n const vertical = props.direction === 'vertical'\n // `start`: the point is where the node begins along the flow, centred across it.\n const along = (v: number, size: number, flow: boolean) => (wait.start && flow ? v : v - size / 2)\n let x = snapTo(port ? wait.at.x - port.x : along(wait.at.x, value.width, !vertical))\n let y = snapTo(port ? wait.at.y - port.y : along(wait.at.y, value.height, vertical))\n if (wait.avoid) {\n // Step across the flow until the node covers no other.\n const others = doc.value.nodes.flatMap((n) => (n.id === id ? [] : (getNodeRect(n.id) ?? [])))\n const step = snapTo((vertical ? value.width : value.height) + 16)\n const free = (px: number, py: number) =>\n !others.some((r) =>\n intersects({ x: px - 8, y: py - 8, width: value.width + 16, height: value.height + 16 }, r),\n )\n for (let i = 0; i < 40 && !free(x, y); i++) {\n if (vertical) x += step\n else y += step\n }\n }\n const node = doc.value.nodes.find((n) => n.id === id)\n if (node && (node.x !== x || node.y !== y))\n transact((tr) => tr.moveNode(id, x, y), { group: wait.group })\n void nextTick(() => {\n // A node added near the edge is scrolled into view, and takes focus so keys act on it.\n reveal(id)\n if (wait.focus)\n root.value\n ?.querySelector<HTMLElement>(`[data-node-id=\"${CSS.escape(id)}\"]`)\n ?.focus({ preventScroll: true })\n })\n}\n\n/** Nodes just added, waiting for their size to be placed: the point and the port to put on it. */\nconst pending = new Map<\n string,\n { at: Point; port?: string; group: string; start?: boolean; avoid?: boolean; focus?: boolean }\n>()\nconst snapTo = (v: number) => (props.grid ? Math.round(v / props.grid) * props.grid : Math.round(v))\n\n// Forget nodes that are gone.\nwatch(nodeById, (nodes) => {\n for (const id of metrics.keys()) if (!nodes.has(id)) metrics.delete(id)\n // a preview of a node that is gone, e.g. removed by a peer, is dropped with it\n const kept = [...previews.value].filter(([id]) => nodes.has(id))\n if (kept.length < previews.value.size) previews.value = new Map(kept)\n})\n\nfunction rectOf(node: FlowNode): Rect {\n const m = metrics.get(node.id)\n return {\n x: node.x,\n y: node.y,\n width: m?.width ?? node.width ?? 0,\n height: m?.height ?? node.height ?? 0,\n }\n}\n\nfunction getNodeRect(id: string) {\n const node = nodeById.value.get(id)\n return node ? rectOf(node) : null\n}\n\n// Edges ----------------------------------------------------------------------------\n\nfunction anchor(node: FlowNode, port: string | undefined, role: 'source' | 'target') {\n const m = metrics.get(node.id)\n if (!m) return null\n const at: PortAnchor | undefined = port ? m.ports.get(port) : undefined\n if (at) return { x: node.x + at.x, y: node.y + at.y, side: at.side }\n return role === 'source'\n ? { x: node.x + m.width, y: node.y + m.height / 2, side: 'right' as const }\n : { x: node.x, y: node.y + m.height / 2, side: 'left' as const }\n}\n\nfunction anchorOf(end: FlowEdge['source'], role: 'source' | 'target') {\n const node = nodeById.value.get(end.node)\n return node ? anchor(node, end.port, role) : null\n}\n\n/** Where an edge ends: on the collapsed container hiding its node, if any. */\nfunction edgeEnd(end: FlowEdge['source']): FlowEdge['source'] {\n const outer = hiddenIn.value.get(end.node)\n return outer ? { node: outer } : end\n}\n\n/**\n * Edges whose ports no longer fit: a port is gone, or its type changed so\n * the two ends don't match any more, e.g. after a form in a node changed it.\n */\nconst staleEdges = computed(() => {\n const out = new Set<string>()\n for (const edge of doc.value.edges) {\n const { source, target } = edge\n if (source.port == null && target.port == null) continue\n const a = source.port == null ? undefined : portSpec(source)\n const b = target.port == null ? undefined : portSpec(target)\n if ((source.port != null && !a) || (target.port != null && !b)) out.add(edge.id)\n else if (\n a &&\n b &&\n (a.kind !== 'output' || b.kind !== 'input' || !typesMatch(a, b, portTypes.value))\n )\n out.add(edge.id)\n }\n return out\n})\n\n// Say so when a change leaves edges that don't fit.\nwatch(staleEdges, (now, before) => {\n let added = 0\n for (const id of now) if (!before.has(id)) added++\n if (added) announce(format(messages.value.stale, { count: added }))\n})\n\nfunction removeStaleEdges() {\n const ids = [...staleEdges.value]\n if (ids.length) transact((tr) => ids.forEach((id) => tr.removeEdge(id)))\n return ids.length\n}\n\n/** How far an edge may bulge past its ends, in canvas px. */\nconst EDGE_REACH = 80\n\n/** Every edge's route and the box around it; independent of the view, so panning skips it. */\nconst edgeRoutes = computed(() => {\n const out: { edge: FlowEdge; route: EdgeRoute; box: Rect; style?: CSSProperties }[] = []\n // Read once: adding or removing edges is tracked, single edges are replaced whole.\n const edges = doc.value.edges\n void edges.length\n for (const edge of toRaw(edges)) {\n const from = edgeEnd(edge.source)\n const to = edgeEnd(edge.target)\n // Both ends folded into the same container.\n if (from.node === to.node && (from !== edge.source || to !== edge.target)) continue\n const source = nodeById.value.get(from.node)\n const target = nodeById.value.get(to.node)\n if (!source || !target) continue\n const a = anchor(source, from.port, 'source')\n const b = anchor(target, to.port, 'target')\n if (!a || !b) continue\n out.push({\n edge,\n route: edgeTypeOf(edge.type).route(a, b, edge),\n // Both ends, with room for the curve.\n box: {\n x: Math.min(a.x, b.x) - EDGE_REACH,\n y: Math.min(a.y, b.y) - EDGE_REACH,\n width: Math.abs(a.x - b.x) + EDGE_REACH * 2,\n height: Math.abs(a.y - b.y) + EDGE_REACH * 2,\n },\n style: edgeStyle(edge),\n })\n }\n return out\n})\n\n/** The edges in view. */\nconst edgePaths = computed(() => {\n const selected = new Set(selection.value.edges)\n const moving = connecting.value?.edge\n const area = drawnArea.value\n const stale = staleEdges.value\n const out: {\n edge: FlowEdge\n route: EdgeRoute\n selected: boolean\n stale: boolean\n style?: CSSProperties\n }[] = []\n for (const item of edgeRoutes.value) {\n const { edge } = item\n if (edge.id === moving || (area && !intersects(item.box, area))) continue\n out.push({\n edge,\n route: item.route,\n selected: selected.has(edge.id),\n stale: stale.has(edge.id),\n style: item.style,\n })\n }\n return out\n})\n\nfunction edgeStyle(edge: FlowEdge): CSSProperties | undefined {\n const port = portSpec(edge.source)\n const type = port && portTypeOf(port)\n const own = edgeTypeOf(edge.type).style?.(edge)\n if (!type) return own\n return {\n '--dou-flow-editor-edge': colorOf(type),\n '--dou-flow-editor-edge-width': type.edgeWidth ? `${type.edgeWidth}px` : undefined,\n strokeDasharray: type.dashed ? '4 4' : undefined,\n ...own,\n } as CSSProperties\n}\n\n/** The handle that reshapes the one selected edge, e.g. a step edge's middle. */\nconst bend = computed(() => {\n const { nodes, edges } = selection.value\n if (props.readonly || nodes.length || edges.length !== 1) return null\n const item = edgeRoutes.value.find((e) => e.edge.id === edges[0])\n const handle = item?.route.handle\n return handle && item ? { edge: item.edge, handle } : null\n})\n\n// Edges from the keyboard ----------------------------------------------------------\n\n/** The node whose edges `E` goes through, and the one last selected. */\nlet edgeCycle: { node: string; edge: string } | null = null\n\n/**\n * Selects the next edge of the selected node, or of the node whose edges\n * are being gone through, and says which nodes it joins.\n */\nfunction cycleEdges(step: 1 | -1) {\n const { nodes, edges } = selection.value\n let node: string | undefined\n let current: string | undefined\n if (nodes.length === 1 && !edges.length) node = nodes[0]\n else {\n const last = edgeCycle\n if (last && !nodes.length && edges.length === 1 && last.edge === edges[0]) {\n node = last.node\n current = last.edge\n }\n }\n if (!node) return false\n // Outgoing first, each in document order.\n const all = doc.value.edges\n const list = [\n ...all.filter((e) => e.source.node === node),\n ...all.filter((e) => e.target.node === node && e.source.node !== node),\n ]\n if (!list.length) {\n announce(messages.value.noEdges)\n return true\n }\n const index = current ? list.findIndex((e) => e.id === current) : -1\n const edge = list[(index + step + list.length) % list.length]!\n edgeCycle = { node, edge: edge.id }\n setSelection({ nodes: [], edges: [edge.id] })\n announce(describeEdge(edge))\n return true\n}\n\nfunction nodeName(id: string) {\n const node = nodeById.value.get(id)\n return node ? nameOf(node) : id\n}\n\nfunction describeEdge(edge: FlowEdge) {\n const t = messages.value\n const text = format(t.edge, { from: nodeName(edge.source.node), to: nodeName(edge.target.node) })\n return edge.label ? text + format(t.edgeLabel, { label: edge.label }) : text\n}\n\n// Edge labels ----------------------------------------------------------------------\n\nconst editingLabel = ref<string | null>(null)\nconst labelDraft = ref('')\n\n// Too small to read when zoomed out, except the one being typed.\nconst edgeLabels = computed(() =>\n edgePaths.value.filter(\n (item) => item.edge.id === editingLabel.value || (item.edge.label && !simple.value),\n ),\n)\n\n/** Opens the text field on an edge's label. */\nfunction editLabel(id: string) {\n if (props.readonly || !doc.value.edges.some((e) => e.id === id)) return\n setSelection({ nodes: [], edges: [id] })\n labelDraft.value = doc.value.edges.find((e) => e.id === id)!.label ?? ''\n editingLabel.value = id\n void nextTick(() => {\n const input = root.value?.querySelector<HTMLInputElement>('.dou-flow-editor__label-input')\n input?.focus({ preventScroll: true })\n input?.select()\n })\n}\n\nfunction finishLabel(save: boolean) {\n const id = editingLabel.value\n if (!id) return\n editingLabel.value = null\n const text = labelDraft.value.trim()\n if (save && transact((tr) => tr.updateEdge(id, { label: text || undefined })).changed)\n announce(messages.value.labelSaved)\n root.value?.focus({ preventScroll: true })\n}\n\nfunction onLabelKeydown(event: KeyboardEvent) {\n if (event.isComposing) return\n if (event.key === 'Enter') finishLabel(true)\n else if (event.key === 'Escape') finishLabel(false)\n else return\n event.preventDefault()\n event.stopPropagation()\n}\n\n/** Connected port ids, by node. */\nconst connected = computed(() => {\n const map = new Map<string, Set<string>>()\n const add = (node: string, port?: string) => {\n if (!port) return\n let set = map.get(node)\n if (!set) map.set(node, (set = new Set()))\n set.add(port)\n }\n for (const edge of doc.value.edges) {\n add(edge.source.node, edge.source.port)\n add(edge.target.node, edge.target.port)\n }\n return map\n})\nconst none = new Set<string>()\nconst noStates = new Map<string, PortState>()\n\n/** How many edges touch each node. */\nconst degree = computed(() => {\n const map = new Map<string, number>()\n const add = (id: string) => map.set(id, (map.get(id) ?? 0) + 1)\n for (const edge of toRaw(doc.value).edges) {\n add(edge.source.node)\n if (edge.target.node !== edge.source.node) add(edge.target.node)\n }\n return map\n})\n\n/** A node's name; a container without one goes by the word for a group. */\nfunction nameOf(node: FlowNode) {\n return (\n labelOf(specOf(node), node, messages.value) || (isContainer(node) ? messages.value.group : '')\n )\n}\n\nconst roleOf = (node: FlowNode) => (isContainer(node) ? messages.value.group : messages.value.node)\n\n/** Read out after a node's name: the group it is in, what it holds, its ports. */\nfunction describe(node: FlowNode) {\n const t = messages.value\n const parts: string[] = []\n const parent = tree.value.parentOf(node.id)\n const group = parent && nodeById.value.get(parent)\n if (group) parts.push(format(t.inGroup, { group: nameOf(group) }))\n if (isContainer(node))\n parts.push(format(t.groupSize, { count: tree.value.children.get(node.id)?.length ?? 0 }))\n const ports = portsOf(specOf(node), node)\n if (ports.length)\n parts.push(\n format(t.ports, {\n inputs: ports.filter((p) => p.kind === 'input').length,\n outputs: ports.filter((p) => p.kind === 'output').length,\n connected: degree.value.get(node.id) ?? 0,\n }),\n )\n return parts.join(t.separator)\n}\n\n// Viewport -------------------------------------------------------------------------\n\nconst clampZoom = (zoom: number) => Math.min(props.maxZoom, Math.max(props.minZoom, zoom))\n\nlet animation = 0\nfunction stopAnimation() {\n cancelAnimationFrame(animation)\n animation = 0\n}\n\n/** `next`, kept within reach of the nodes; see `clampPan`. */\nfunction reachable(next: FlowViewport) {\n return clampPan(next, viewport.value, boundsOf(shownNodes.value.map(rectOf)), size.value)\n}\n\n/** Moves the view, as far as the nodes allow; `v-model:viewport` alone can go further. */\nfunction setViewport(target: FlowViewport, animate = false) {\n stopAnimation()\n const next = reachable(target)\n if (!animate || reducedMotion.value || typeof requestAnimationFrame === 'undefined') {\n viewport.value = next\n return\n }\n const from = { ...viewport.value }\n const start = performance.now()\n const duration = 240\n const frame = (now: number) => {\n const t = Math.min(1, (now - start) / duration)\n const e = 1 - (1 - t) ** 3\n viewport.value = {\n x: from.x + (next.x - from.x) * e,\n y: from.y + (next.y - from.y) * e,\n zoom: from.zoom + (next.zoom - from.zoom) * e,\n }\n animation = t < 1 ? requestAnimationFrame(frame) : 0\n }\n animation = requestAnimationFrame(frame)\n}\n\n/** Point on the screen relative to the editor's top left corner. */\nfunction local(clientX: number, clientY: number): Point {\n const rect = root.value!.getBoundingClientRect()\n return { x: clientX - rect.left, y: clientY - rect.top }\n}\n\nfunction toCanvas(point: Point): Point {\n const p = local(point.x, point.y)\n const { x, y, zoom } = viewport.value\n return { x: (p.x - x) / zoom, y: (p.y - y) / zoom }\n}\n\n/** Zooms keeping the canvas point under `at` (local px) in place. */\nfunction zoomAround(zoom: number, at: Point, animate = false) {\n const { x, y, zoom: k } = viewport.value\n const next = clampZoom(zoom)\n const cx = (at.x - x) / k\n const cy = (at.y - y) / k\n setViewport({ x: at.x - cx * next, y: at.y - cy * next, zoom: next }, animate)\n}\n\nconst center = () => ({ x: root.value!.clientWidth / 2, y: root.value!.clientHeight / 2 })\nconst zoomTo = (zoom: number) => zoomAround(zoom, center(), true)\nconst zoomIn = () => zoomTo(viewport.value.zoom * 1.25)\nconst zoomOut = () => zoomTo(viewport.value.zoom / 1.25)\n\n/** Room left around the nodes when fitting them into view. */\nconst FIT_PADDING = 40\n\n/**\n * Shows the given nodes, or all. With `floor`, zooms out no further than\n * that; what still doesn't fit starts at the top left, to be panned to.\n */\nfunction fit(ids?: string[], animate = true, floor = 0) {\n const nodes = ids ? ids.map((id) => nodeById.value.get(id)!).filter(Boolean) : doc.value.nodes\n const bounds = boundsOf(nodes.map(rectOf))\n if (bounds) fitRect(bounds, animate, floor)\n}\n\n/**\n * Room to leave around the nodes: the padding, or more where a panel\n * (controls, minimap…) sits in a corner, so fitted nodes don't end\n * up under it. Each panel takes room on the side where it costs less: a\n * wide one above or below, a tall one on the left or right.\n */\nfunction fitInsets() {\n const box = root.value!.getBoundingClientRect()\n const insets = { top: FIT_PADDING, bottom: FIT_PADDING, left: FIT_PADDING, right: FIT_PADDING }\n for (const panel of root.value!.querySelectorAll(\n '.dou-flow-editor__panels > :not(.dou-flow-toolbar)',\n )) {\n const rect = panel.getBoundingClientRect()\n if (!rect.height) continue\n const upper = rect.top + rect.height / 2 < box.top + box.height / 2\n const leftward = rect.left + rect.width / 2 < box.left + box.width / 2\n const vertical = upper ? rect.bottom - box.top : box.bottom - rect.top\n const horizontal = leftward ? rect.right - box.left : box.right - rect.left\n if (vertical / box.height <= horizontal / box.width) {\n const side = upper ? 'top' : 'bottom'\n insets[side] = Math.max(insets[side], vertical + PANEL_GAP)\n } else {\n const side = leftward ? 'left' : 'right'\n insets[side] = Math.max(insets[side], horizontal + PANEL_GAP)\n }\n }\n return insets\n}\n\n/** Room between a panel and the nodes fitted next to it. */\nconst PANEL_GAP = 16\n\nfunction fitRect(bounds: Rect, animate: boolean, floor: number) {\n if (!root.value) return\n const width = root.value.clientWidth\n const height = root.value.clientHeight\n const insets = fitInsets()\n const zoom = clampZoom(\n Math.max(\n Math.min(\n (width - insets.left - insets.right) / Math.max(1, bounds.width),\n (height - insets.top - insets.bottom) / Math.max(1, bounds.height),\n 1,\n ),\n Math.min(floor, 1),\n ),\n )\n // Centres the nodes in the room between `before` and `after`, or starts them at `before` when they don't fit.\n const offset = (room: number, size: number, start: number, before: number, after: number) =>\n Math.round(\n size * zoom > room - before - after\n ? before - start * zoom\n : before + (room - before - after - size * zoom) / 2 - start * zoom,\n )\n setViewport(\n {\n x: offset(width, bounds.width, bounds.x, insets.left, insets.right),\n y: offset(height, bounds.height, bounds.y, insets.top, insets.bottom),\n zoom,\n },\n animate,\n )\n}\n\n/** Room between ranks (along the flow) and between nodes in a rank when tidying. */\nconst TIDY_GAP = { horizontal: { rank: 80, row: 40 }, vertical: { rank: 56, row: 40 } }\n\n/**\n * Lays the given nodes, or the selected ones when two or more are selected,\n * or all, out in ranks along their edges in the flow's `direction`, as one\n * undo step. The group keeps its top left corner; locked nodes stay put.\n */\nfunction tidy(ids?: string[]) {\n const chosen = new Set(\n ids ??\n (selection.value.nodes.length > 1 ? selection.value.nodes : doc.value.nodes.map((n) => n.id)),\n )\n // Nodes all in one container are laid out inside it; otherwise on the canvas.\n const parents = new Set(tree.value.topmost(chosen).map((id) => tree.value.parentOf(id)))\n const scope = parents.size === 1 ? [...parents][0] : undefined\n // A container is laid out as one block with what is inside it; edges to its nodes count as its own.\n const blockOf = (id: string) => {\n let at: string | undefined = id\n while (at && tree.value.parentOf(at) !== scope) at = tree.value.parentOf(at)\n return at\n }\n const blocks = new Set([...chosen].flatMap((id) => blockOf(id) ?? []))\n const nodes = doc.value.nodes.filter((node) => blocks.has(node.id))\n const bounds = boundsOf(nodes.map(rectOf))\n if (!bounds || nodes.length < 2) return\n const vertical = props.direction === 'vertical'\n const gap = TIDY_GAP[vertical ? 'vertical' : 'horizontal']\n const across = vertical ? (['top', 'bottom'] as const) : (['left', 'right'] as const)\n const layout = layoutGraph(\n nodes.map((node) => {\n const m = metrics.get(node.id)\n const { width, height } = rectOf(node)\n // Ranks line up on the first port facing along the flow, so edges between levelled ports run straight.\n let offset = (vertical ? width : height) / 2\n for (const port of m?.ports.values() ?? [])\n if ((across as readonly string[]).includes(port.side)) {\n offset = vertical ? port.x : port.y\n break\n }\n return { id: node.id, width, height, anchor: offset }\n }),\n doc.value.edges.flatMap((e) => {\n const from = blockOf(e.source.node)\n const to = blockOf(e.target.node)\n return from && to && from !== to && blocks.has(from) && blocks.has(to) ? [{ from, to }] : []\n }),\n {\n direction: props.direction,\n rankGap: gap.rank,\n laneGap: gap.row,\n equalWidth: false,\n grid: props.grid || 8,\n },\n )\n const snap = (v: number) => (props.grid ? Math.round(v / props.grid) * props.grid : Math.round(v))\n const origin = { x: snap(bounds.x), y: snap(bounds.y) }\n const from = new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }]))\n const moved = new Map<string, Point>()\n for (const node of nodes) {\n const placed = layout.nodes.get(node.id)\n if (!placed || node.locked) continue\n const to = { x: origin.x + snap(placed.x), y: origin.y + snap(placed.y) }\n if (to.x !== node.x || to.y !== node.y) moved.set(node.id, to)\n }\n if (!moved.size) return\n const count = moved.size\n // What is inside a moved container moves the same way.\n for (const { id } of nodes) {\n const to = moved.get(id)\n if (!to) continue\n const block = from.get(id)!\n for (const inner of tree.value.descendants(id)) {\n const node = nodeById.value.get(inner)\n if (!node) continue\n from.set(inner, { x: node.x, y: node.y })\n moved.set(inner, { x: node.x + to.x - block.x, y: node.y + to.y - block.y })\n }\n }\n transact((tr) => {\n for (const [id, to] of moved) tr.moveNode(id, to.x, to.y)\n })\n announce(format(messages.value.tidied, { count }))\n animateTidy(from, moved)\n // A tidier layout can be wider or taller; bring it back into view if so.\n const after = boundsOf(\n nodes.map((node) => ({ ...rectOf(node), ...(moved.get(node.id) ?? from.get(node.id)) })),\n )!\n const { x, y, zoom } = viewport.value\n const { width, height } = size.value\n const inView =\n after.x * zoom + x >= 0 &&\n after.y * zoom + y >= 0 &&\n (after.x + after.width) * zoom + x <= width &&\n (after.y + after.height) * zoom + y <= height\n if (!inView) fitRect(after, true, props.fitMinZoom)\n}\n\nlet tweenFrame = 0\nconst easeOut = (t: number) => 1 - (1 - t) ** 3\n\n/** Glides moved nodes from where they were to where tidying put them. */\nfunction animateTidy(from: Map<string, Point>, to: Map<string, Point>) {\n cancelAnimationFrame(tweenFrame)\n if (reducedMotion.value || typeof requestAnimationFrame === 'undefined') return\n const start = performance.now()\n const duration = 220\n const step = (now: number) => {\n const t = Math.min(1, (now - start) / duration)\n const k = easeOut(t)\n const frame = new Map<string, Point>()\n for (const [id, end] of to) {\n const a = from.get(id)!\n frame.set(id, { x: a.x + (end.x - a.x) * k, y: a.y + (end.y - a.y) * k })\n }\n tween.value = t < 1 ? frame : null\n if (t < 1) tweenFrame = requestAnimationFrame(step)\n }\n tween.value = new Map(from)\n tweenFrame = requestAnimationFrame(step)\n}\n\nfunction centerNode(id: string) {\n const rect = getNodeRect(id)\n if (!rect || !root.value) return\n const { zoom } = viewport.value\n const c = center()\n setViewport(\n {\n x: c.x - (rect.x + rect.width / 2) * zoom,\n y: c.y - (rect.y + rect.height / 2) * zoom,\n zoom,\n },\n true,\n )\n}\n\n/** Keeps a node in view, e.g. when it gets keyboard focus. */\nfunction reveal(id: string) {\n const rect = getNodeRect(id)\n if (!rect || !root.value) return\n const { x, y, zoom } = viewport.value\n const pad = 16\n const left = rect.x * zoom + x\n const top = rect.y * zoom + y\n const right = left + rect.width * zoom\n const bottom = top + rect.height * zoom\n const w = root.value.clientWidth\n const h = root.value.clientHeight\n let dx = 0\n let dy = 0\n if (left < pad) dx = pad - left\n else if (right > w - pad) dx = Math.max(pad - left, w - pad - right)\n if (top < pad) dy = pad - top\n else if (bottom > h - pad) dy = Math.max(pad - top, h - pad - bottom)\n if (dx || dy) setViewport({ x: x + dx, y: y + dy, zoom }, true)\n}\n\n// Fit once every node has been measured.\nlet fitted = !props.fitView\nwatch(\n () => metrics.size,\n (size) => {\n if (fitted || !size || size < doc.value.nodes.length) return\n fitted = true\n fit(undefined, false, props.fitMinZoom)\n },\n)\n\n/** Dots every 16 canvas px, spread out further when zoomed out. */\nconst background = computed<CSSProperties>(() => {\n const { x, y, zoom } = viewport.value\n let gap = 16\n while (gap * zoom < 12) gap *= 2\n const size = gap * zoom\n return {\n backgroundSize: `${size}px ${size}px`,\n backgroundPosition: `${x - size / 2}px ${y - size / 2}px`,\n }\n})\n\nconst layerStyle = computed<CSSProperties>(() => {\n const { x, y, zoom } = viewport.value\n return {\n transform: `translate(${x}px, ${y}px) scale(${zoom})`,\n '--dou-flow-editor-zoom': zoom,\n } as CSSProperties\n})\n\n// Pointer gestures ------------------------------------------------------------------\n\ntype Gesture =\n | {\n kind: 'pan'\n id: number\n touch: boolean\n start: Point\n origin: FlowViewport\n moved: boolean\n additive: boolean\n }\n | {\n kind: 'box'\n id: number\n touch: boolean\n start: Point\n current: Point\n client: Point\n base: FlowSelection\n }\n | {\n kind: 'drag'\n id: number\n touch: boolean\n node: string\n start: Point\n client: Point\n origins: Map<string, Point>\n others: Rect[]\n /** Ports on moving nodes connected to ports on standing ones. */\n links: SnapLink[]\n started: boolean\n additive: boolean\n wasSelected: boolean\n held: boolean\n group: string\n snap: boolean\n }\n | {\n kind: 'resize'\n id: number\n touch: boolean\n node: string\n start: Point\n origin: { width: number; height: number }\n min: { width: number; height: number }\n group: string\n }\n | {\n kind: 'bend'\n id: number\n touch: boolean\n edge: string\n /** The edge's data before the drag, to put back on Escape. */\n origin: FlowEdge['data']\n move: NonNullable<EdgeRoute['handle']>['move']\n group: string\n start: Point\n /** From the pointer to the handle, in canvas coordinates. */\n offset: Point\n started: boolean\n }\n | {\n /** A press an extension took over. */\n kind: 'press'\n id: number\n touch: boolean\n handler: FlowPressHandler\n done: boolean\n }\n | {\n kind: 'connect'\n id: number\n touch: boolean\n /** The pressed node, selected on a plain click. */\n node: string\n start: Point\n client: Point\n started: boolean\n link: Link\n }\n\n/** An edge being drawn: one end stays, the other follows the pointer. */\ninterface Link {\n fixed: Required<FlowEndpoint>\n /** Which end of the edge is moving. */\n moving: 'source' | 'target'\n /** An existing edge being moved to another port. */\n edge?: string\n /** Where the moving end is, in canvas coordinates. */\n point: Point\n candidate: Required<FlowEndpoint> | null\n /** A port under the pointer that can't be joined, and why. */\n refused?: { end: Required<FlowEndpoint>; refusal: JoinRefusal } | null\n /** Ports the moving end may land on: port ids by node. */\n valid: Map<string, Set<string>>\n}\n\nconst gesture = shallowRef<Gesture | null>(null)\nconst guides = shallowRef<Guide[]>([])\nconst panning = ref(false)\nconst spaceHeld = ref(false)\nlet pressTimer = 0\nlet dragCount = 0\n\n/** When a finger last went down, to tell a long press's own context menu event. */\nlet lastTouch = 0\n/** Active touch points, for pinching. */\nconst touches = new Map<number, Point>()\nlet pinch: { distance: number; mid: Point; origin: FlowViewport } | null = null\n\n/** Where people type or read: presses there don't drag, and text can be selected. */\nconst FIELDS =\n 'input, textarea, [contenteditable]:not([contenteditable=\"false\"]), [data-dou-nodrag]'\nconst isInteractive = (el: Element) => !!el.closest(`${FIELDS}, select, button, a[href]`)\nconst inPanels = (el: Element) => !!el.closest('.dou-flow-editor__panels > *')\n\n/**\n * No text selection starts on the canvas, only in fields and in parts of\n * nodes marked `data-dou-nodrag`.\n * `user-select: none` says the same, but a user style or a browser\n * extension can turn selecting back on, and then every drag paints its way\n * across the nodes and panels.\n */\nfunction onSelectstart(event: Event) {\n const node = event.target as Node\n const el = node instanceof Element ? node : node.parentElement\n if (!el?.closest(FIELDS)) event.preventDefault()\n}\n\n/** Keeps receiving a pointer's events once it leaves the editor. */\nfunction capture(id: number) {\n try {\n root.value!.setPointerCapture(id)\n } catch {\n // The pointer is already gone.\n }\n}\n\nfunction clearPress() {\n clearTimeout(pressTimer)\n pressTimer = 0\n}\n\nfunction onPointerDown(event: PointerEvent) {\n const target = event.target as Element\n if (inPanels(target)) return\n stopAnimation()\n const touch = event.pointerType === 'touch'\n if (touch) {\n lastTouch = Date.now()\n // The first finger of a new touch: any finger still on record lifted\n // where the editor didn't hear it (outside it, or taken by the browser).\n if (event.isPrimary) {\n touches.clear()\n pinch = null\n }\n touches.set(event.pointerId, { x: event.clientX, y: event.clientY })\n if (touches.size === 2) {\n startPinch()\n return\n }\n if (touches.size > 2) return\n }\n if (gesture.value) return\n\n const nodeEl = target.closest<HTMLElement>('[data-node-id]')\n const edgeEl = target.closest<Element>('[data-edge-id]')\n const additive = event.shiftKey || event.metaKey || event.ctrlKey\n const client = { x: event.clientX, y: event.clientY }\n\n if (event.button === 1 || (event.button === 0 && spaceHeld.value)) {\n event.preventDefault()\n startPan(event, false)\n return\n }\n if (event.button !== 0) return\n // Typing a label: the field's own presses.\n if (target.closest('.dou-flow-editor__label-input')) return\n\n if (resolved.value.presses.length && !props.readonly && !isInteractive(target)) {\n const pressedId = target.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId\n const press = {\n point: toCanvas(client),\n node: (pressedId && nodeById.value.get(pressedId)) || null,\n event,\n }\n for (const hook of resolved.value.presses) {\n const handler = hook(context, press)\n if (!handler) continue\n gesture.value = { kind: 'press', id: event.pointerId, touch, handler, done: false }\n capture(event.pointerId)\n return\n }\n }\n\n const resizeEl = target.closest('.dou-flow-editor__resize')\n if (resizeEl && nodeEl && !props.readonly) {\n const node = nodeById.value.get(nodeEl.dataset.nodeId!)\n const min = node && resizeOf(specOf(node), node)\n if (node && min) {\n const rect = rectOf(node)\n gesture.value = {\n kind: 'resize',\n id: event.pointerId,\n touch,\n node: node.id,\n start: toCanvas(client),\n origin: { width: rect.width, height: rect.height },\n min: { width: min.minWidth ?? 40, height: min.minHeight ?? 40 },\n group: `resize:${++dragCount}`,\n }\n capture(event.pointerId)\n return\n }\n }\n\n // The handle, or the label sitting on it.\n const bendEl = target.closest('.dou-flow-editor__bend, .dou-flow-editor__label.is-bend')\n if (bendEl && bend.value) {\n const { edge, handle } = bend.value\n const at = toCanvas(client)\n gesture.value = {\n kind: 'bend',\n id: event.pointerId,\n touch,\n edge: edge.id,\n origin: edge.data,\n move: handle.move,\n group: `bend:${++dragCount}`,\n start: client,\n offset: { x: handle.at.x - at.x, y: handle.at.y - at.y },\n started: false,\n }\n // Captured once it moves, so a double click still reaches the label.\n return\n }\n\n const portEl = target.closest<HTMLElement>('.dou-flow-port')\n if (nodeEl && portEl && !props.readonly) {\n const link = linkFrom({ node: nodeEl.dataset.nodeId!, port: portEl.dataset.port! })\n if (link) {\n gesture.value = {\n kind: 'connect',\n id: event.pointerId,\n touch,\n node: nodeEl.dataset.nodeId!,\n start: client,\n client,\n started: false,\n link,\n }\n capture(event.pointerId)\n return\n }\n }\n\n // Shift-dragging inside a container selects the nodes in it, as on the canvas.\n const pressed = nodeEl && nodeById.value.get(nodeEl.dataset.nodeId!)\n if (pressed && isContainer(pressed) && event.shiftKey && !touch && !isInteractive(target)) {\n startBox(event, client)\n return\n }\n\n if (nodeEl) {\n if (isInteractive(target)) return\n const id = nodeEl.dataset.nodeId!\n const wasSelected = selection.value.nodes.includes(id)\n if (!wasSelected) {\n setSelection(\n additive\n ? { nodes: [...selection.value.nodes, id], edges: selection.value.edges }\n : { nodes: [id], edges: [] },\n )\n }\n gesture.value = {\n kind: 'drag',\n id: event.pointerId,\n touch,\n node: id,\n start: toCanvas(client),\n client,\n origins: new Map(),\n others: [],\n links: [],\n started: false,\n additive,\n wasSelected,\n held: false,\n group: '',\n snap: !event.altKey,\n }\n if (touch) {\n // Holding a node adds it to the selection, without dragging.\n pressTimer = window.setTimeout(() => {\n const g = gesture.value\n if (g?.kind !== 'drag' || g.started) return\n g.held = true\n if (!selection.value.nodes.includes(id))\n setSelection({ nodes: [...selection.value.nodes, id], edges: selection.value.edges })\n }, LONG_PRESS)\n }\n // Captured once it moves, so a double click still reaches the node.\n return\n }\n\n if (edgeEl) {\n const id = edgeEl.getAttribute('data-edge-id')!\n const has = selection.value.edges.includes(id)\n if (additive)\n setSelection({\n nodes: selection.value.nodes,\n edges: has ? selection.value.edges.filter((e) => e !== id) : [...selection.value.edges, id],\n })\n else setSelection({ nodes: [], edges: [id] })\n const edge = doc.value.edges.find((e) => e.id === id)\n if (edge) emit('edgeClick', edge, event)\n root.value!.focus({ preventScroll: true })\n return\n }\n\n if (event.shiftKey && !touch) startBox(event, client)\n else startPan(event, touch, additive)\n}\n\nfunction startPan(event: PointerEvent, touch: boolean, additive = false) {\n gesture.value = {\n kind: 'pan',\n id: event.pointerId,\n touch,\n start: { x: event.clientX, y: event.clientY },\n origin: { ...viewport.value },\n moved: false,\n additive,\n }\n if (touch) {\n // Holding the empty canvas starts a selection box instead.\n pressTimer = window.setTimeout(() => {\n const g = gesture.value\n if (g?.kind !== 'pan' || g.moved) return\n startBox(event, g.start)\n }, LONG_PRESS)\n }\n capture(event.pointerId)\n}\n\nfunction startBox(event: PointerEvent, client: Point) {\n const start = toCanvas(client)\n gesture.value = {\n kind: 'box',\n id: event.pointerId,\n touch: event.pointerType === 'touch',\n start,\n current: start,\n client,\n base: event.shiftKey ? selection.value : emptySelection(),\n }\n capture(event.pointerId)\n}\n\nfunction startPinch() {\n const [a, b] = [...touches.values()] as [Point, Point]\n clearPress()\n endGesture()\n // Both fingers report back here even when they leave the editor.\n for (const id of touches.keys()) capture(id)\n pinch = {\n distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,\n mid: local((a.x + b.x) / 2, (a.y + b.y) / 2),\n origin: { ...viewport.value },\n }\n}\n\nfunction movePinch() {\n if (!pinch) return\n const [a, b] = [...touches.values()] as [Point, Point]\n const { origin } = pinch\n const zoom = clampZoom((origin.zoom * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.distance)\n const mid = local((a.x + b.x) / 2, (a.y + b.y) / 2)\n // The canvas point under the fingers' first midpoint follows the midpoint.\n const cx = (pinch.mid.x - origin.x) / origin.zoom\n const cy = (pinch.mid.y - origin.y) / origin.zoom\n setViewport({ x: mid.x - cx * zoom, y: mid.y - cy * zoom, zoom })\n}\n\n/** Where the pointer is over the editor, for pasting under it. */\nlet hover: Point | null = null\n\nfunction onPointerMove(event: PointerEvent) {\n if (event.pointerType === 'mouse' && root.value?.contains(event.target as Node))\n hover = { x: event.clientX, y: event.clientY }\n if (event.pointerType === 'touch' && touches.has(event.pointerId)) {\n touches.set(event.pointerId, { x: event.clientX, y: event.clientY })\n if (pinch) {\n movePinch()\n return\n }\n }\n const g = gesture.value\n if (!g || g.id !== event.pointerId) return\n const client = { x: event.clientX, y: event.clientY }\n if (g.kind === 'pan') {\n const dx = client.x - g.start.x\n const dy = client.y - g.start.y\n if (!g.moved) {\n if (Math.hypot(dx, dy) < THRESHOLD[g.touch ? 'touch' : 'mouse']) return\n g.moved = true\n clearPress()\n panning.value = true\n }\n setViewport({ x: g.origin.x + dx, y: g.origin.y + dy, zoom: g.origin.zoom })\n } else if (g.kind === 'press') {\n g.handler.move?.(toCanvas(client), event)\n } else if (g.kind === 'box') {\n g.client = client\n updateBox(g)\n autoPan(client)\n } else if (g.kind === 'resize') {\n const p = toCanvas(client)\n const width = snapTo(Math.max(g.min.width, g.origin.width + p.x - g.start.x))\n const height = snapTo(Math.max(g.min.height, g.origin.height + p.y - g.start.y))\n transact((tr) => tr.updateNode(g.node, { width, height }), { group: g.group })\n } else if (g.kind === 'bend') {\n if (!g.started) {\n if (\n Math.hypot(client.x - g.start.x, client.y - g.start.y) <\n THRESHOLD[g.touch ? 'touch' : 'mouse']\n )\n return\n g.started = true\n capture(g.id)\n }\n const p = toCanvas(client)\n const patch = g.move({ x: p.x + g.offset.x, y: p.y + g.offset.y })\n transact((tr) => tr.updateEdge(g.edge, { data: { ...g.origin, ...patch } }), {\n group: g.group,\n })\n } else if (g.kind === 'connect') {\n g.client = client\n if (!g.started) {\n if (\n Math.hypot(client.x - g.start.x, client.y - g.start.y) <\n THRESHOLD[g.touch ? 'touch' : 'mouse']\n )\n return\n g.started = true\n }\n updateLink(g)\n autoPan(client)\n } else {\n g.client = client\n if (!g.started) {\n const { zoom } = viewport.value\n const p = toCanvas(client)\n const moved = Math.hypot(p.x - g.start.x, p.y - g.start.y) * zoom\n if (moved < THRESHOLD[g.touch ? 'touch' : 'mouse'] || g.held) return\n clearPress()\n if (props.readonly) return\n startDrag(g)\n }\n g.snap = !event.altKey\n moveDrag(g)\n autoPan(client)\n }\n}\n\nfunction updateBox(g: Extract<Gesture, { kind: 'box' }>) {\n g.current = toCanvas(g.client)\n const box = rectFrom(g.start, g.current)\n // A container only when the box takes it in whole, so a box drawn inside picks its nodes.\n const inside = (r: Rect) =>\n r.x >= box.x &&\n r.y >= box.y &&\n r.x + r.width <= box.x + box.width &&\n r.y + r.height <= box.y + box.height\n const hit = doc.value.nodes\n .filter((node) => {\n if (hiddenIn.value.has(node.id)) return false\n const rect = rectOf(node)\n if (isContainer(node)) return inside(rect)\n // a turned node is picked by what it covers, not by its upright box\n return intersectsTurned(box, rect, specOf(node).rotation?.(node) ?? 0)\n })\n .map((n) => n.id)\n const nodes = [...new Set([...g.base.nodes, ...hit])]\n setSelection({ nodes, edges: g.base.edges })\n gesture.value = { ...g }\n}\n\n/**\n * What moving `ids` moves: the unlocked ones and everything inside them,\n * locked or not, since a container carries its contents.\n */\nfunction carried(ids: readonly string[]) {\n const out = new Set<string>()\n for (const id of ids) {\n if (out.has(id) || nodeById.value.get(id)?.locked) continue\n out.add(id)\n for (const inner of tree.value.descendants(id)) out.add(inner)\n }\n return out\n}\n\nfunction startDrag(g: Extract<Gesture, { kind: 'drag' }>) {\n g.started = true\n capture(g.id)\n g.group = `drag:${++dragCount}`\n const moving = carried(selection.value.nodes)\n for (const node of doc.value.nodes) {\n if (moving.has(node.id)) g.origins.set(node.id, { x: node.x, y: node.y })\n }\n g.others = doc.value.nodes.filter((node) => !moving.has(node.id)).map(rectOf)\n for (const edge of doc.value.edges) {\n const fromSource = g.origins.has(edge.source.node)\n if (fromSource === g.origins.has(edge.target.node)) continue\n const [mine, theirs] = fromSource ? [edge.source, edge.target] : [edge.target, edge.source]\n const a = anchorOf(mine, fromSource ? 'source' : 'target')\n const b = anchorOf(theirs, fromSource ? 'target' : 'source')\n if (a && b && level(a.side) === level(b.side))\n g.links.push({ from: a, to: b, axis: level(a.side) })\n }\n gesture.value = { ...g }\n}\n\nfunction moveDrag(g: Extract<Gesture, { kind: 'drag' }>) {\n if (!g.origins.size) return\n const p = toCanvas(g.client)\n const dx = p.x - g.start.x\n const dy = p.y - g.start.y\n const moved = boundsOf(\n [...g.origins].map(([id, o]) => {\n const rect = getNodeRect(id)!\n return { ...rect, x: o.x + dx, y: o.y + dy }\n }),\n )!\n const snap = g.snap\n ? snapBox(moved, props.guides ? g.others : [], {\n threshold: 6 / viewport.value.zoom,\n grid: props.grid,\n links: props.guides\n ? g.links.map((link) => ({ ...link, from: { x: link.from.x + dx, y: link.from.y + dy } }))\n : [],\n })\n : { dx: 0, dy: 0, guides: [] }\n const sx = dx + snap.dx\n const sy = dy + snap.dy\n transact(\n (tr) => {\n for (const [id, o] of g.origins) tr.moveNode(id, Math.round(o.x + sx), Math.round(o.y + sy))\n },\n { group: g.group },\n )\n guides.value = snap.guides\n dropTarget.value = containerAt(p, containerRects(new Set(g.origins.keys()))) ?? null\n}\n\n/** The container the dragged nodes will go into, or `null` for the canvas. */\nconst dropTarget = ref<string | null>(null)\n\n/** After a drag: the moved nodes go into the container under the pointer, or out of theirs. */\nfunction settleDrop(g: Extract<Gesture, { kind: 'drag' }>) {\n const target = dropTarget.value ?? undefined\n dropTarget.value = null\n const moved = tree.value.topmost(g.origins.keys())\n if (!moved.some((id) => tree.value.parentOf(id) !== target)) return\n reparent(moved, target, g.group)\n}\n\n/** Puts nodes into a container, or out on the canvas, and says so. */\nfunction reparent(ids: readonly string[], target: string | undefined, group: string) {\n const changing = ids.filter((id) => tree.value.parentOf(id) !== target)\n if (!changing.length) return\n const left = target ? undefined : tree.value.parentOf(changing[0]!)\n transact(\n (tr) => {\n for (const id of changing) tr.updateNode(id, { parent: target })\n },\n { group },\n )\n const t = messages.value\n if (target) announce(format(t.joinedGroup, { group: nodeName(target) }))\n else if (left) announce(format(t.leftGroup, { group: nodeName(left) }))\n}\n\nlet autoPanFrame = 0\n/** Scrolls the canvas while a drag or a selection box nears its edge. */\nfunction autoPan(client: Point) {\n cancelAnimationFrame(autoPanFrame)\n const el = root.value\n if (!el) return\n const p = local(client.x, client.y)\n const vx = edgeSpeed(p.x) - edgeSpeed(el.clientWidth - p.x)\n const vy = edgeSpeed(p.y) - edgeSpeed(el.clientHeight - p.y)\n if (!vx && !vy) return\n const step = () => {\n const g = gesture.value\n if (\n !g ||\n g.kind === 'pan' ||\n g.kind === 'bend' ||\n g.kind === 'press' ||\n g.kind === 'resize' ||\n ((g.kind === 'drag' || g.kind === 'connect') && !g.started)\n )\n return\n const { x, y, zoom } = viewport.value\n viewport.value = reachable({ x: x + vx, y: y + vy, zoom })\n if (g.kind === 'box') updateBox(g)\n else if (g.kind === 'connect') updateLink(g)\n else moveDrag(g)\n autoPanFrame = requestAnimationFrame(step)\n }\n autoPanFrame = requestAnimationFrame(step)\n}\n\n// Connecting ---------------------------------------------------------------------\n\n/** The prop's rule, then each extension's, in priority order; the first refusal wins. */\nconst canConnect = computed(() => {\n const rules = [...(props.canConnect ? [props.canConnect] : []), ...resolved.value.rules]\n if (!rules.length) return undefined\n return (connection: FlowConnection, current: FlowDocument) => {\n for (const rule of rules) {\n const verdict = rule(connection, current)\n if (verdict !== true) return verdict\n }\n return true\n }\n})\n\nconst connectOptions = () => ({\n portTypes: portTypes.value,\n acyclic: props.acyclic,\n canConnect: canConnect.value,\n})\n\n/**\n * Starts an edge at a port. From an output, or an empty input, it draws a\n * new edge; from an input that has an edge, it picks that edge up.\n */\nfunction linkFrom(at: Required<FlowEndpoint>): Link | null {\n const port = portSpec(at)\n if (!port) return null\n let link: Omit<Link, 'valid'>\n let taken: FlowEdge | undefined\n if (port.kind === 'input')\n for (const e of doc.value.edges)\n if (e.target.node === at.node && e.target.port === at.port) taken = e\n if (taken && taken.source.port != null) {\n link = {\n fixed: taken.source as Required<FlowEndpoint>,\n moving: 'target',\n edge: taken.id,\n point: anchorOf(at, 'target') ?? { x: 0, y: 0 },\n candidate: at,\n }\n } else {\n link = {\n fixed: at,\n moving: port.kind === 'output' ? 'target' : 'source',\n point: anchorOf(at, port.kind === 'output' ? 'source' : 'target') ?? { x: 0, y: 0 },\n candidate: null,\n }\n }\n return { ...link, valid: validEnds(link) }\n}\n\nconst connectionOf = (link: Pick<Link, 'fixed' | 'moving'>, end: Required<FlowEndpoint>) =>\n (link.moving === 'target'\n ? { source: link.fixed, target: end }\n : { source: end, target: link.fixed }) as FlowConnection\n\nfunction validEnds(link: Pick<Link, 'fixed' | 'moving' | 'edge'>) {\n const valid = new Map<string, Set<string>>()\n const fixedPort = portSpec(link.fixed)\n if (!fixedPort) return valid\n const options = connectOptions()\n for (const node of doc.value.nodes) {\n const ids = new Set<string>()\n for (const port of portsOf(specOf(node), node)) {\n const connection = connectionOf(link, { node: node.id, port: port.id })\n const ports =\n link.moving === 'target'\n ? { source: fixedPort, target: port }\n : { source: port, target: fixedPort }\n if (canJoin(toRaw(doc.value), connection, ports, options, link.edge)) ids.add(port.id)\n }\n if (ids.size) valid.set(node.id, ids)\n }\n return valid\n}\n\n/** How close, in screen px, the pointer must come to a port to snap to it. */\nconst MAGNET = 24\n\nfunction updateLink(g: Extract<Gesture, { kind: 'connect' }>) {\n const link = g.link\n const point = toCanvas(g.client)\n const radius = (g.touch ? MAGNET * 1.5 : MAGNET) / viewport.value.zoom\n let best: { end: Required<FlowEndpoint>; d: number } | null = null\n const consider = (node: string, port: string, limit: number) => {\n const at = anchorOf({ node, port }, link.moving)\n if (!at) return\n const d = Math.hypot(at.x - point.x, at.y - point.y)\n if (d <= limit && (!best || d < best.d)) best = { end: { node, port }, d }\n }\n for (const [node, ports] of link.valid) for (const port of ports) consider(node, port, radius)\n // Dropping anywhere on a node picks its nearest free port.\n if (!best) {\n const over = document\n .elementFromPoint(g.client.x, g.client.y)\n ?.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId\n for (const port of (over && link.valid.get(over)) || []) consider(over!, port, Infinity)\n }\n const candidate = (best as { end: Required<FlowEndpoint> } | null)?.end ?? null\n g.link = { ...link, point, candidate, refused: candidate ? null : refusedAt(link, point, radius) }\n gesture.value = { ...g }\n}\n\n/** The nearest port within `radius` of `point` that the link can't land on, and why. */\nfunction refusedAt(link: Link, point: Point, radius: number) {\n const fixedPort = portSpec(link.fixed)\n if (!fixedPort) return null\n let best: { end: Required<FlowEndpoint>; d: number } | null = null\n for (const node of doc.value.nodes)\n for (const port of portsOf(specOf(node), node)) {\n if (link.valid.get(node.id)?.has(port.id)) continue\n const end = { node: node.id, port: port.id }\n if (end.node === link.fixed.node && end.port === link.fixed.port) continue\n const at = anchorOf(end, link.moving)\n const d = at ? Math.hypot(at.x - point.x, at.y - point.y) : Infinity\n if (d <= radius && (!best || d < best.d)) best = { end, d }\n }\n if (!best) return null\n const { end } = best as { end: Required<FlowEndpoint> }\n const port = portSpec(end)!\n const ports =\n link.moving === 'target'\n ? { source: fixedPort, target: port }\n : { source: port, target: fixedPort }\n const refusal = joinRefusal(\n toRaw(doc.value),\n connectionOf(link, end),\n ports,\n connectOptions(),\n link.edge,\n )\n return refusal && { end, refusal }\n}\n\n/** What screen readers hear when a link is refused. */\nfunction refusalText(refusal: JoinRefusal) {\n if (refusal.reason === 'custom') return refusal.message ?? messages.value.refused.custom\n return messages.value.refused[refusal.reason]\n}\n\n/** Whether a link was let go over a node other than its own. */\nfunction overNode(link: Link) {\n const { x, y } = link.point\n return doc.value.nodes.some((node) => {\n if (node.id === link.fixed.node || isContainer(node)) return false\n const r = rectOf(node)\n return x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height\n })\n}\n\n/** Adds the edge a link describes, replacing what it must. */\nfunction finishLink(link: Link, event?: MouseEvent) {\n const { candidate } = link\n if (!candidate) {\n if (link.refused) {\n // Dropped on a port that can't take it: say why, and leave things as they were.\n announce(refusalText(link.refused.refusal))\n } else if (!link.edge && overNode(link)) {\n // Dropped on a node with no port that can take it.\n announce(messages.value.noTarget)\n } else if (link.edge) {\n // Dropping a picked-up edge on nothing removes it.\n const id = link.edge\n transact((tr) => tr.removeEdge(id))\n } else if (event) {\n emit('connectToCanvas', link.fixed, link.point, event)\n if (!event.defaultPrevented && paletteTypes.value.length) {\n quickAdd.value = {\n link,\n at: link.point,\n client: { x: event.clientX, y: event.clientY },\n types: typesTaking(link.fixed),\n touch: (event as PointerEvent).pointerType === 'touch',\n }\n }\n }\n return\n }\n const connection = connectionOf(link, candidate)\n const source = portSpec(connection.source)\n const target = portSpec(connection.target)\n const edge: FlowEdge = { id: createId('e'), source: connection.source, target: connection.target }\n const displaced =\n source && target\n ? displacedEdges(toRaw(doc.value), connection, { source, target }, link.edge)\n : []\n transact((tr) => {\n if (link.edge) tr.removeEdge(link.edge)\n for (const id of displaced) tr.removeEdge(id)\n tr.addEdge(edge)\n })\n emit('connect', edge)\n announce(messages.value.connected)\n}\n\n/** What the canvas shows while an edge is being drawn, by mouse, touch or keyboard. */\nconst connecting = computed<Link | null>(() => {\n const g = gesture.value\n if (g?.kind === 'connect' && g.started) return g.link\n return keyLink.value\n})\n\nconst previewPath = computed(() => {\n // The dropped edge stays drawn while a node for its end is chosen.\n const link = connecting.value ?? quickAdd.value?.link\n if (!link) return null\n const fixedRole = link.moving === 'target' ? 'source' : 'target'\n const a = anchorOf(link.fixed, fixedRole)\n if (!a) return null\n const c = link.candidate && anchorOf(link.candidate, link.moving)\n const opposite = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' } as const\n const b: EdgeAnchor = c ?? { ...link.point, side: opposite[a.side] }\n const moved = link.edge ? doc.value.edges.find((e) => e.id === link.edge) : undefined\n const route = edgeTypeOf(moved?.type).route\n return (link.moving === 'target' ? route(a, b, moved) : route(b, a, moved)).path\n})\n\n/** How each port looks while connecting: by node, then port. */\nconst portStates = computed(() => {\n const link = connecting.value\n if (!link) return null\n const states = new Map<string, Map<string, PortState>>()\n const set = (end: Required<FlowEndpoint>, state: PortState) => {\n let ports = states.get(end.node)\n if (!ports) states.set(end.node, (ports = new Map()))\n ports.set(end.port, state)\n }\n for (const [node, ports] of link.valid) for (const port of ports) set({ node, port }, 'valid')\n set(link.fixed, 'active')\n if (link.candidate) set(link.candidate, 'candidate')\n if (link.refused) set(link.refused.end, 'refused')\n return states\n})\n\n// Connecting from the keyboard: C on a selected node, arrows pick the port.\n\nconst keyLink = shallowRef<\n (Link & { from: Required<FlowEndpoint>; options: Required<FlowEndpoint>[] }) | null\n>(null)\n\n/** Valid ends of a link, nearest first. */\n/** A port's anchor; nodes not measured yet fall back to their corner. */\nfunction place(end: Required<FlowEndpoint>, role: 'source' | 'target') {\n return anchorOf(end, role) ?? nodeById.value.get(end.node) ?? { x: 0, y: 0 }\n}\n\nfunction rankEnds(link: Link) {\n const at = place(link.fixed, link.moving === 'target' ? 'source' : 'target')\n const ends: { end: Required<FlowEndpoint>; d: number }[] = []\n for (const [node, ports] of link.valid)\n for (const port of ports) {\n const p = place({ node, port }, link.moving)\n ends.push({ end: { node, port }, d: Math.hypot(p.x - at.x, p.y - at.y) })\n }\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh list\n return ends.sort((a, b) => a.d - b.d).map((e) => e.end)\n}\n\nfunction describeEnd(end: Required<FlowEndpoint>) {\n const node = nodeById.value.get(end.node)\n const port = portSpec(end)\n const name = node ? labelOf(specOf(node), node, messages.value) : end.node\n return format(messages.value.connectTo, { node: name, port: port?.label ?? port?.id ?? '' })\n}\n\n/** Starts keyboard connecting from the next port of a node that has somewhere to go. */\nfunction startKeyLink(nodeId: string, after?: Required<FlowEndpoint>) {\n const node = nodeById.value.get(nodeId)\n if (!node) return false\n // Outputs first: drawing a new edge is more common than moving one.\n const all = portsOf(specOf(node), node)\n const ports = [\n ...all.filter((p) => p.kind === 'output'),\n ...all.filter((p) => p.kind !== 'output'),\n ]\n const start = after ? ports.findIndex((p) => p.id === after.port) + 1 : 0\n for (let i = 0; i < ports.length; i++) {\n const port = ports[(start + i) % ports.length]!\n const link = linkFrom({ node: nodeId, port: port.id })\n if (!link) continue\n const options = rankEnds(link)\n if (!options.length) continue\n const candidate = link.edge ? link.candidate : options[0]!\n keyLink.value = { ...link, candidate, from: { node: nodeId, port: port.id }, options }\n if (candidate) {\n reveal(candidate.node)\n announce(describeEnd(candidate))\n }\n return true\n }\n announce(messages.value.noTarget)\n return false\n}\n\nfunction onKeyLink(event: KeyboardEvent) {\n const link = keyLink.value!\n const { options } = link\n const index = link.candidate\n ? options.findIndex((o) => o.node === link.candidate!.node && o.port === link.candidate!.port)\n : -1\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowDown':\n case 'ArrowLeft':\n case 'ArrowUp': {\n const step = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1\n const candidate = options[(index + step + options.length) % options.length]!\n keyLink.value = { ...link, candidate }\n reveal(candidate.node)\n announce(describeEnd(candidate))\n break\n }\n case 'Tab':\n startKeyLink(link.from.node, link.from)\n break\n case 'Enter':\n keyLink.value = null\n finishLink(link)\n break\n case 'Escape':\n keyLink.value = null\n break\n default:\n return\n }\n event.preventDefault()\n}\n\n// Clipboard ------------------------------------------------------------------------\n\n/** The selected nodes and the edges between them. */\n/** `ids` and every node inside the containers among them. */\nfunction withInside(ids: readonly string[]) {\n const out = new Set(ids)\n for (const id of ids) for (const inner of tree.value.descendants(id)) out.add(inner)\n return [...out]\n}\n\nfunction copySelection(): FlowDocument | null {\n const ids = new Set(withInside(selection.value.nodes))\n if (!ids.size) return null\n const raw = toRaw(doc.value)\n return {\n version: 1,\n nodes: raw.nodes.filter((node) => ids.has(node.id)),\n edges: raw.edges.filter((edge) => ids.has(edge.source.node) && ids.has(edge.target.node)),\n }\n}\n\nlet pastes = 0\n\n/** Adds a copy of `data` with new ids, at the pointer or next to the original. */\nfunction paste(data: FlowDocument, at?: Point | null) {\n for (const transform of resolved.value.pasteTransforms) data = transform(data, context)\n const bounds = boundsOf(\n data.nodes.map((n) => ({ x: n.x, y: n.y, width: n.width ?? 0, height: n.height ?? 0 })),\n )\n if (!bounds) return\n const step = (props.grid || 8) * 3\n const shift = at\n ? { x: Math.round(at.x - bounds.x), y: Math.round(at.y - bounds.y) }\n : { x: step * ++pastes, y: step * pastes }\n const ids = new Map(data.nodes.map((n) => [n.id, createId()]))\n const containers = containerRects()\n // Shallow copies: documents are never changed in place.\n const nodes = data.nodes.map((n) => {\n const x = n.x + shift.x\n const y = n.y + shift.y\n // Stays in a copied container; otherwise goes into the one it lands in, if any.\n const copied = n.parent ? ids.get(n.parent) : undefined\n const m = metrics.get(n.id)\n const centre = {\n x: x + (m?.width ?? n.width ?? 0) / 2,\n y: y + (m?.height ?? n.height ?? 0) / 2,\n }\n return {\n ...n,\n data: n.data && { ...n.data },\n id: ids.get(n.id)!,\n x,\n y,\n locked: undefined,\n parent: copied ?? containerAt(centre, containers),\n }\n })\n transact((tr) => {\n for (const node of nodes) tr.addNode(node)\n for (const edge of data.edges) {\n const source = ids.get(edge.source.node)\n const target = ids.get(edge.target.node)\n if (!source || !target) continue\n tr.addEdge({\n ...edge,\n id: createId('e'),\n source: { ...edge.source, node: source },\n target: { ...edge.target, node: target },\n })\n }\n })\n const copies = new Set(ids.values())\n setSelection({\n nodes: nodes.filter((n) => !n.parent || !copies.has(n.parent)).map((n) => n.id),\n edges: [],\n })\n}\n\nfunction duplicate() {\n const data = copySelection()\n if (!data || props.readonly) return\n pastes = 0\n paste(data)\n}\n\nfunction selectAll() {\n setSelection({\n nodes: doc.value.nodes.flatMap((n) => (hiddenIn.value.has(n.id) ? [] : [n.id])),\n edges: [],\n })\n}\n\n/** Copies the selection to the system clipboard, and keeps it for pasting from the menu. */\nfunction copy() {\n const data = copySelection()\n if (!data) return false\n const text = writeClipboard(data)\n pastes = 0\n void navigator.clipboard?.writeText(text).catch(() => {})\n return true\n}\n\nfunction cut() {\n if (props.readonly || !copy()) return false\n deleteSelection()\n return true\n}\n\n/** Pastes from the system clipboard when the page may read it, or what was copied here. */\nasync function pasteFromClipboard(at?: Point) {\n if (props.readonly) return false\n let text: string | null = null\n try {\n text = (await navigator.clipboard?.readText()) ?? null\n } catch {\n // Reading the clipboard wasn't allowed: fall back to what was copied here.\n }\n const data = readClipboard(text) ?? readClipboard()\n if (!data) return false\n paste(data, at ?? null)\n return true\n}\n\n// Adding nodes ---------------------------------------------------------------------\n\nconst paletteTypes = computed(() => [...types.value.values()].filter((spec) => spec.palette))\n\n/** A node of a type, not yet in the document. */\nfunction draft(spec: NodeTypeSpec): FlowNode {\n return {\n id: createId(),\n type: spec.type,\n x: 0,\n y: 0,\n ...spec.size,\n data: spec.defaults?.() ?? {},\n }\n}\n\n/** The first port of a new node that can take an edge from `from`, if any. */\nfunction takingPort(node: FlowNode, from: Required<FlowEndpoint>) {\n const fromPort = portSpec(from)\n if (!fromPort) return undefined\n const spec = specOf(node)\n const next = { ...doc.value, nodes: [...doc.value.nodes, node] }\n for (const port of portsOf(spec, node)) {\n if (port.kind === fromPort.kind) continue\n const end = { node: node.id, port: port.id }\n const connection: FlowConnection =\n fromPort.kind === 'output' ? { source: from, target: end } : { source: end, target: from }\n const ports =\n fromPort.kind === 'output'\n ? { source: fromPort, target: port }\n : { source: port, target: fromPort }\n if (!joinRefusal(next, connection, ports, connectOptions())) return port\n }\n return undefined\n}\n\n/** Palette types a new node of which could take an edge from `from`. */\nfunction typesTaking(from: Required<FlowEndpoint>) {\n return paletteTypes.value.filter((spec) => takingPort(draft(spec), from))\n}\n\nfunction insertNode(type: string, options: FlowInsertOptions = {}) {\n const spec = types.value.get(type)\n if (!spec || props.readonly) return null\n const node = draft(spec)\n if (options.data) node.data = { ...node.data, ...options.data }\n if (options.size) Object.assign(node, options.size)\n let at = options.at\n let from = options.from\n let start = false\n const after = !at && !from ? soleSelected() : undefined\n if (after) {\n // After the one selected node, connected to it when a port can take an edge from it.\n const rect = getNodeRect(after.id)!\n const vertical = props.direction === 'vertical'\n const gap = TIDY_GAP[props.direction].rank\n for (const out of portsOf(specOf(after), after)) {\n const end = { node: after.id, port: out.id }\n if (out.kind === 'output' && takingPort(node, end)) {\n from = end\n break\n }\n }\n const fromAt = from && anchorOf(from, 'source')\n at = vertical\n ? { x: fromAt?.x ?? rect.x + rect.width / 2, y: rect.y + rect.height + gap }\n : { x: rect.x + rect.width + gap, y: fromAt?.y ?? rect.y + rect.height / 2 }\n start = true\n }\n // Without a point: the middle of the view.\n at ??= toCanvas(clientCenter())\n node.x = snapTo(at.x)\n node.y = snapTo(at.y)\n // Dropped inside a container: it goes in.\n const inside = containerAt(at, containerRects())\n if (inside) node.parent = inside\n const port = from ? takingPort(node, from) : undefined\n const group = `insert:${node.id}`\n transact(\n (tr) => {\n tr.addNode(node)\n if (from && port) {\n const end = { node: node.id, port: port.id }\n const fromPort = portSpec(from)!\n tr.addEdge({\n id: createId('e'),\n source: fromPort.kind === 'output' ? from : end,\n target: fromPort.kind === 'output' ? end : from,\n })\n }\n },\n { group },\n )\n pending.set(node.id, {\n at,\n port: port?.id,\n group,\n start,\n avoid: !options.at,\n focus: options.focus ?? true,\n })\n setSelection({ nodes: [node.id], edges: [] })\n announce(format(messages.value.added, { node: labelOf(spec, node, messages.value) }))\n return node.id\n}\n\n/** The node selected on its own, if any. */\nfunction soleSelected() {\n const { nodes, edges } = selection.value\n return nodes.length === 1 && !edges.length ? nodeById.value.get(nodes[0]!) : undefined\n}\n\n/** The middle of the editor on screen. */\nfunction clientCenter(): Point {\n const box = root.value!.getBoundingClientRect()\n return { x: box.left + box.width / 2, y: box.top + box.height / 2 }\n}\n\n// Menus and the toolbar ----------------------------------------------------------------\n\nconst menu = useTemplateRef<InstanceType<typeof FlowMenu>>('menu')\nconst menuItems = shallowRef<ContextMenuItem[]>([])\n\n/** The extensions' items for a target, a separator between each extension's. */\nfunction itemsFor(target: FlowMenuTarget) {\n const items: ContextMenuItem[] = []\n for (const fn of resolved.value.menus) {\n const list = fn(context, target)\n // a separator between extensions, never above the first item\n list.forEach((item, i) =>\n items.push({ ...item, divided: !!items.length && (i === 0 || !!item.divided) }),\n )\n }\n return items\n}\n\nconst menuOpen = ref(false)\n/** The last press while the menu was open, which closed it. */\nlet menuPress: { target: EventTarget | null; at: number } | null = null\n// Listens on the window while mounted, in capture, so it sees the press\n// before the menu closes on it: a listener added and removed as the menu\n// opens and closes would be gone by then.\nconst onMenuPress = (event: PointerEvent) => {\n menuPress = menuOpen.value ? { target: event.target, at: Date.now() } : null\n}\n// The press counts only for the click it starts.\nconst onMenuClick = () => (menuPress = null)\n/** The button that opened the menu, while it is open. */\nconst menuFrom = shallowRef<Element | null>(null)\n/** The button that opened the menu last, open or not. */\nlet lastMenuFrom: Element | null = null\nwatch(menuOpen, (open) => {\n if (!open) menuFrom.value = null\n})\n\nfunction openMenu(target: FlowMenuTarget, client: Point, from?: Element) {\n return showMenu(itemsFor(target), client, from)\n}\n\nfunction showMenu(\n items: ContextMenuItem[],\n client: Point,\n from?: Element,\n options: { focusChecked?: boolean } = {},\n) {\n // The press on the button that opened the menu closed it: leave it\n // closed. A press on another button opens that one's.\n const press = menuPress\n menuPress = null\n if (\n from &&\n from === lastMenuFrom &&\n press &&\n Date.now() - press.at < 500 &&\n press.target instanceof Node &&\n from.contains(press.target)\n ) {\n lastMenuFrom = null\n return false\n }\n if (!items.length) return false\n menuItems.value = items\n lastMenuFrom = from ?? null\n // Beside the button that opened it, so the menu never covers it, even\n // when it has to flip above.\n const rect = from?.getBoundingClientRect()\n if (rect)\n menu.value?.open(rect.left, rect.top - 2, {\n box: { width: rect.width, height: rect.height + 4 },\n focusChecked: options.focusChecked,\n })\n else menu.value?.open(client.x, client.y, { focusChecked: options.focusChecked })\n menuFrom.value = from ?? null\n return true\n}\n\nonMounted(() => {\n window.addEventListener('pointerdown', onMenuPress, true)\n window.addEventListener('click', onMenuClick)\n})\nonBeforeUnmount(() => {\n window.removeEventListener('pointerdown', onMenuPress, true)\n window.removeEventListener('click', onMenuClick)\n})\n\nconst toolbarItems = computed<FlowToolbarItem[][]>(() => {\n if (props.readonly || gesture.value || keyLink.value || quickAdd.value) return []\n const nodes = selection.value.nodes.flatMap((id) => nodeById.value.get(id) ?? [])\n if (nodes.length)\n return resolved.value.toolbars.map((fn) => fn(context, nodes)).filter((list) => list.length)\n const ids = new Set(selection.value.edges)\n const edges = doc.value.edges.filter((edge) => ids.has(edge.id))\n if (!edges.length) return []\n return resolved.value.edgeToolbars.map((fn) => fn(context, edges)).filter((list) => list.length)\n})\n\n/** Where the toolbar floats: above the selected nodes, in screen px within the editor. */\nconst toolbarAnchor = computed(() => {\n const { nodes, edges } = selection.value\n const bounds = boundsOf(\n nodes.length\n ? nodes.flatMap((id) => {\n const node = nodeById.value.get(id)\n if (!node) return []\n return nodeBounds(specOf(node), node, rectOf(node))\n })\n : edgeRoutes.value\n .filter((item) => edges.includes(item.edge.id))\n .flatMap((item) => pathPoints(item.route.path))\n .map((p) => ({ x: p.x, y: p.y, width: 0, height: 0 })),\n )\n if (!bounds) return null\n const { x, y, zoom } = viewport.value\n const screen = (rect: Rect) => ({\n left: rect.x * zoom + x,\n top: rect.y * zoom + y,\n right: (rect.x + rect.width) * zoom + x,\n bottom: (rect.y + rect.height) * zoom + y,\n })\n // Over edges, keep clear of the nodes around them: they are what the edges join.\n const reach = 64 / zoom\n const band = {\n x: bounds.x - reach * 4,\n y: bounds.y - reach,\n width: bounds.width + reach * 8,\n height: bounds.height + reach * 2,\n }\n const avoid = nodes.length\n ? undefined\n : shownNodes.value\n .filter((node) => !hiddenIn.value.has(node.id) && !isContainer(node))\n .map((node) => rectOf(node))\n .filter((rect) => intersects(rect, band))\n .map(screen)\n return {\n x: (bounds.x + bounds.width / 2) * zoom + x,\n top: bounds.y * zoom + y,\n bottom: (bounds.y + bounds.height) * zoom + y,\n avoid,\n }\n})\n\n// Dropping an edge on the canvas offers the node types that can take it.\nconst quickAdd = shallowRef<{\n link: Link\n at: Point\n client: Point\n types: NodeTypeSpec[]\n touch: boolean\n} | null>(null)\n\nfunction chooseQuickAdd(type: string | null) {\n const pendingAdd = quickAdd.value\n quickAdd.value = null\n if (!pendingAdd) return\n if (type) insertNode(type, { at: pendingAdd.at, from: pendingAdd.link.fixed })\n else root.value?.focus({ preventScroll: true })\n}\n\n// Dragging a new node in, from anywhere on the page --------------------------------\n\nconst insertDrag = shallowRef<{\n spec: NodeTypeSpec\n id: number\n touch: boolean\n start: Point\n client: Point\n started: boolean\n /** Whether letting go here adds the node. */\n over: boolean\n} | null>(null)\nlet pickUpTimer: ReturnType<typeof setTimeout> | undefined\n/** Keeps a finger that picked a node up from scrolling the page. */\nlet insertHold: ReturnType<typeof holdPointer> | undefined\n\n/** Whether a point on screen is on the canvas itself, not on a panel over it. */\nfunction onCanvasAt(client: Point) {\n const el = document.elementFromPoint(client.x, client.y)\n return !!root.value && !!el && root.value.contains(el) && !inPanels(el)\n}\n\nfunction startInsertDrag(type: string, event: PointerEvent) {\n const spec = types.value.get(type)\n if (!spec || props.readonly || event.button !== 0 || insertDrag.value) return false\n const client = { x: event.clientX, y: event.clientY }\n insertDrag.value = {\n spec,\n id: event.pointerId,\n touch: event.pointerType === 'touch',\n start: client,\n client,\n started: false,\n over: false,\n }\n // The source may be anywhere, so the pointer is followed on the window.\n window.addEventListener('pointermove', onInsertMove, true)\n window.addEventListener('pointerup', onInsertUp, true)\n window.addEventListener('pointercancel', onInsertUp, true)\n window.addEventListener('keydown', onInsertKey, true)\n window.addEventListener('contextmenu', onInsertMenu, true)\n insertHold = holdPointer(event)\n // A finger that slides at once scrolls the palette; one that rests picks the node up.\n if (event.pointerType === 'touch')\n pickUpTimer = setTimeout(() => {\n const d = insertDrag.value\n if (!d || d.started) return\n insertHold?.grab()\n insertDrag.value = { ...d, started: true, over: onCanvasAt(d.client) }\n }, PICK_UP)\n return true\n}\n\n// A long press would open the browser's menu for the source.\nfunction onInsertMenu(event: Event) {\n event.preventDefault()\n}\n\nfunction onInsertMove(event: PointerEvent) {\n const d = insertDrag.value\n if (!d || d.id !== event.pointerId) return\n const client = { x: event.clientX, y: event.clientY }\n if (!d.started) {\n const moved = Math.hypot(client.x - d.start.x, client.y - d.start.y)\n if (moved < THRESHOLD[d.touch ? 'touch' : 'mouse']) return\n // a finger that moves before the node is picked up is scrolling\n if (d.touch) return stopInsertDrag()\n }\n insertDrag.value = { ...d, client, started: true, over: onCanvasAt(client) }\n}\n\nfunction onInsertUp(event: PointerEvent) {\n const d = insertDrag.value\n if (!d || d.id !== event.pointerId) return\n stopInsertDrag()\n if (!d.started) return\n swallowClick()\n const client = { x: event.clientX, y: event.clientY }\n if (event.type === 'pointerup' && onCanvasAt(client))\n insertNode(d.spec.type, { at: toCanvas(client) })\n}\n\n// Escape puts the node back.\nfunction onInsertKey(event: KeyboardEvent) {\n if (event.key !== 'Escape' || !insertDrag.value) return\n event.preventDefault()\n event.stopPropagation()\n if (insertDrag.value.started) swallowClick()\n stopInsertDrag()\n}\n\nfunction stopInsertDrag() {\n insertDrag.value = null\n clearTimeout(pickUpTimer)\n insertHold?.release()\n insertHold = undefined\n window.removeEventListener('contextmenu', onInsertMenu, true)\n window.removeEventListener('pointermove', onInsertMove, true)\n window.removeEventListener('pointerup', onInsertUp, true)\n window.removeEventListener('pointercancel', onInsertUp, true)\n window.removeEventListener('keydown', onInsertKey, true)\n}\n\nconst stopEvent = (event: Event) => {\n event.stopPropagation()\n event.preventDefault()\n}\n\n/** The click that ends a drag isn't a click on the source: it doesn't add another node. */\nfunction swallowClick() {\n window.addEventListener('click', stopEvent, { capture: true, once: true })\n setTimeout(() => window.removeEventListener('click', stopEvent, true))\n}\n\nonBeforeUnmount(stopInsertDrag)\n\nconst ghostStyle = computed(() => {\n const d = insertDrag.value\n return d ? { transform: `translate(${d.client.x}px, ${d.client.y}px)` } : undefined\n})\n\nfunction onPointerUp(event: PointerEvent) {\n if (event.pointerType === 'touch') {\n touches.delete(event.pointerId)\n if (pinch) {\n if (touches.size < 2) pinch = null\n return\n }\n }\n const g = gesture.value\n if (!g || g.id !== event.pointerId) return\n const cancelled = event.type === 'pointercancel'\n if (g.kind === 'press' && !cancelled) {\n g.done = true\n g.handler.end?.(toCanvas({ x: event.clientX, y: event.clientY }), event)\n }\n if (!cancelled) {\n if (g.kind === 'pan' && !g.moved) {\n if (!g.additive) setSelection(emptySelection())\n emit('canvasClick', toCanvas({ x: event.clientX, y: event.clientY }), event)\n } else if (g.kind === 'box' && g.touch && g.current === g.start) {\n // Holding the empty canvas and letting go without moving opens its menu.\n endGesture()\n menuFromPointer(\n { kind: 'canvas', point: g.start },\n { x: event.clientX, y: event.clientY },\n event,\n )\n return\n } else if (g.kind === 'connect') {\n if (g.started) finishLink(g.link, event)\n else setSelection({ nodes: [g.node], edges: [] })\n } else if (g.kind === 'drag' && g.started) settleDrop(g)\n else if (g.kind === 'drag' && !g.started && !g.held) {\n const node = nodeById.value.get(g.node)\n if (g.additive && g.wasSelected)\n setSelection({\n nodes: selection.value.nodes.filter((id) => id !== g.node),\n edges: selection.value.edges,\n })\n else if (!g.additive && g.wasSelected) setSelection({ nodes: [g.node], edges: [] })\n if (node) emit('nodeClick', node, event)\n }\n }\n endGesture()\n}\n\nfunction endGesture() {\n const g = gesture.value\n // the press didn't end with the pointer let go: cancelled, by Escape or a second finger\n if (g?.kind === 'press' && !g.done) {\n g.done = true\n g.handler.cancel?.()\n }\n clearPress()\n cancelAnimationFrame(autoPanFrame)\n if (g && root.value?.hasPointerCapture(g.id)) root.value.releasePointerCapture(g.id)\n if ((g?.kind === 'drag' || g?.kind === 'bend') && g.started) history.close()\n if (g?.kind === 'resize') history.close()\n gesture.value = null\n guides.value = []\n dropTarget.value = null\n panning.value = false\n}\n\n/** Puts dragged nodes back where they started. */\nfunction cancelDrag() {\n const g = gesture.value\n if ((g?.kind !== 'drag' && g?.kind !== 'bend') || !g.started) return false\n const tr = new FlowTransaction(docOf()).setMeta({ history: false })\n if (g.kind === 'bend') tr.updateEdge(g.edge, { data: g.origin })\n else for (const [id, o] of g.origins) tr.moveNode(id, o.x, o.y)\n if (tr.changed) commit(tr)\n history.drop(g.group)\n syncHistory()\n endGesture()\n return true\n}\n\nfunction onWheel(event: WheelEvent) {\n const target = event.target as Element\n if (inPanels(target)) return\n if (quickAdd.value) chooseQuickAdd(null)\n const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? root.value!.clientHeight : 1\n let dx = event.deltaX * unit\n let dy = event.deltaY * unit\n if (event.ctrlKey || event.metaKey) {\n // Pinching a trackpad also arrives here, with small deltas.\n event.preventDefault()\n stopAnimation()\n const factor = 2 ** (-Math.max(-100, Math.min(100, dy)) / 240)\n zoomAround(viewport.value.zoom * factor, local(event.clientX, event.clientY))\n return\n }\n if (!props.panOnScroll || scrollsInside(target, dx, dy)) return\n event.preventDefault()\n stopAnimation()\n if (event.shiftKey && !dx) {\n dx = dy\n dy = 0\n }\n const { x, y, zoom } = viewport.value\n setViewport({ x: x - dx, y: y - dy, zoom })\n}\n\n/** Whether something scrollable inside a node can take the scroll itself. */\nfunction scrollsInside(target: Element, dx: number, dy: number) {\n let el: Element | null = target\n while (el && el !== root.value && !(el as HTMLElement).dataset?.nodeId) {\n const style = getComputedStyle(el)\n if (dy && /auto|scroll/.test(style.overflowY) && el.scrollHeight > el.clientHeight) {\n if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight) return true\n }\n if (dx && /auto|scroll/.test(style.overflowX) && el.scrollWidth > el.clientWidth) {\n if (dx < 0 ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true\n }\n el = el.parentElement\n }\n return false\n}\n\nfunction onDblclick(event: MouseEvent) {\n const nodeEl = (event.target as Element).closest<HTMLElement>('[data-node-id]')\n const node = nodeEl && nodeById.value.get(nodeEl.dataset.nodeId!)\n if (node) emit('nodeDblclick', node, event)\n else {\n const edgeEl = (event.target as Element).closest<Element>('[data-edge-id]')\n if (edgeEl) editLabel(edgeEl.getAttribute('data-edge-id')!)\n }\n}\n\nfunction onContextmenu(event: MouseEvent) {\n const target = event.target as Element\n if (inPanels(target)) return\n // A long press: the editor handles holding itself.\n if ((event as PointerEvent).pointerType === 'touch' || Date.now() - lastTouch < 1000) {\n event.preventDefault()\n return\n }\n // Fields inside nodes keep the browser's own menu.\n if (isEditable(target)) return\n const nodeEl = target.closest<HTMLElement>('[data-node-id]')\n const edgeEl = target.closest<Element>('[data-edge-id]')\n // From the keyboard there is no pointer: open by the focused node, or the middle.\n const keyboard = event.button === -1 || (event.clientX === 0 && event.clientY === 0)\n let client = { x: event.clientX, y: event.clientY }\n if (keyboard) {\n const box = (nodeEl ?? root.value!).getBoundingClientRect()\n client = nodeEl ? { x: box.left, y: box.bottom } : clientCenter()\n }\n const point = toCanvas(client)\n if (nodeEl) {\n const node = nodeById.value.get(nodeEl.dataset.nodeId!)\n if (!node) return\n emit('nodeContextmenu', node, event)\n if (event.defaultPrevented) return\n if (!selection.value.nodes.includes(node.id)) setSelection({ nodes: [node.id], edges: [] })\n menuFromPointer({ kind: 'node', node, point }, client, event)\n } else if (edgeEl) {\n const edge = doc.value.edges.find((e) => e.id === edgeEl.getAttribute('data-edge-id'))\n if (!edge) return\n emit('edgeContextmenu', edge, event)\n if (event.defaultPrevented) return\n if (!selection.value.edges.includes(edge.id)) setSelection({ nodes: [], edges: [edge.id] })\n menuFromPointer({ kind: 'edge', edge, point }, client, event)\n } else {\n emit('canvasContextmenu', point, event)\n if (event.defaultPrevented) return\n menuFromPointer({ kind: 'canvas', point }, client, event)\n }\n}\n\n/** Opens the menu for a pointer event, keeping the browser's own when there's nothing to show. */\nfunction menuFromPointer(target: FlowMenuTarget, client: Point, event: Event) {\n if (target.kind === 'canvas' && event.type !== 'contextmenu') {\n // A long press: the app may want it for its own menu.\n const menuEvent = new MouseEvent('contextmenu', {\n cancelable: true,\n clientX: client.x,\n clientY: client.y,\n })\n emit('canvasContextmenu', target.point, menuEvent)\n if (menuEvent.defaultPrevented) return\n }\n if (openMenu(target, client)) event.preventDefault()\n}\n\n// Keyboard ------------------------------------------------------------------------\n\nconst isEditable = (el: Element) =>\n !!el.closest('input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]')\n\n/** Nodes in the order Tab visits them: along the edges, left to right. */\nconst readingOrder = () => flowOrder(doc.value)\n\nfunction focusNode(id: string) {\n // Drawn even while out of view, until it is revealed.\n focusedNode.value = id\n setSelection({ nodes: [id], edges: [] })\n reveal(id)\n void nextTick(() =>\n root.value\n ?.querySelector<HTMLElement>(`[data-node-id=\"${CSS.escape(id)}\"]`)\n ?.focus({ preventScroll: true }),\n )\n}\n\n/** Room around grouped nodes; more on top for the title. */\nconst GROUP_PAD = { side: 24, top: 56 }\n\n/**\n * Puts nodes, the selected ones by default, into a new container of `type`\n * drawn around them, as one undo step; returns its id.\n */\nfunction groupNodes(ids: string[] = selection.value.nodes, type = 'group') {\n const spec = types.value.get(type)\n if (props.readonly || !spec?.container) return null\n const top = tree.value.topmost(ids.filter((id) => !hiddenIn.value.has(id)))\n const bounds = boundsOf(top.flatMap((id) => getNodeRect(id) ?? []))\n if (!bounds) return null\n // A frame around nodes from different groups would cut across them.\n const parents = new Set(top.map((id) => tree.value.parentOf(id)))\n if (parents.size > 1) {\n announce(messages.value.groupMixed)\n return null\n }\n const group: FlowNode = {\n ...draft(spec),\n x: snapTo(bounds.x - GROUP_PAD.side),\n y: snapTo(bounds.y - GROUP_PAD.top),\n width: snapTo(bounds.width + GROUP_PAD.side * 2),\n height: snapTo(bounds.height + GROUP_PAD.top + GROUP_PAD.side),\n // Inside the container they shared, if any.\n parent: [...parents][0],\n }\n transact((tr) => {\n // Before the nodes, so it is under them in the document's order too.\n const order = tr.doc.nodes.map((n) => n.id)\n const index = Math.min(...top.map((id) => order.indexOf(id)))\n tr.addNode(group, index)\n for (const id of top) tr.updateNode(id, { parent: group.id })\n })\n setSelection({ nodes: [group.id], edges: [] })\n announce(format(messages.value.added, { node: nameOf(group) }))\n return group.id\n}\n\n/** Removes a container and keeps what was inside it, as one undo step. */\nfunction ungroupNode(id: string) {\n const node = nodeById.value.get(id)\n if (props.readonly || !node || !isContainer(node)) return false\n const children = tree.value.children.get(id) ?? []\n const name = nameOf(node)\n transact((tr) => {\n for (const child of children) tr.updateNode(child, { parent: node.parent })\n tr.removeNode(id)\n })\n setSelection({ nodes: [...children], edges: [] })\n announce(format(messages.value.ungrouped, { group: name }))\n return true\n}\n\n/**\n * Takes nodes, the selected ones by default, out of the containers they\n * are in and puts them just below, as one undo step; returns how many moved.\n */\nfunction leaveGroup(ids: string[] = selection.value.nodes) {\n if (props.readonly) return 0\n const byParent = new Map<string, string[]>()\n for (const id of tree.value.topmost(ids)) {\n const parent = tree.value.parentOf(id)\n if (parent && !nodeById.value.get(id)?.locked)\n byParent.set(parent, [...(byParent.get(parent) ?? []), id])\n }\n if (!byParent.size) return 0\n let count = 0\n transact((tr) => {\n for (const [parent, list] of byParent) {\n const frame = getNodeRect(parent)!\n const top = Math.min(...list.map((id) => nodeById.value.get(id)!.y))\n const dy = snapTo(frame.y + frame.height + GROUP_PAD.side - top)\n for (const id of list) {\n tr.updateNode(id, { parent: nodeById.value.get(parent)!.parent })\n for (const inner of [id, ...tree.value.descendants(id)]) {\n const node = tr.getNode(inner)!\n tr.moveNode(inner, node.x, node.y + dy)\n }\n count++\n }\n }\n })\n if (byParent.size === 1)\n announce(format(messages.value.leftGroup, { group: nodeName([...byParent.keys()][0]!) }))\n return count\n}\n\nfunction deleteSelection() {\n const { edges } = selection.value\n // A container goes with what is inside it.\n const nodes = withInside(selection.value.nodes)\n let count = 0\n transact((tr) => {\n for (const id of edges) {\n if (tr.getEdge(id)) {\n tr.removeEdge(id)\n count++\n }\n }\n for (const id of nodes) {\n const node = tr.getNode(id)\n if (node && !node.locked) {\n tr.removeNode(id)\n count++\n }\n }\n })\n if (count) {\n root.value?.focus({ preventScroll: true })\n announce(format(messages.value.deleted, { count }))\n }\n}\n\nfunction nudge(dx: number, dy: number) {\n const ids = selection.value.nodes\n const moving = carried(ids)\n transact(\n (tr) => {\n for (const id of moving) {\n const node = tr.getNode(id)\n if (node) tr.moveNode(id, node.x + dx, node.y + dy)\n }\n },\n { group: 'nudge' },\n )\n // As with a drag: nodes go into the container their middle is over, or out of theirs.\n const others = containerRects(moving)\n const settle = new Map<string | undefined, string[]>()\n for (const id of tree.value.topmost(moving)) {\n const rect = getNodeRect(id)\n if (!rect) continue\n const target = containerAt({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, others)\n settle.set(target, [...(settle.get(target) ?? []), id])\n }\n for (const [target, list] of settle) reparent(list, target, 'nudge')\n if (ids.length === 1) reveal(ids[0]!)\n}\n\n/** Resizes the selected resizable nodes from the keyboard. */\nfunction resizeBy(dw: number, dh: number) {\n const nodes = selection.value.nodes.flatMap((id) => {\n const node = nodeById.value.get(id)\n return node && resizeOf(specOf(node), node) && !node.locked ? [node] : []\n })\n if (!nodes.length) return false\n const sizes = nodes.map((node) => {\n const min = resizeOf(specOf(node), node)!\n const rect = rectOf(node)\n return {\n node,\n width: Math.max(min.minWidth ?? 40, rect.width + dw),\n height: Math.max(min.minHeight ?? 40, rect.height + dh),\n }\n })\n transact(\n (tr) => {\n for (const { node, width, height } of sizes) tr.updateNode(node.id, { width, height })\n },\n { group: 'resize-keys' },\n )\n if (sizes.length === 1) {\n const { node, width, height } = sizes[0]!\n announce(format(messages.value.resized, { node: nameOf(node), width, height }))\n }\n return true\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n const target = event.target as Element\n if (isEditable(target) || inPanels(target)) return\n // Keys typed into a node's own controls are theirs, Escape for their popups included.\n const nodeEl = target.closest('[data-node-id]')\n if (nodeEl && nodeEl !== target) return\n if (keyLink.value) return onKeyLink(event)\n // Escape lets go of a press an extension took over, before its keys are asked\n if (event.key === 'Escape' && gesture.value?.kind === 'press') {\n event.preventDefault()\n endGesture()\n return\n }\n for (const keymap of keymaps.value)\n for (const [hotkey, handler] of Object.entries(keymap))\n if (matchesHotkey(event, hotkey) && handler(event) !== false) {\n event.preventDefault()\n return\n }\n const mod = event.metaKey || event.ctrlKey\n const key = event.key.length === 1 ? event.key.toLowerCase() : event.key\n const onCanvas = target === root.value || !!(target as HTMLElement).dataset?.nodeId\n\n if (mod) {\n let handled = true\n if (key === 'z' && !props.readonly) {\n if (event.shiftKey) redo()\n else undo()\n } else if (key === 'y' && !props.readonly) redo()\n else if (key === 'a') selectAll()\n // Ctrl/⌘ + G is for groups here, so the next match is F3 only\n else if (key === 'f' && !event.altKey && props.searchable) openSearch()\n // Copy, cut and paste come as clipboard events; see onClipboard.\n else if (key === 'c' || key === 'x' || key === 'v') return\n else if (key === 'd' && !props.readonly) {\n if (!selection.value.nodes.length) return\n duplicate()\n } else if (key === '=' || key === '+') zoomIn()\n else if (key === '-') zoomOut()\n else if (key === '0') zoomTo(1)\n else handled = false\n if (handled) event.preventDefault()\n return\n }\n // Alt only goes with the arrow keys, for resizing.\n if (event.altKey && !key.startsWith('Arrow')) return\n\n switch (key) {\n case 'F3':\n if (!stepFound(event.shiftKey ? -1 : 1)) return\n break\n case 'Tab': {\n if (!onCanvas) return\n const order = readingOrder()\n if (!order.length) return\n const current = (target as HTMLElement).dataset?.nodeId\n const index = current ? order.findIndex((n) => n.id === current) : -1\n if (event.shiftKey) {\n if (index > 0) focusNode(order[index - 1]!.id)\n else if (index === 0) root.value!.focus({ preventScroll: true })\n else return\n } else if (index < order.length - 1) focusNode(order[index + 1]!.id)\n else {\n // Past the last node: leave the canvas the usual way.\n end.value?.focus({ preventScroll: true })\n return\n }\n event.preventDefault()\n return\n }\n case 'Escape':\n if (cancelDrag() || closeSearch()) break\n if (!selection.value.nodes.length && !selection.value.edges.length) return\n setSelection(emptySelection())\n break\n case 'Enter':\n case 'F2': {\n const { nodes, edges } = selection.value\n if (props.readonly || nodes.length || edges.length !== 1) return\n editLabel(edges[0]!)\n break\n }\n case 'Delete':\n case 'Backspace':\n if (props.readonly) return\n deleteSelection()\n break\n case 'ArrowLeft':\n case 'ArrowRight':\n case 'ArrowUp':\n case 'ArrowDown': {\n const sign = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1\n const horizontal = key === 'ArrowLeft' || key === 'ArrowRight'\n if (selection.value.nodes.length && !props.readonly) {\n const step = (props.grid || 1) * (event.shiftKey ? 4 : 1)\n // Alt: wider or narrower, taller or shorter, for nodes that can be resized.\n if (event.altKey) {\n if (!resizeBy(horizontal ? sign * step : 0, horizontal ? 0 : sign * step)) return\n } else nudge(horizontal ? sign * step : 0, horizontal ? 0 : sign * step)\n } else {\n const step = event.shiftKey ? 160 : 40\n const { x, y, zoom } = viewport.value\n setViewport(\n { x: horizontal ? x - sign * step : x, y: horizontal ? y : y - sign * step, zoom },\n true,\n )\n }\n break\n }\n case ' ':\n if (!onCanvas) return\n spaceHeld.value = true\n break\n case 'c':\n if (props.readonly || selection.value.nodes.length !== 1) return\n startKeyLink(selection.value.nodes[0]!)\n break\n case 'e':\n if (!cycleEdges(event.shiftKey ? -1 : 1)) return\n break\n default:\n return\n }\n event.preventDefault()\n}\n\nfunction onKeyup(event: KeyboardEvent) {\n if (event.key === ' ') spaceHeld.value = false\n}\n\nconst end = useTemplateRef<HTMLElement>('end')\n\n// Announcements for screen readers.\nconst live = ref('')\n/** Set while a cancelled change is being told of, so what follows doesn't say it was made. */\nlet refusing = false\nfunction announce(text: string) {\n if (refusing) return\n live.value = ''\n void nextTick(() => (live.value = text))\n}\n/** Why the last change didn't go ahead, shown for a moment over the editor. */\nconst notice = ref('')\nlet noticeTimer: ReturnType<typeof setTimeout> | undefined\nonBeforeUnmount(() => clearTimeout(noticeTimer))\n/** Says why a change didn't go ahead, once while the same one repeats, as in a drag. */\nlet lastRefusal = { text: '', at: 0 }\nfunction refuse(text: string) {\n const at = Date.now()\n if (refusing || (text === lastRefusal.text && at - lastRefusal.at < 1000)) return\n lastRefusal = { text, at }\n announce(text)\n notice.value = text\n clearTimeout(noticeTimer)\n noticeTimer = setTimeout(() => (notice.value = ''), 3200)\n refusing = true\n void nextTick(() => (refusing = false))\n}\n\n// Find -----------------------------------------------------------------------------\n\nconst findOpen = ref(false)\nconst findQuery = ref<FlowSearchQuery>({ search: '', caseSensitive: false })\nconst findPanel = ref<InstanceType<typeof DouFindReplace>>()\n\n/** The nodes whose names contain the text, in reading order. */\nfunction findNodes({ search, caseSensitive }: FlowSearchQuery) {\n if (!search) return []\n const fold = (text: string) => (caseSensitive ? text : text.toLowerCase())\n const wanted = fold(search)\n return readingOrder()\n .filter((node) => fold(nameOf(node)).includes(wanted))\n .map((node) => node.id)\n}\n\nconst found = computed(() => (findOpen.value ? findNodes(findQuery.value) : []))\n/** The match that is the one node selected; `-1` when none is. */\nconst currentFound = computed(() => {\n const { nodes } = selection.value\n return nodes.length === 1 ? found.value.indexOf(nodes[0]!) : -1\n})\n/** The matches' boxes, in canvas coordinates, to mark them. */\nconst foundRects = computed(() =>\n found.value.flatMap((id) => {\n const rect = getNodeRect(id)\n return rect ? [{ id, rect, rotation: nodeRotation(id) }] : []\n }),\n)\n\n/** Selects a match and brings it to the middle of the view. */\nfunction goToFound(index: number) {\n const id = found.value[index]\n if (!id) return\n setSelection({ nodes: [id], edges: [] })\n centerNode(id)\n const node = nodeById.value.get(id)\n if (node) announce(nameOf(node))\n}\n\nfunction stepFound(step: 1 | -1) {\n const list = found.value\n if (!list.length) return false\n const i = currentFound.value\n goToFound(i >= 0 ? (i + step + list.length) % list.length : step > 0 ? 0 : list.length - 1)\n return true\n}\n\n/** Opens the panel, with a query put in. */\nfunction openSearch(query?: Partial<FlowSearchQuery>) {\n if (!props.searchable) return false\n if (query) findQuery.value = { ...findQuery.value, ...query }\n findOpen.value = true\n void nextTick(() => findPanel.value?.focus())\n return true\n}\n\nfunction closeSearch() {\n if (!findOpen.value) return false\n findOpen.value = false\n root.value?.focus({ preventScroll: true })\n return true\n}\n\n// The panel sits top right, below whatever else is there, such as a toolbar or controls.\nconst panelsLayer = useTemplateRef<HTMLElement>('panelsLayer')\nconst findBox = useTemplateRef<HTMLElement>('findBox')\nconst FIND_INSET = 12\nconst findTop = ref(FIND_INSET)\nfunction placeFind() {\n const layer = panelsLayer.value\n const box = findBox.value\n const panel = box?.firstElementChild as HTMLElement | null | undefined\n if (!layer || !box || !panel) return\n const area = layer.getBoundingClientRect()\n const width = panel.offsetWidth\n const height = panel.offsetHeight\n const right = area.right - FIND_INSET\n const left = right - width\n const others = [...layer.children]\n // the selection's toolbar moves with the nodes; the panel stays put for it\n .filter((el) => el !== box && !el.classList.contains('dou-flow-toolbar'))\n .map((el) => el.getBoundingClientRect())\n .filter((r) => r.width && r.height)\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts the list built here\n .sort((a, b) => a.top - b.top)\n let top = area.top + FIND_INSET\n for (const r of others)\n if (r.left < right && r.right > left && r.top < top + height && r.bottom > top)\n top = r.bottom + 8\n findTop.value = Math.round(top - area.top)\n}\n\n// typing a search selects the first match, as a preview\nwatch(\n () => [findQuery.value.search, findQuery.value.caseSensitive],\n () => {\n if (findOpen.value && found.value.length && currentFound.value < 0) goToFound(0)\n },\n)\n\n// Lifecycle --------------------------------------------------------------------------\n\n// Popups opened from inside nodes follow them: they reposition on scroll.\nwatch(viewport, () => root.value?.dispatchEvent(new Event('scroll')))\n\n/**\n * Copy, cut and paste while the canvas or a node has focus. Going through the\n * clipboard events puts the nodes on the system clipboard without asking for\n * permission.\n */\nfunction onClipboard(event: ClipboardEvent) {\n const active = document.activeElement\n if (!active || !root.value?.contains(active)) return\n if (active !== root.value && !(active as HTMLElement).dataset?.nodeId) return\n if (event.type === 'paste') {\n if (props.readonly) return\n const data = readClipboard(event.clipboardData?.getData('text/plain'))\n if (!data) return\n event.preventDefault()\n paste(data, hover ? toCanvas(hover) : null)\n return\n }\n const data = copySelection()\n if (!data) return\n event.preventDefault()\n event.clipboardData?.setData('text/plain', writeClipboard(data))\n pastes = 0\n if (event.type === 'cut' && !props.readonly) deleteSelection()\n}\n\nconst clipboardEvents = ['copy', 'cut', 'paste'] as const\n\nconst pointerListeners = [\n ['pointermove', onPointerMove],\n ['pointerup', onPointerUp],\n ['pointercancel', onPointerUp],\n] as const\n\n/** Keeps the page from scrolling while a finger drags or pinches in the editor. */\nfunction holdTouch(event: TouchEvent) {\n if ((gesture.value?.touch || pinch) && event.cancelable) event.preventDefault()\n}\n\n/** The editor's size on screen, for the minimap. */\nconst size = ref({ width: 0, height: 0 })\n// the find panel finds its place again as the editor changes size\nwatch([findOpen, size], () => findOpen.value && void nextTick(placeFind), { flush: 'post' })\nlet sizeObserver: ResizeObserver | undefined\n\nonMounted(() => {\n const el = root.value!\n size.value = { width: el.clientWidth, height: el.clientHeight }\n if (typeof ResizeObserver !== 'undefined') {\n sizeObserver = new ResizeObserver(() => {\n size.value = { width: el.clientWidth, height: el.clientHeight }\n })\n sizeObserver.observe(el)\n }\n root.value!.addEventListener('wheel', onWheel, { passive: false })\n for (const type of clipboardEvents) document.addEventListener(type, onClipboard)\n // On the window: a pressed element that is drawn again mid-gesture leaves the\n // document, and its pointer events no longer bubble up to the editor.\n for (const [type, fn] of pointerListeners) window.addEventListener(type, fn, true)\n window.addEventListener('touchmove', holdTouch, { passive: false })\n})\nonBeforeUnmount(() => {\n sizeObserver?.disconnect()\n cancelAnimationFrame(tweenFrame)\n for (const type of clipboardEvents) document.removeEventListener(type, onClipboard)\n root.value?.removeEventListener('wheel', onWheel)\n for (const [type, fn] of pointerListeners) window.removeEventListener(type, fn, true)\n window.removeEventListener('touchmove', holdTouch)\n stopInsertDrag()\n stopAnimation()\n endGesture()\n})\n\n// Selection box and guides, drawn in screen space so they stay 1px.\nconst marquee = computed<CSSProperties | null>(() => {\n const g = gesture.value\n if (g?.kind !== 'box') return null\n const { x, y, zoom } = viewport.value\n const box = rectFrom(g.start, g.current)\n return {\n transform: `translate(${box.x * zoom + x}px, ${box.y * zoom + y}px)`,\n width: `${box.width * zoom}px`,\n height: `${box.height * zoom}px`,\n }\n})\n\n/** A gesture that changes the document as it goes: moving nodes, resizing, bending an edge. */\nconst gesturing = computed(() => {\n const g = gesture.value\n if (g?.kind === 'resize') return true\n return (g?.kind === 'drag' || g?.kind === 'bend') && g.started\n})\n\nconst dragging = computed(() => {\n const g = gesture.value\n return g?.kind === 'drag' && g.started ? new Set(g.origins.keys()) : null\n})\n\n// Drawing only what is in view ----------------------------------------------------\n\n/** How far past the editor's edges nodes and edges are still drawn, in screen px. */\nconst DRAW_MARGIN = 200\n\n/** The part of the canvas that is drawn: the view and a margin around it. */\nconst drawnArea = computed<Rect | null>(() => {\n const { width, height } = size.value\n if (!width || !height) return null\n const { x, y, zoom } = viewport.value\n return {\n x: (-x - DRAW_MARGIN) / zoom,\n y: (-y - DRAW_MARGIN) / zoom,\n width: (width + DRAW_MARGIN * 2) / zoom,\n height: (height + DRAW_MARGIN * 2) / zoom,\n }\n})\n\n/** The node holding focus, kept drawn when it scrolls away. */\nconst focusedNode = ref<string | null>(null)\nfunction onFocusin(event: FocusEvent) {\n const nodeEl = (event.target as Element).closest<HTMLElement>('[data-node-id]')\n focusedNode.value = nodeEl?.dataset.nodeId ?? null\n}\n\n/**\n * Nodes in view. A node not measured yet is drawn wherever it is, so it gets\n * a size; selected and focused ones too, so dragging and keys keep working.\n */\nconst drawnNodes = computed(() => {\n const area = drawnArea.value\n if (!area) return shownNodes.value.filter((node) => !hiddenIn.value.has(node.id))\n const keep = selectedNodes.value\n const hidden = hiddenIn.value\n return shownNodes.value.filter((node) => {\n if (hidden.has(node.id)) return false\n const m = metrics.get(node.id)\n if (!m || keep.has(node.id) || node.id === focusedNode.value) return true\n const rect = { x: node.x, y: node.y, width: m.width, height: m.height }\n return intersects(nodeBounds(specOf(node), node, rect), area)\n })\n})\n\n/** Containers, drawn under the edges: outer ones first. */\nconst drawnContainers = computed(() =>\n drawnNodes.value\n .filter(isContainer)\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh array\n .sort((a, b) => tree.value.depth(a.id) - tree.value.depth(b.id)),\n)\n/** The other nodes, lowest `z` first; equal ones in document order. */\nconst drawnOthers = computed(() => {\n const others = drawnNodes.value.filter((node) => !isContainer(node))\n if (!others.some((node) => node.z)) return others\n const index = new Map(others.map((node, i) => [node.id, i]))\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh array\n return others.sort((a, b) => (a.z ?? 0) - (b.z ?? 0) || index.get(a.id)! - index.get(b.id)!)\n})\n\n/** Zoomed out this far, measured nodes are drawn as plain boxes. */\nconst simple = computed(() => viewport.value.zoom < props.simpleZoom)\n/** Names below this zoom would be a few px tall: only the boxes are drawn. */\nconst TINY = 0.2\n\nconst classes = computed(() => ({\n 'is-simple': simple.value,\n 'is-tiny': simple.value && viewport.value.zoom < TINY,\n 'is-readonly': props.readonly,\n 'is-panning': panning.value,\n 'is-space': spaceHeld.value,\n 'is-dragging': !!dragging.value,\n 'is-connecting': !!connecting.value,\n 'is-refused': !!connecting.value?.refused,\n}))\n\nconst context: FlowEditorContext = {\n exportSVG,\n exportPNG,\n doc: currentDoc,\n selection,\n viewport,\n setViewport,\n readonly: computed(() => props.readonly),\n messages,\n minZoom: computed(() => props.minZoom),\n maxZoom: computed(() => props.maxZoom),\n transact,\n undo,\n redo,\n canUndo,\n canRedo,\n fit,\n zoomTo,\n zoomIn,\n zoomOut,\n portTypeOf,\n colorOf,\n getNodeRect,\n tidy,\n centerNode,\n announce,\n size,\n selectedNodes: computed(() => new Set(selection.value.nodes)),\n root,\n insertNode,\n paletteTypes,\n startInsertDrag,\n editLabel,\n groupNodes,\n ungroupNode,\n leaveGroup,\n tree,\n isContainer,\n nodeRotation,\n gesturing,\n previewNode,\n connectingPath: previewPath,\n openMenu,\n menuItems: itemsFor,\n showMenu,\n menuFrom,\n toCanvas,\n copy,\n cut,\n paste: pasteFromClipboard,\n duplicate,\n deleteSelection,\n selectAll,\n}\nprovide(flowEditorKey, context)\n\n// Extensions that hook into the editor's life: set up once mounted, again when the list changes.\nlet cleanups: (() => void)[] = []\nlet mounted = false\nfunction teardownExtensions() {\n for (const cleanup of cleanups.splice(0)) cleanup()\n customHistory = null\n}\nlet setUp = false\nlet setupTimes: number[] = []\nlet setupWarned = false\n/** An extension made in the template is new on every render, and set up again each time. */\nfunction warnRepeatedSetup() {\n if (process.env.NODE_ENV === 'production' || setupWarned) return\n const now = Date.now()\n setupTimes = [...setupTimes.filter((time) => now - time < 1000), now]\n if (setupTimes.length < 4) return\n setupWarned = true\n console.warn(\n '[@dou-ui/flow] the extensions keep being set up again, which also clears undo each time. Create them once in <script setup> instead of in the template.',\n )\n}\nfunction setupExtensions() {\n if (setUp) warnRepeatedSetup()\n setUp = true\n teardownExtensions()\n customHistory = resolved.value.history?.(context) ?? null\n for (const setup of resolved.value.setups) {\n const cleanup = setup(context)\n if (typeof cleanup === 'function') cleanups.push(cleanup)\n }\n syncHistory()\n}\nonMounted(() => {\n mounted = true\n setupExtensions()\n})\n// A list written inline is new on every render; set up again only when the hooks change.\nwatch(\n () => [resolved.value.history, ...resolved.value.setups],\n (hooks, before) => {\n if (mounted && (hooks.length !== before.length || hooks.some((hook, i) => hook !== before[i])))\n setupExtensions()\n },\n)\nonBeforeUnmount(teardownExtensions)\n\nconst keymaps = computed(() => resolved.value.keymaps.map((keymap) => keymap(context)))\n\nconst selectedNodes = computed(() => new Set(selection.value.nodes))\n\nfunction updateData(id: string, patch: Record<string, unknown>) {\n transact((tr) => tr.updateData(id, patch), { group: `data:${id}` })\n}\n\n// Export -----------------------------------------------------------------------------\n\n/** The flow drawn as SVG, with image addresses turned by `imageHref`. */\nfunction drawing(options: FlowExportOptions, imageHref?: (src: string) => string | null) {\n const el = root.value\n if (!el) throw new Error('[@dou-ui/flow] the editor is not mounted')\n let keep: Set<string> | null = null\n let frame: Rect | undefined\n if (options.range === 'selection') {\n if (!selection.value.nodes.length)\n throw new Error('[@dou-ui/flow] nothing is selected to export')\n keep = new Set<string>()\n for (const id of selection.value.nodes) {\n keep.add(id)\n for (const inner of tree.value.descendants(id)) keep.add(inner)\n }\n } else if (options.range === 'view') {\n const { x, y, zoom } = viewport.value\n frame = {\n x: -x / zoom,\n y: -y / zoom,\n width: size.value.width / zoom,\n height: size.value.height / zoom,\n }\n }\n const hidden = hiddenIn.value\n const nodes = shownNodes.value.filter((node) => {\n if (hidden.has(node.id) || (keep && !keep.has(node.id))) return false\n if (!frame) return true\n return intersects(nodeBounds(specOf(node), node, rectOf(node)), frame)\n })\n const index = new Map(nodes.map((node, i) => [node.id, i]))\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh array\n const ordered = nodes.sort((a, b) => {\n const ca = isContainer(a)\n const cb = isContainer(b)\n if (ca !== cb) return ca ? -1 : 1\n if (ca)\n return tree.value.depth(a.id) - tree.value.depth(b.id) || index.get(a.id)! - index.get(b.id)!\n return (a.z ?? 0) - (b.z ?? 0) || index.get(a.id)! - index.get(b.id)!\n })\n const tools = drawTools(el, messages.value, imageHref)\n const empty = new Map<string, PortAnchor>()\n try {\n const drawNodes: DrawNode[] = ordered.map((node) => ({\n node,\n spec: specOf(node),\n rect: rectOf(node),\n ports: metrics.get(node.id)?.ports ?? empty,\n container: isContainer(node),\n label: nameOf(node),\n port: (id) => drawPort(node, id, tools),\n }))\n const stale = staleEdges.value\n const edges: DrawEdge[] = []\n for (const item of edgeRoutes.value) {\n if (\n keep &&\n !(keep.has(edgeEnd(item.edge.source).node) && keep.has(edgeEnd(item.edge.target).node))\n )\n continue\n edges.push({\n edge: item.edge,\n type: edgeTypeOf(item.edge.type).type,\n route: item.route,\n style: item.style,\n stale: stale.has(item.edge.id),\n })\n }\n const out = drawFlow(\n {\n nodes: drawNodes,\n edges,\n padding: options.padding ?? 24,\n background: options.background ?? true,\n frame,\n layers: resolved.value.drawLayers.map(\n (fn) => (draw: FlowDrawContext) => fn(context, draw, ordered),\n ),\n },\n tools,\n el,\n )\n return { ...out, images: tools.images }\n } finally {\n tools.dispose()\n }\n}\n\n/** A port as `flow-port` shows it, at rest. */\nfunction drawPort(node: FlowNode, id: string, tools: DrawTools) {\n const at = metrics.get(node.id)?.ports.get(id)\n const spec = portsOf(specOf(node), node).find((port) => port.id === id)\n if (!at || !spec) return ''\n const type = portTypeOf(spec)\n const ring = tools.color(colorOf(type) ?? 'var(--dou-flow-editor-port)')\n const fill = connected.value.get(node.id)?.has(id)\n ? ring\n : tools.color('var(--dou-flow-editor-node-bg)')\n const paint = `fill=\"${fill}\" stroke=\"${ring}\" stroke-width=\"1\"`\n const x = Math.round(at.x * 100) / 100\n const y = Math.round(at.y * 100) / 100\n const shape = type?.shape ?? 'circle'\n if (shape === 'square')\n return `<rect x=\"${x - 3.5}\" y=\"${y - 3.5}\" width=\"7\" height=\"7\" rx=\"1.5\" ${paint}/>`\n if (shape === 'triangle')\n return `<path d=\"M${x - 2.4} ${y - 2.8}L${x + 2.4} ${y}L${x - 2.4} ${y + 2.8}Z\" ${paint} stroke-linejoin=\"round\"/>`\n return `<circle cx=\"${x}\" cy=\"${y}\" r=\"3.5\" ${paint}/>`\n}\n\nfunction exportSVG(options: FlowExportOptions = {}) {\n return drawing(options).svg\n}\n\n/** Image addresses this page couldn't read, not asked for again. */\nconst unreadable = new Set<string>()\n\nconst PNG_SOURCE = {\n scope: '@dou-ui/flow',\n hint: \"export range 'view' or 'selection' a part at a time, or use exportSVG()\",\n}\n\nasync function exportPNG(options: FlowImageOptions = {}) {\n // a picture drawn from SVG can't load other files: images go in as data\n const { images, width: w, height: h } = drawing(options)\n // too big for a canvas: fail before reading the images\n fitPixelRatio(w, h, options.pixelRatio, PNG_SOURCE)\n const read = new Map<string, string | null>()\n await Promise.all(\n [...images].map(async (src) => {\n const data = unreadable.has(src) ? null : await readImage(src)\n if (data === null) unreadable.add(src)\n read.set(src, data)\n }),\n )\n const { svg, width, height } = drawing(options, (src) => read.get(src) ?? null)\n return svgToPNG(svg, { width, height, pixelRatio: options.pixelRatio, ...PNG_SOURCE })\n}\n\ndefineExpose<FlowEditorExpose>({\n setViewport,\n openSearch,\n closeSearch,\n findNodes,\n exportSVG,\n exportPNG,\n transact,\n undo,\n redo,\n get canUndo() {\n return canUndo.value\n },\n get canRedo() {\n return canRedo.value\n },\n fit,\n zoomTo,\n zoomIn,\n zoomOut,\n centerNode,\n toCanvas,\n getNodeRect,\n tidy,\n get staleEdges() {\n return [...staleEdges.value]\n },\n removeStaleEdges,\n insertNode,\n startInsertDrag,\n editLabel,\n groupNodes,\n ungroupNode,\n leaveGroup,\n get paletteTypes() {\n return paletteTypes.value\n },\n})\n</script>\n\n<template>\n <div\n ref=\"root\"\n class=\"dou-flow-editor\"\n :class=\"classes\"\n :style=\"background\"\n role=\"application\"\n :aria-label=\"ariaLabel\"\n :aria-description=\"messages.canvas\"\n tabindex=\"0\"\n @pointerdown=\"onPointerDown\"\n @pointerleave=\"hover = null\"\n @dblclick=\"onDblclick\"\n @selectstart=\"onSelectstart\"\n @contextmenu=\"onContextmenu\"\n @keydown=\"onKeydown\"\n @keyup=\"onKeyup\"\n @blur.capture=\"spaceHeld = false\"\n @focusin=\"onFocusin\"\n >\n <div class=\"dou-flow-editor__layer\" :style=\"layerStyle\">\n <component :is=\"layer\" v-for=\"(layer, i) in resolved.below\" :key=\"`below-${i}`\" />\n <div\n v-for=\"mark in foundRects\"\n :key=\"`found-${mark.id}`\"\n class=\"dou-flow-editor__found\"\n :class=\"{ 'is-current': found[currentFound] === mark.id }\"\n :style=\"{\n translate: `${mark.rect.x}px ${mark.rect.y}px`,\n rotate: mark.rotation ? `${mark.rotation}deg` : undefined,\n width: `${mark.rect.width}px`,\n height: `${mark.rect.height}px`,\n }\"\n aria-hidden=\"true\"\n />\n <!-- Containers sit under the edges. -->\n <template v-for=\"node in drawnContainers\" :key=\"node.id\">\n <div\n v-if=\"simple && metrics.has(node.id)\"\n class=\"dou-flow-editor__simple\"\n :class=\"{\n 'is-selected': selectedNodes.has(node.id),\n 'is-drop-target': dropTarget === node.id,\n }\"\n :style=\"{\n transform: `translate(${node.x}px, ${node.y}px)`,\n width: `${metrics.get(node.id)!.width}px`,\n height: `${metrics.get(node.id)!.height}px`,\n }\"\n :data-node-id=\"node.id\"\n role=\"group\"\n :aria-roledescription=\"roleOf(node)\"\n :aria-label=\"nameOf(node)\"\n tabindex=\"-1\"\n >\n <slot name=\"simple\" :node=\"node\">\n <span v-if=\"viewport.zoom >= TINY\" class=\"dou-flow-editor__simple-label\">{{\n labelOf(specOf(node), node, messages)\n }}</span>\n </slot>\n </div>\n <NodeView\n v-else\n :node=\"node\"\n :spec=\"specOf(node)\"\n :selected=\"selectedNodes.has(node.id)\"\n :readonly=\"readonly\"\n :dragging=\"!!dragging?.has(node.id)\"\n :connected=\"connected.get(node.id) ?? none\"\n :port-states=\"portStates ? (portStates.get(node.id) ?? noStates) : null\"\n :role-description=\"roleOf(node)\"\n :label=\"nameOf(node)\"\n :description=\"describe(node)\"\n :drop-target=\"dropTarget === node.id\"\n @measure=\"onMeasure\"\n @update=\"updateData\"\n />\n </template>\n <svg class=\"dou-flow-editor__edges\" aria-hidden=\"true\">\n <g\n v-for=\"item in edgePaths\"\n :key=\"item.edge.id\"\n :data-edge-id=\"item.edge.id\"\n :data-edge-type=\"edgeTypeOf(item.edge.type).type\"\n >\n <path class=\"dou-flow-editor__edge-hit\" :d=\"item.route.path\" />\n <path\n class=\"dou-flow-editor__edge\"\n :style=\"item.style\"\n :class=\"{\n 'is-selected': item.selected,\n 'is-stale': item.stale,\n 'is-animated': item.edge.animated,\n }\"\n :d=\"item.route.path\"\n />\n <path\n v-if=\"item.route.marks\"\n class=\"dou-flow-editor__edge is-mark\"\n :style=\"item.style\"\n :class=\"{ 'is-selected': item.selected, 'is-stale': item.stale }\"\n :d=\"item.route.marks\"\n />\n </g>\n <path v-if=\"previewPath\" class=\"dou-flow-editor__edge is-preview\" :d=\"previewPath\" />\n </svg>\n <div\n v-for=\"item in edgeLabels\"\n :key=\"item.edge.id\"\n class=\"dou-flow-editor__label\"\n :class=\"[\n bend?.edge.id === item.edge.id && `is-bend is-${bend.handle.axis}`,\n { 'is-selected': item.selected, 'is-editing': item.edge.id === editingLabel },\n ]\"\n :style=\"{ transform: `translate(${item.route.label.x}px, ${item.route.label.y}px)` }\"\n :data-edge-id=\"item.edge.id\"\n :data-value=\"item.edge.id === editingLabel ? labelDraft || ' ' : undefined\"\n >\n <input\n v-if=\"item.edge.id === editingLabel\"\n v-model=\"labelDraft\"\n class=\"dou-flow-editor__label-input\"\n :aria-label=\"messages.label\"\n @keydown=\"onLabelKeydown\"\n @blur=\"finishLabel(true)\"\n />\n <template v-else>{{ item.edge.label }}</template>\n </div>\n <div\n v-if=\"bend && !bend.edge.label && editingLabel !== bend.edge.id\"\n class=\"dou-flow-editor__bend\"\n :class=\"`is-${bend.handle.axis}`\"\n :style=\"{\n translate: `calc(${bend.handle.at.x}px - 50%) calc(${bend.handle.at.y}px - 50%)`,\n }\"\n aria-hidden=\"true\"\n />\n <template v-for=\"node in drawnOthers\" :key=\"node.id\">\n <div\n v-if=\"simple && metrics.has(node.id)\"\n class=\"dou-flow-editor__simple\"\n :class=\"{\n 'is-selected': selectedNodes.has(node.id),\n 'is-drop-target': dropTarget === node.id,\n }\"\n :style=\"{\n transform: `translate(${node.x}px, ${node.y}px)`,\n width: `${metrics.get(node.id)!.width}px`,\n height: `${metrics.get(node.id)!.height}px`,\n }\"\n :data-node-id=\"node.id\"\n role=\"group\"\n :aria-roledescription=\"roleOf(node)\"\n :aria-label=\"nameOf(node)\"\n tabindex=\"-1\"\n >\n <slot name=\"simple\" :node=\"node\">\n <span v-if=\"viewport.zoom >= TINY\" class=\"dou-flow-editor__simple-label\">{{\n labelOf(specOf(node), node, messages)\n }}</span>\n </slot>\n </div>\n <NodeView\n v-else\n :node=\"node\"\n :spec=\"specOf(node)\"\n :selected=\"selectedNodes.has(node.id)\"\n :readonly=\"readonly\"\n :dragging=\"!!dragging?.has(node.id)\"\n :connected=\"connected.get(node.id) ?? none\"\n :port-states=\"portStates ? (portStates.get(node.id) ?? noStates) : null\"\n :role-description=\"roleOf(node)\"\n :label=\"nameOf(node)\"\n :description=\"describe(node)\"\n :drop-target=\"dropTarget === node.id\"\n @measure=\"onMeasure\"\n @update=\"updateData\"\n />\n </template>\n <component :is=\"layer\" v-for=\"(layer, i) in resolved.above\" :key=\"`above-${i}`\" />\n <svg v-if=\"guides.length\" class=\"dou-flow-editor__guides\" aria-hidden=\"true\">\n <line\n v-for=\"(guide, i) in guides\"\n :key=\"i\"\n :x1=\"guide.axis === 'x' ? guide.at : guide.from\"\n :y1=\"guide.axis === 'x' ? guide.from : guide.at\"\n :x2=\"guide.axis === 'x' ? guide.at : guide.to\"\n :y2=\"guide.axis === 'x' ? guide.to : guide.at\"\n />\n </svg>\n </div>\n <div v-if=\"marquee\" class=\"dou-flow-editor__marquee\" :style=\"marquee\" />\n <div ref=\"panelsLayer\" class=\"dou-flow-editor__panels\">\n <slot />\n <component :is=\"overlay\" v-for=\"(overlay, i) in resolved.overlays\" :key=\"i\" />\n <FlowToolbar\n v-if=\"toolbarItems.length && toolbarAnchor\"\n :groups=\"toolbarItems\"\n :anchor=\"toolbarAnchor\"\n :size=\"size\"\n />\n <div\n v-if=\"findOpen\"\n ref=\"findBox\"\n class=\"dou-flow-editor__find\"\n :style=\"{ top: `${findTop}px` }\"\n >\n <DouFindReplace\n ref=\"findPanel\"\n :search=\"findQuery.search\"\n :count=\"found.length\"\n :current=\"currentFound\"\n readonly\n @update:search=\"findQuery = { ...findQuery, search: $event }\"\n @next=\"stepFound(1)\"\n @previous=\"stepFound(-1)\"\n @close=\"closeSearch\"\n >\n <template #options>\n <DouTooltip :content=\"common('findReplace.caseSensitive')\">\n <DouToggle\n :pressed=\"findQuery.caseSensitive\"\n size=\"sm\"\n :aria-label=\"common('findReplace.caseSensitive')\"\n @update:pressed=\"findQuery = { ...findQuery, caseSensitive: $event }\"\n >\n <LetterCaseIcon />\n </DouToggle>\n </DouTooltip>\n </template>\n </DouFindReplace>\n </div>\n </div>\n <FlowMenu ref=\"menu\" v-model:open=\"menuOpen\" :items=\"menuItems\" />\n <FlowQuickAdd :state=\"quickAdd\" @choose=\"chooseQuickAdd\">\n <template v-if=\"$slots.quickAdd\" #default=\"scope\">\n <slot name=\"quickAdd\" v-bind=\"scope\" />\n </template>\n </FlowQuickAdd>\n <Teleport to=\"body\">\n <div\n v-if=\"insertDrag?.started\"\n class=\"dou-flow-editor__ghost\"\n :class=\"{ 'is-outside': !insertDrag.over }\"\n :style=\"ghostStyle\"\n aria-hidden=\"true\"\n >\n <slot name=\"ghost\" :spec=\"insertDrag.spec\" :over=\"insertDrag.over\">\n <span class=\"dou-flow-editor__ghost-card\">\n <span v-if=\"insertDrag.spec.palette?.icon\" class=\"dou-flow-editor__ghost-icon\">\n <component :is=\"insertDrag.spec.palette.icon\" />\n </span>\n {{ insertDrag.spec.palette?.label ?? insertDrag.spec.type }}\n </span>\n </slot>\n </div>\n </Teleport>\n <span ref=\"end\" class=\"dou-flow-editor__end\" tabindex=\"-1\" />\n <div class=\"dou-flow-editor__live\" aria-live=\"polite\">{{ live }}</div>\n <div v-if=\"notice\" class=\"dou-flow-editor__notice\" aria-hidden=\"true\">{{ notice }}</div>\n </div>\n</template>\n"],"mappings":""}