Rocket Flow Pro
Rocket is currently in beta.
A Datastar powered version of React Flow, in which the server owns the graph state while Rocket provides a canvas for manipulating nodes and edges.
Explanation #
Each client subscribes to an updates endpoint from which the server streams the authoritative graph. This is a heavy integration example, so the component is intentionally more imperative than the simpler demos: it has to own a live canvas surface, drag state, and server reconciliation.
This keeps the flow editor optimistic without turning it into a client-owned graph like React Flow. Rocket simply provides the interactive layer while Go continues to own the topology, making it easy to broadcast changes across tabs and keep the visuals honest.
Rocket Component #
1import { rocket } from 'datastar'
2
3const SVG_NS = 'http://www.w3.org/2000/svg'
4const XLINK_NS = 'http://www.w3.org/1999/xlink'
5const XML_NS = 'http://www.w3.org/XML/1998/namespace'
6const SVG_ATTR_CASE_MAP = {
7 viewbox: 'viewBox',
8 preserveaspectratio: 'preserveAspectRatio',
9 patternunits: 'patternUnits',
10 patterntransform: 'patternTransform',
11 markerunits: 'markerUnits',
12 markerwidth: 'markerWidth',
13 markerheight: 'markerHeight',
14 gradientunits: 'gradientUnits',
15 gradienttransform: 'gradientTransform',
16 clippathunits: 'clipPathUnits',
17 filterunits: 'filterUnits',
18 maskunits: 'maskUnits',
19 maskcontentunits: 'maskContentUnits',
20 spreadmethod: 'spreadMethod',
21 unicodebidi: 'unicodeBidi',
22}
23const DEFAULT_NODE_WIDTH = 120
24const DEFAULT_NODE_HEIGHT = 48
25
26const clampZoom = (value) => {
27 const next = Number(value)
28 if (!Number.isFinite(next) || next <= 0) return 1
29 return Math.min(16, Math.max(0.05, next))
30}
31
32const normalizeViewport = (value) => [
33 value?.[0] ?? 0,
34 value?.[1] ?? 0,
35 clampZoom(value?.[2] ?? 1),
36]
37
38const bezierPathPoints = (sx, sy, tx, ty) => {
39 const dx = tx - sx
40 const dy = ty - sy
41 if (Math.abs(dx) >= Math.abs(dy)) {
42 const midX = sx + dx * 0.5
43 return { c1x: midX, c1y: sy, c2x: midX, c2y: ty }
44 }
45 const midY = sy + dy * 0.5
46 return { c1x: sx, c1y: midY, c2x: tx, c2y: midY }
47}
48
49const makeEdgeKey = (sourceId, targetId, id) =>
50 id ? `id:${id}` : `${sourceId ?? ''}->${targetId ?? ''}`
51
52const hideFlowChild = (node) => {
53 if (!(node instanceof HTMLElement)) return
54 const tag = node.tagName.toLowerCase()
55 if (tag === 'rocket-flow-node' || tag === 'rocket-flow-edge') {
56 node.style.display = 'none'
57 }
58}
59
60const cloneNodeIntoSvg = (node) => {
61 if (!node) return null
62 if (node.nodeType === Node.TEXT_NODE) {
63 const text = node.textContent ?? ''
64 return text.trim() ? document.createTextNode(text) : null
65 }
66 if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
67 const fragment = document.createDocumentFragment()
68 node.childNodes.forEach((child) => {
69 const clone = cloneNodeIntoSvg(child)
70 if (!clone) return
71 if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
72 fragment.append(...Array.from(clone.childNodes))
73 } else {
74 fragment.append(clone)
75 }
76 })
77 return fragment
78 }
79 if (node.nodeType !== Node.ELEMENT_NODE) return null
80 if (node.tagName === 'SLOT') return null
81 const tag = node.tagName.toUpperCase()
82 if (tag === 'SCRIPT' || tag === 'STYLE') return null
83 if (
84 node.hasAttribute('data-ref') &&
85 node.getAttribute('data-ref') === 'defaultContent'
86 ) {
87 return null
88 }
89 if (node.tagName === 'TEMPLATE') {
90 const fragment = document.createDocumentFragment()
91 node.content.childNodes.forEach((child) => {
92 const clone = cloneNodeIntoSvg(child)
93 if (!clone) return
94 if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
95 fragment.append(...Array.from(clone.childNodes))
96 } else {
97 fragment.append(clone)
98 }
99 })
100 return fragment
101 }
102 const clone = document.createElementNS(SVG_NS, node.localName.toLowerCase())
103 for (const attr of Array.from(node.attributes)) {
104 if (attr.namespaceURI)
105 clone.setAttributeNS(attr.namespaceURI, attr.name, attr.value)
106 else if (attr.prefix === 'xlink')
107 clone.setAttributeNS(XLINK_NS, attr.name, attr.value)
108 else if (attr.prefix === 'xml')
109 clone.setAttributeNS(XML_NS, attr.name, attr.value)
110 else
111 clone.setAttribute(
112 SVG_ATTR_CASE_MAP[attr.name.toLowerCase()] ?? attr.name,
113 attr.value,
114 )
115 }
116 node.childNodes.forEach((child) => {
117 const clonedChild = cloneNodeIntoSvg(child)
118 if (!clonedChild) return
119 if (clonedChild.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
120 clone.append(...Array.from(clonedChild.childNodes))
121 } else {
122 clone.append(clonedChild)
123 }
124 })
125 return clone
126}
127
128rocket('flow-container', {
129 props: ({ array, number, date }) => ({
130 viewport: array(number, number, number).default([0, 0, 1]),
131 grid: number.min(1).default(32),
132 serverUpdateTime: date.default(() => new Date()),
133 }),
134 onFirstRender: ({ cleanup, host, observeProps, props, refs }) => {
135 host.style.display = 'block'
136 host.style.position = 'relative'
137 if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0')
138
139 let svg
140 let background
141 let gridPattern
142 let gridPath
143 let graph
144 let frame = 0
145 let graphFrame = 0
146 let edgeFrameId = 0
147 let edgeFramePending = false
148 let lastSignature = ''
149 let selectedEdgeKey = null
150 let lastServerUpdateTime = Number.NaN
151 let viewport = normalizeViewport(props.viewport)
152 let grid = props.grid
153
154 const metrics = {
155 screenWidth: 1,
156 screenHeight: 1,
157 worldWidth: 1,
158 worldHeight: 1,
159 zoom: 1,
160 minX: 0,
161 minY: 0,
162 }
163 const nodes = new Map()
164 const nodesById = new Map()
165 const edges = new Map()
166 const edgesByKey = new Map()
167 const activeNodeDrags = new Map()
168 const edgeSelectionBoundGroups = new WeakSet()
169 const dragEnabledGroups = new WeakSet()
170
171 const ensureRefs = () => {
172 svg = refs.svg ?? null
173 background = refs.background ?? null
174 gridPattern = refs.gridPattern ?? null
175 gridPath = refs.gridPath ?? null
176 graph = refs.graph ?? null
177 return (
178 svg instanceof SVGSVGElement &&
179 background instanceof SVGRectElement &&
180 gridPattern instanceof SVGPatternElement &&
181 gridPath instanceof SVGPathElement &&
182 graph instanceof SVGGElement
183 )
184 }
185
186 const ensureLayer = (name) => {
187 let layer = graph?.querySelector(`g[data-layer="${name}"]`)
188 if (layer instanceof SVGGElement) return layer
189 layer = document.createElementNS(SVG_NS, 'g')
190 layer.dataset.layer = name
191 graph?.append(layer)
192 return layer
193 }
194
195 let edgesLayer
196 let nodesLayer
197
198 const syncNodeDataset = (entry) => {
199 if (!entry?.group) return
200 if (entry.id) entry.group.dataset.nodeId = entry.id
201 else delete entry.group.dataset.nodeId
202 }
203
204 const updateNodeId = (entry, nextId) => {
205 const id = (nextId ?? '').trim()
206 if (entry.id && nodesById.get(entry.id) === entry)
207 nodesById.delete(entry.id)
208 entry.id = id
209 if (id) nodesById.set(id, entry)
210 syncNodeDataset(entry)
211 }
212
213 const emitNodeEvent = (entry, type, extra = {}) => {
214 if (!entry?.el) return
215 entry.el.dispatchEvent(
216 new CustomEvent(type, {
217 detail: {
218 instanceId: entry.el.rocketInstanceId,
219 node: entry.el,
220 x: entry.x,
221 y: entry.y,
222 ...extra,
223 },
224 bubbles: true,
225 composed: true,
226 }),
227 )
228 }
229
230 const scheduleEdgeRender = () => {
231 if (!edges.size || edgeFramePending) return
232 edgeFramePending = true
233 edgeFrameId = requestAnimationFrame(() => {
234 edgeFramePending = false
235 edgeFrameId = 0
236 for (const entry of edges.values()) {
237 const source = entry.sourceId ? nodesById.get(entry.sourceId) : null
238 const target = entry.targetId ? nodesById.get(entry.targetId) : null
239 if (!source || !target) {
240 entry.group?.setAttribute('display', 'none')
241 continue
242 }
243 entry.group?.removeAttribute('display')
244 const { c1x, c1y, c2x, c2y } = bezierPathPoints(
245 source.x,
246 source.y,
247 target.x,
248 target.y,
249 )
250 entry.path?.setAttribute(
251 'd',
252 `M ${source.x} ${source.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${target.x} ${target.y}`,
253 )
254 entry.path?.classList.toggle('is-animated', !!entry.animated)
255 if (!entry.labelEl) continue
256 if (!entry.label) {
257 entry.labelEl.textContent = ''
258 entry.labelEl.setAttribute('display', 'none')
259 continue
260 }
261 entry.labelEl.textContent = entry.label
262 entry.labelEl.removeAttribute('display')
263 entry.labelEl.setAttribute('x', String((source.x + target.x) / 2))
264 entry.labelEl.setAttribute('y', String((source.y + target.y) / 2))
265 }
266 })
267 }
268
269 const applyEdgeSelectionClasses = () => {
270 edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
271 const selected = group.dataset.edgeKey === selectedEdgeKey
272 group.classList.toggle('is-selected', selected)
273 if (
274 !selected &&
275 group.dataset.edgeKey !== host.getAttribute('pending-edge')
276 ) {
277 group.classList.remove('is-pending')
278 }
279 })
280 }
281
282 const clearSelectedEdge = () => {
283 if (!selectedEdgeKey) return
284 selectedEdgeKey = null
285 applyEdgeSelectionClasses()
286 host.dispatchEvent(
287 new CustomEvent('flow-edge-select', {
288 detail: null,
289 bubbles: true,
290 composed: true,
291 }),
292 )
293 }
294
295 const registerEdgeEntry = (entry, previousKey = entry.key) => {
296 entry.key = makeEdgeKey(entry.sourceId, entry.targetId, entry.id)
297 if (
298 previousKey &&
299 previousKey !== entry.key &&
300 edgesByKey.get(previousKey) === entry
301 ) {
302 edgesByKey.delete(previousKey)
303 }
304 if (entry.key) edgesByKey.set(entry.key, entry)
305 if (entry.group) {
306 if (entry.sourceId) entry.group.dataset.sourceId = entry.sourceId
307 else delete entry.group.dataset.sourceId
308 if (entry.targetId) entry.group.dataset.targetId = entry.targetId
309 else delete entry.group.dataset.targetId
310 if (entry.id) entry.group.dataset.edgeId = entry.id
311 else delete entry.group.dataset.edgeId
312 if (entry.key) entry.group.dataset.edgeKey = entry.key
313 else delete entry.group.dataset.edgeKey
314 }
315 if (entry.group && !edgeSelectionBoundGroups.has(entry.group)) {
316 edgeSelectionBoundGroups.add(entry.group)
317 entry.group.addEventListener('pointerdown', (evt) => {
318 evt.stopPropagation()
319 evt.preventDefault()
320 if (!entry.key) registerEdgeEntry(entry)
321 if (!entry.key) return
322 selectedEdgeKey = entry.key
323 applyEdgeSelectionClasses()
324 host.dispatchEvent(
325 new CustomEvent('flow-edge-select', {
326 detail: {
327 id: entry.id ?? '',
328 sourceId: entry.sourceId ?? '',
329 targetId: entry.targetId ?? '',
330 },
331 bubbles: true,
332 composed: true,
333 }),
334 )
335 try {
336 host.focus({ preventScroll: true })
337 } catch {}
338 })
339 }
340 applyEdgeSelectionClasses()
341 }
342
343 const ensureEdgeGraphics = (entry) => {
344 if (entry.group) return
345 const group = document.createElementNS(SVG_NS, 'g')
346 const path = document.createElementNS(SVG_NS, 'path')
347 const label = document.createElementNS(SVG_NS, 'text')
348 group.classList.add('flow-edge')
349 group.style.pointerEvents = 'auto'
350 group.style.cursor = 'pointer'
351 path.setAttribute('pointer-events', 'stroke')
352 group.append(path, label)
353 edgesLayer?.append(group)
354 entry.group = group
355 entry.path = path
356 entry.labelEl = label
357 registerEdgeEntry(entry)
358 }
359
360 const positionNode = (entry) => {
361 if (!entry.group) return
362 entry.group.setAttribute(
363 'transform',
364 `translate(${entry.x - entry.width / 2} ${entry.y - entry.height / 2})`,
365 )
366 scheduleEdgeRender()
367 }
368
369 const applyNodeContent = (entry, content) => {
370 if (!entry.group) return
371 if (!content) {
372 entry.group.replaceChildren()
373 return
374 }
375 const clone = content.cloneNode(true)
376 if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
377 entry.group.replaceChildren(...Array.from(clone.childNodes))
378 } else {
379 entry.group.replaceChildren(clone)
380 }
381 }
382
383 const enableNodeDrag = (entry) => {
384 if (!entry.group || dragEnabledGroups.has(entry.group) || !svg) return
385 dragEnabledGroups.add(entry.group)
386 const root = svg.ownerDocument?.defaultView ?? window
387 entry.group.addEventListener('pointerdown', (evt) => {
388 if (evt.button !== 0) return
389 console.log('[rocket/flow] node:pointerdown', {
390 pointerId: evt.pointerId,
391 id: entry.id,
392 target: evt.target instanceof Element ? evt.target.tagName : null,
393 })
394 evt.preventDefault()
395 evt.stopPropagation()
396 const pointerId = evt.pointerId
397 const drag = {
398 pointerId,
399 startX: entry.x,
400 startY: entry.y,
401 startClientX: evt.clientX,
402 startClientY: evt.clientY,
403 }
404 activeNodeDrags.set(pointerId, drag)
405 try {
406 entry.group.setPointerCapture(pointerId)
407 } catch {}
408 entry.group.classList.add('is-dragging')
409 entry.group.style.cursor = 'grabbing'
410 emitNodeEvent(entry, 'flow-node-drag-start', {
411 pointerId,
412 origin: { x: drag.startX, y: drag.startY },
413 })
414
415 const move = (event) => {
416 if (event.pointerId !== pointerId) return
417 entry.x =
418 drag.startX +
419 (event.clientX - drag.startClientX) *
420 (metrics.worldWidth / metrics.screenWidth)
421 entry.y =
422 drag.startY +
423 (event.clientY - drag.startClientY) *
424 (metrics.worldHeight / metrics.screenHeight)
425 positionNode(entry)
426 emitNodeEvent(entry, 'flow-node-drag', {
427 pointerId,
428 origin: { x: drag.startX, y: drag.startY },
429 })
430 }
431
432 const finish = (event, type) => {
433 if (event.pointerId !== pointerId) return
434 activeNodeDrags.delete(pointerId)
435 try {
436 entry.group.releasePointerCapture(pointerId)
437 } catch {}
438 entry.group.classList.remove('is-dragging')
439 entry.group.style.cursor = 'grab'
440 root.removeEventListener('pointermove', move, true)
441 root.removeEventListener('pointerup', end, true)
442 root.removeEventListener('pointercancel', cancel, true)
443 if (type === 'cancel') {
444 emitNodeEvent(entry, 'flow-node-drag-cancel', {
445 pointerId,
446 origin: { x: drag.startX, y: drag.startY },
447 })
448 return
449 }
450 const detail = {
451 pointerId,
452 origin: { x: drag.startX, y: drag.startY },
453 source: 'drag',
454 x: entry.x,
455 y: entry.y,
456 }
457 emitNodeEvent(entry, 'flow-node-drag-end', detail)
458 try {
459 entry.el.setAttribute('x', String(entry.x))
460 entry.el.setAttribute('y', String(entry.y))
461 } catch {}
462 emitNodeEvent(entry, 'flow-node-update', detail)
463 }
464
465 const end = (event) => finish(event, 'end')
466 const cancel = (event) => finish(event, 'cancel')
467 root.addEventListener('pointermove', move, true)
468 root.addEventListener('pointerup', end, true)
469 root.addEventListener('pointercancel', cancel, true)
470 })
471 }
472
473 const ensureNodeGroup = (entry) => {
474 if (entry.group) return
475 entry.group = document.createElementNS(SVG_NS, 'g')
476 entry.group.classList.add('flow-node')
477 entry.group.setAttribute('tabindex', '-1')
478 entry.group.style.cursor = 'grab'
479 nodesLayer?.append(entry.group)
480 syncNodeDataset(entry)
481 enableNodeDrag(entry)
482 }
483
484 const renderNodes = () => {
485 if (!nodes.size) return
486 for (const entry of nodes.values()) {
487 ensureNodeGroup(entry)
488 positionNode(entry)
489 }
490 scheduleEdgeRender()
491 }
492
493 const scheduleNodeRender = () => {
494 if (graphFrame) return
495 graphFrame = requestAnimationFrame(() => {
496 graphFrame = 0
497 renderNodes()
498 })
499 }
500
501 const clearPendingEdge = () => {
502 host.removeAttribute('pending-edge')
503 edgesLayer?.querySelectorAll('.flow-edge.is-pending').forEach((group) => {
504 group.classList.remove('is-pending')
505 })
506 }
507
508 const clearPendingState = () => {
509 host.removeAttribute('pending')
510 host.removeAttribute('data-pending-node')
511 graph
512 ?.querySelectorAll('.flow-node.is-pending, .flow-edge.is-pending')
513 .forEach((item) => {
514 item.classList.remove('is-pending')
515 })
516 clearPendingEdge()
517 }
518
519 const setPendingState = (nodeId) => {
520 if (!nodeId) {
521 clearPendingState()
522 return
523 }
524 host.setAttribute('pending', 'true')
525 host.setAttribute('data-pending-node', nodeId)
526 nodesLayer?.querySelectorAll('.flow-node').forEach((group) => {
527 group.classList.toggle('is-pending', group.dataset.nodeId === nodeId)
528 })
529 edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
530 const connected =
531 group.dataset.sourceId === nodeId || group.dataset.targetId === nodeId
532 group.classList.toggle('is-pending', connected)
533 })
534 }
535
536 const setPendingEdge = (edgeKey) => {
537 if (!edgeKey) {
538 clearPendingEdge()
539 return
540 }
541 host.setAttribute('pending-edge', edgeKey)
542 edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
543 group.classList.toggle('is-pending', group.dataset.edgeKey === edgeKey)
544 })
545 }
546
547 const schedule = () => {
548 if (frame || !svg || !background || !gridPattern || !gridPath) return
549 frame = requestAnimationFrame(() => {
550 frame = 0
551 const [cx, cy, zoom] = viewport
552 const rect = svg.getBoundingClientRect()
553 metrics.screenWidth = Math.max(1, rect.width)
554 metrics.screenHeight = Math.max(1, rect.height)
555 metrics.zoom = zoom
556 metrics.worldWidth = metrics.screenWidth / zoom
557 metrics.worldHeight = metrics.screenHeight / zoom
558 metrics.minX = cx - metrics.worldWidth / 2
559 metrics.minY = cy - metrics.worldHeight / 2
560 svg.setAttribute(
561 'viewBox',
562 `${metrics.minX} ${metrics.minY} ${metrics.worldWidth} ${metrics.worldHeight}`,
563 )
564 background.setAttribute('x', String(metrics.minX))
565 background.setAttribute('y', String(metrics.minY))
566 background.setAttribute('width', String(metrics.worldWidth))
567 background.setAttribute('height', String(metrics.worldHeight))
568 const spacing = Math.max(4, grid)
569 gridPattern.setAttribute('width', String(spacing))
570 gridPattern.setAttribute('height', String(spacing))
571 gridPath.setAttribute('d', `M ${spacing} 0 L 0 0 0 ${spacing}`)
572 gridPattern.removeAttribute('patternTransform')
573 const signature = `${metrics.minX}|${metrics.minY}|${metrics.zoom}|${metrics.screenWidth}|${metrics.screenHeight}`
574 if (signature !== lastSignature) {
575 lastSignature = signature
576 console.log('[rocket/flow] viewport:change', {
577 viewport: [...viewport],
578 })
579 host.dispatchEvent(
580 new CustomEvent('viewport-change', {
581 detail: {
582 viewport: [...viewport],
583 screen: {
584 width: metrics.screenWidth,
585 height: metrics.screenHeight,
586 },
587 world: {
588 minX: metrics.minX,
589 minY: metrics.minY,
590 width: metrics.worldWidth,
591 height: metrics.worldHeight,
592 },
593 },
594 bubbles: true,
595 composed: true,
596 }),
597 )
598 }
599 renderNodes()
600 })
601 }
602
603 const pointerDown = (evt) => {
604 if (evt.button !== 0 || (evt.target !== svg && evt.target !== background))
605 return
606 console.log('[rocket/flow] viewport:pointerdown', {
607 pointerId: evt.pointerId,
608 target: evt.target instanceof Element ? evt.target.tagName : null,
609 })
610 clearSelectedEdge()
611 evt.preventDefault()
612 const origin = [...viewport]
613 svg.classList.add('is-dragging')
614 try {
615 svg.setPointerCapture(evt.pointerId)
616 } catch {}
617 const move = (event) => {
618 if (event.pointerId !== evt.pointerId) return
619 const scale = 1 / origin[2]
620 viewport = [
621 origin[0] - (event.clientX - evt.clientX) * scale,
622 origin[1] - (event.clientY - evt.clientY) * scale,
623 origin[2],
624 ]
625 schedule()
626 }
627 const end = (event) => {
628 if (event.pointerId !== evt.pointerId) return
629 svg.classList.remove('is-dragging')
630 try {
631 svg.releasePointerCapture(evt.pointerId)
632 } catch {}
633 svg.removeEventListener('pointermove', move, true)
634 svg.removeEventListener('pointerup', end, true)
635 svg.removeEventListener('pointercancel', end, true)
636 }
637 svg.addEventListener('pointermove', move, true)
638 svg.addEventListener('pointerup', end, true)
639 svg.addEventListener('pointercancel', end, true)
640 }
641
642 const handleWheel = (evt) => {
643 evt.preventDefault()
644 if (!svg) return
645 const rect = svg.getBoundingClientRect()
646 const zoom = metrics.zoom
647 const nextZoom = clampZoom(zoom * 1.2 ** (-evt.deltaY / 120))
648 if (nextZoom === zoom) return
649 const px = evt.clientX - rect.left
650 const py = evt.clientY - rect.top
651 const focusX = metrics.minX + (px / rect.width) * metrics.worldWidth
652 const focusY = metrics.minY + (py / rect.height) * metrics.worldHeight
653 const nextWorldWidth = metrics.screenWidth / nextZoom
654 const nextWorldHeight = metrics.screenHeight / nextZoom
655 const minX = focusX - (px / rect.width) * nextWorldWidth
656 const minY = focusY - (py / rect.height) * nextWorldHeight
657 viewport = [
658 minX + nextWorldWidth / 2,
659 minY + nextWorldHeight / 2,
660 nextZoom,
661 ]
662 schedule()
663 }
664
665 const handleKeydown = (evt) => {
666 if (!selectedEdgeKey || (evt.key !== 'Backspace' && evt.key !== 'Delete'))
667 return
668 const entry = edgesByKey.get(selectedEdgeKey)
669 if (!entry) {
670 clearSelectedEdge()
671 return
672 }
673 evt.preventDefault()
674 setPendingEdge(entry.key)
675 host.dispatchEvent(
676 new CustomEvent('flow-edge-delete-request', {
677 detail: {
678 id: entry.id ?? '',
679 sourceId: entry.sourceId ?? '',
680 targetId: entry.targetId ?? '',
681 },
682 bubbles: true,
683 composed: true,
684 }),
685 )
686 }
687
688 const init = () => {
689 if (!ensureRefs()) return
690 const patternId = `flow-grid-${/** @type {HTMLElement & { rocketInstanceId?: string }} */ (host).rocketInstanceId ?? ''}`
691 gridPattern.id = patternId
692 background.setAttribute('fill', `url(#${patternId})`)
693 edgesLayer = ensureLayer('edges')
694 nodesLayer = ensureLayer('nodes')
695 schedule()
696 svg.addEventListener('pointerdown', pointerDown)
697 svg.addEventListener('wheel', handleWheel, { passive: false })
698 const resize = new ResizeObserver(schedule)
699 resize.observe(svg)
700 cleanup(() => resize.disconnect())
701 }
702
703 const childObserver = new MutationObserver((mutations) => {
704 for (const mutation of mutations) {
705 for (const node of mutation.addedNodes) hideFlowChild(node)
706 }
707 })
708
709 const onNodeRegister = (evt) => {
710 const target = evt.target
711 if (!(target instanceof HTMLElement)) return
712 hideFlowChild(target)
713 const detail =
714 /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
715 const entry = nodes.get(target) ?? {
716 el: target,
717 group: null,
718 x: 0,
719 y: 0,
720 width: DEFAULT_NODE_WIDTH,
721 height: DEFAULT_NODE_HEIGHT,
722 }
723 entry.x = detail.x
724 entry.y = detail.y
725 entry.width = detail.width
726 entry.height = detail.height
727 updateNodeId(entry, detail.id ?? target.getAttribute('id'))
728 nodes.set(target, entry)
729 ensureNodeGroup(entry)
730 if (detail.content) applyNodeContent(entry, detail.content)
731 scheduleNodeRender()
732 positionNode(entry)
733 }
734
735 const onNodeUpdate = (evt) => {
736 const target = evt.target
737 if (!(target instanceof HTMLElement)) return
738 const entry = nodes.get(target)
739 if (!entry) return
740 const detail =
741 /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
742 if ('x' in detail) entry.x = detail.x
743 if ('y' in detail) entry.y = detail.y
744 if ('width' in detail) entry.width = detail.width
745 if ('height' in detail) entry.height = detail.height
746 if ('id' in detail)
747 updateNodeId(entry, detail.id ?? target.getAttribute('id'))
748 if (detail.content) applyNodeContent(entry, detail.content)
749 scheduleNodeRender()
750 positionNode(entry)
751 }
752
753 const onNodeRemove = (evt) => {
754 const target = evt.target
755 if (!(target instanceof HTMLElement)) return
756 const entry = nodes.get(target)
757 entry?.group?.remove()
758 nodes.delete(target)
759 if (entry?.id && nodesById.get(entry.id) === entry)
760 nodesById.delete(entry.id)
761 scheduleEdgeRender()
762 }
763
764 const onEdgeRegister = (evt) => {
765 const target = evt.target
766 if (!(target instanceof HTMLElement)) return
767 hideFlowChild(target)
768 const detail =
769 /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
770 const entry = edges.get(target) ?? {
771 el: target,
772 group: null,
773 path: null,
774 labelEl: null,
775 sourceId: '',
776 targetId: '',
777 label: '',
778 animated: false,
779 id: '',
780 }
781 entry.sourceId = (
782 detail.source ??
783 detail.sourceId ??
784 target.getAttribute('source') ??
785 ''
786 ).trim()
787 entry.targetId = (
788 detail.target ??
789 detail.targetId ??
790 target.getAttribute('target') ??
791 ''
792 ).trim()
793 entry.label = detail.label ?? target.getAttribute('label') ?? ''
794 entry.animated = Boolean(
795 detail.animated ?? target.hasAttribute('animated'),
796 )
797 entry.id = (detail.id ?? target.getAttribute('id') ?? '').trim()
798 edges.set(target, entry)
799 ensureEdgeGraphics(entry)
800 registerEdgeEntry(entry)
801 scheduleEdgeRender()
802 }
803
804 const onEdgeUpdate = (evt) => {
805 const target = evt.target
806 if (!(target instanceof HTMLElement)) return
807 const entry = edges.get(target)
808 if (!entry) return
809 const detail =
810 /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
811 entry.sourceId = (
812 detail.source ??
813 detail.sourceId ??
814 target.getAttribute('source') ??
815 ''
816 ).trim()
817 entry.targetId = (
818 detail.target ??
819 detail.targetId ??
820 target.getAttribute('target') ??
821 ''
822 ).trim()
823 entry.label = detail.label ?? target.getAttribute('label') ?? ''
824 entry.animated = Boolean(
825 detail.animated ?? target.hasAttribute('animated'),
826 )
827 entry.id = (detail.id ?? target.getAttribute('id') ?? '').trim()
828 registerEdgeEntry(entry)
829 scheduleEdgeRender()
830 }
831
832 const onEdgeRemove = (evt) => {
833 const target = evt.target
834 if (!(target instanceof HTMLElement)) return
835 const entry = edges.get(target)
836 if (entry?.key && edgesByKey.get(entry.key) === entry)
837 edgesByKey.delete(entry.key)
838 entry?.group?.remove()
839 edges.delete(target)
840 if (entry?.key === selectedEdgeKey) clearSelectedEdge()
841 scheduleEdgeRender()
842 }
843
844 host.addEventListener('flow-node-register', onNodeRegister)
845 host.addEventListener('flow-node-update', onNodeUpdate)
846 host.addEventListener('flow-node-remove', onNodeRemove)
847 host.addEventListener('flow-edge-register', onEdgeRegister)
848 host.addEventListener('flow-edge-update', onEdgeUpdate)
849 host.addEventListener('flow-edge-remove', onEdgeRemove)
850 host.addEventListener('flow-node-drag-start', (evt) => {
851 const detail = /** @type {CustomEvent<{ node?: { id?: string } }>} */ (
852 evt
853 ).detail
854 setPendingState(detail?.node?.id ?? '')
855 })
856 host.addEventListener('flow-node-drag-end', (evt) => {
857 const detail = /** @type {CustomEvent<{ node?: { id?: string } }>} */ (
858 evt
859 ).detail
860 setPendingState(detail?.node?.id ?? '')
861 })
862 host.addEventListener('flow-node-drag-cancel', clearPendingState)
863 host.addEventListener('keydown', handleKeydown)
864 childObserver.observe(host, { childList: true })
865 queueMicrotask(() => {
866 Array.from(host.children).forEach(hideFlowChild)
867 })
868 init()
869
870 observeProps(() => {
871 viewport = normalizeViewport(props.viewport)
872 schedule()
873 }, 'viewport')
874 observeProps(() => {
875 grid = props.grid
876 schedule()
877 }, 'grid')
878 observeProps(() => {
879 const next = props.serverUpdateTime.getTime()
880 if (next === lastServerUpdateTime) return
881 lastServerUpdateTime = next
882 clearPendingState()
883 clearSelectedEdge()
884 }, 'serverUpdateTime')
885
886 cleanup(() => {
887 childObserver.disconnect()
888 host.removeEventListener('flow-node-register', onNodeRegister)
889 host.removeEventListener('flow-node-update', onNodeUpdate)
890 host.removeEventListener('flow-node-remove', onNodeRemove)
891 host.removeEventListener('flow-edge-register', onEdgeRegister)
892 host.removeEventListener('flow-edge-update', onEdgeUpdate)
893 host.removeEventListener('flow-edge-remove', onEdgeRemove)
894 host.removeEventListener('keydown', handleKeydown)
895 if (svg) {
896 svg.removeEventListener('pointerdown', pointerDown)
897 svg.removeEventListener('wheel', handleWheel)
898 }
899 if (frame) cancelAnimationFrame(frame)
900 if (graphFrame) cancelAnimationFrame(graphFrame)
901 if (edgeFrameId) cancelAnimationFrame(edgeFrameId)
902 })
903 },
904 render: ({ html }) => html`
905 <style>
906 :host {
907 display: block;
908 position: relative;
909 }
910
911 .flow-root {
912 position: relative;
913 width: 100%;
914 height: 100%;
915 overflow: hidden;
916 }
917
918 svg.flow-svg {
919 display: block;
920 width: 100%;
921 height: 100%;
922 cursor: grab;
923 background: #eef2ff;
924 touch-action: none;
925 user-select: none;
926 }
927
928 svg.flow-svg.is-dragging {
929 cursor: grabbing;
930 }
931
932 .flow-edge path {
933 stroke: #64748b;
934 stroke-width: 1.5;
935 fill: none;
936 stroke-linecap: round;
937 stroke-linejoin: round;
938 }
939
940 .flow-edge path.is-animated {
941 stroke-dasharray: 6 4;
942 animation: flow-edge-dash 1.2s linear infinite;
943 }
944
945 .flow-edge text {
946 fill: #475569;
947 font-size: 12px;
948 font-weight: 500;
949 text-anchor: middle;
950 dominant-baseline: middle;
951 }
952
953 .flow-edge.is-selected path {
954 stroke: #2563eb;
955 stroke-width: 2;
956 }
957
958 .flow-edge.is-selected text {
959 fill: #2563eb;
960 font-weight: 600;
961 }
962
963 .flow-node.is-pending,
964 .flow-edge.is-pending {
965 opacity: 0.55;
966 }
967
968 @keyframes flow-edge-dash {
969 from {
970 stroke-dashoffset: 0;
971 }
972 to {
973 stroke-dashoffset: -10;
974 }
975 }
976 </style>
977 <div class="flow-root">
978 <svg class="flow-svg" data-ref:svg role="presentation" tabindex="0">
979 <defs>
980 <pattern data-ref:grid-pattern patternUnits="userSpaceOnUse">
981 <path
982 data-ref:grid-path
983 fill="none"
984 stroke="rgba(100,116,139,0.38)"
985 stroke-width="0.75"
986 ></path>
987 </pattern>
988 </defs>
989 <rect data-ref:background></rect>
990 <g data-ref:graph></g>
991 </svg>
992 </div>
993 <slot hidden></slot>
994 `,
995})
996
997rocket('flow-node', {
998 props: ({ number, string }) => ({
999 x: number,
1000 y: number,
1001 label: string,
1002 width: number.min(1).default(DEFAULT_NODE_WIDTH),
1003 height: number.min(1).default(DEFAULT_NODE_HEIGHT),
1004 }),
1005 setup: ({ cleanup, effect, host, props }) => {
1006 host.style.display = 'none'
1007
1008 const collectCustomContent = () => {
1009 const fragment = document.createDocumentFragment()
1010 host.childNodes.forEach((child) => {
1011 if (
1012 child.nodeType === Node.ELEMENT_NODE &&
1013 child.nodeName.toUpperCase() === 'TEMPLATE'
1014 )
1015 return
1016 const clone = cloneNodeIntoSvg(child)
1017 if (!clone) return
1018 if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1019 fragment.append(...Array.from(clone.childNodes))
1020 } else {
1021 fragment.append(clone)
1022 }
1023 })
1024 return fragment.childNodes.length ? fragment : null
1025 }
1026
1027 const createDefaultContent = () => {
1028 const fragment = document.createDocumentFragment()
1029 const svg = document.createElementNS(SVG_NS, 'svg')
1030 const rect = document.createElementNS(SVG_NS, 'rect')
1031 const text = document.createElementNS(SVG_NS, 'text')
1032 svg.setAttribute('width', String(props.width))
1033 svg.setAttribute('height', String(props.height))
1034 svg.setAttribute('viewBox', `0 0 ${props.width} ${props.height}`)
1035 svg.setAttribute('data-default-node', '')
1036 rect.setAttribute('rx', '12')
1037 rect.setAttribute('ry', '12')
1038 rect.setAttribute('width', String(props.width))
1039 rect.setAttribute('height', String(props.height))
1040 rect.setAttribute('fill', '#ffffff')
1041 rect.setAttribute('stroke', '#94a3b8')
1042 rect.setAttribute('stroke-width', '1.5')
1043 text.setAttribute('x', String(props.width / 2))
1044 text.setAttribute('y', String(props.height / 2))
1045 text.setAttribute('text-anchor', 'middle')
1046 text.setAttribute('dominant-baseline', 'middle')
1047 text.setAttribute(
1048 'font-family',
1049 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont',
1050 )
1051 text.setAttribute('font-weight', '600')
1052 text.setAttribute('font-size', '13')
1053 text.setAttribute('fill', '#0f172a')
1054 text.textContent = props.label
1055 svg.append(rect, text)
1056 fragment.append(svg)
1057 return fragment
1058 }
1059
1060 const snapshot = () => ({
1061 id: host.getAttribute('id') ?? '',
1062 x: props.x,
1063 y: props.y,
1064 label: props.label,
1065 width: props.width,
1066 height: props.height,
1067 content: collectCustomContent() ?? createDefaultContent(),
1068 })
1069
1070 const emit = (name) => {
1071 host.dispatchEvent(
1072 new CustomEvent(name, {
1073 detail: snapshot(),
1074 bubbles: true,
1075 composed: true,
1076 }),
1077 )
1078 }
1079
1080 const observer = new MutationObserver(() =>
1081 queueMicrotask(() => emit('flow-node-update')),
1082 )
1083 observer.observe(host, {
1084 childList: true,
1085 subtree: true,
1086 characterData: true,
1087 attributes: true,
1088 })
1089
1090 queueMicrotask(() => emit('flow-node-register'))
1091 cleanup(() => {
1092 observer.disconnect()
1093 emit('flow-node-remove')
1094 })
1095 effect(() => {
1096 props.x
1097 props.y
1098 props.label
1099 props.width
1100 props.height
1101 emit('flow-node-update')
1102 })
1103 },
1104})
1105
1106rocket('flow-edge', {
1107 props: ({ string, bool }) => ({
1108 source: string,
1109 target: string,
1110 label: string,
1111 animated: bool,
1112 }),
1113 setup: ({ cleanup, effect, host, props }) => {
1114 host.style.display = 'none'
1115
1116 const snapshot = () => ({
1117 id: host.getAttribute('id') ?? '',
1118 source: host.getAttribute('source') ?? props.source,
1119 target: host.getAttribute('target') ?? props.target,
1120 label: host.getAttribute('label') ?? props.label,
1121 animated: props.animated || host.hasAttribute('animated'),
1122 })
1123
1124 const emit = (name) => {
1125 host.dispatchEvent(
1126 new CustomEvent(name, {
1127 detail: snapshot(),
1128 bubbles: true,
1129 composed: true,
1130 }),
1131 )
1132 }
1133
1134 const observer = new MutationObserver(() =>
1135 queueMicrotask(() => emit('flow-edge-update')),
1136 )
1137 observer.observe(host, { attributes: true })
1138
1139 queueMicrotask(() => emit('flow-edge-register'))
1140 cleanup(() => {
1141 observer.disconnect()
1142 emit('flow-edge-remove')
1143 })
1144 effect(() => {
1145 props.source
1146 props.target
1147 props.label
1148 props.animated
1149 emit('flow-edge-update')
1150 })
1151 },
1152})