blob: 60491793adef397fa1a7e6bb5e6d1fa16c68b444 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {assertIsInstance} from '../../../base/assert';
import {Point2D} from '../../../base/geom';
import {MithrilEvent} from '../../../base/mithril_utils';
import {DockedNode, Node, NodeGraphAttrs} from '../model';
import {NGCardHeader, NGNode, NGPort, NGCard, NGCardBody} from './node';
import {NGToolbar} from './toolbar';
import {captureDrag} from '../../../base/dom_utils';
import {DisposableStack} from '../../../base/disposable_stack';
import {shortUuid} from '../../../base/uuid';
import {arrowheadMarker, connectionPath} from '../svg';
import type {PortDirection} from '../svg';
import {PopupMenu} from '../../../widgets/menu';
import {PopupPosition} from '../../../widgets/popup';
import {start} from 'repl';
const WHEEL_ZOOM_SCALING_FACTOR = 0.006;
const MIN_ZOOM = 0.01;
const MAX_ZOOM = 3.0;
const GRID_SIZE = 24;
function snapToGrid(v: number): number {
return Math.round(v / GRID_SIZE) * GRID_SIZE;
}
function portDirFromEl(el: Element): PortDirection {
if (el.classList.contains('pf-port-north')) return 'top';
if (el.classList.contains('pf-port-south')) return 'bottom';
if (el.classList.contains('pf-port-east')) return 'right';
if (el.classList.contains('pf-input')) return 'left';
return 'right';
}
function oppositeDir(dir: PortDirection): PortDirection {
switch (dir) {
case 'top':
return 'bottom';
case 'bottom':
return 'top';
case 'left':
return 'right';
case 'right':
return 'left';
}
}
function dotBackground(zoom: number, offset: {x: number; y: number}) {
let gridSize = GRID_SIZE;
while (gridSize * zoom < 10) gridSize *= 2;
const size = gridSize * zoom;
return {
size,
posX: offset.x * zoom - size / 2,
posY: offset.y * zoom - size / 2,
};
}
export interface NodeGraphViewport {
readonly offset: Point2D;
readonly zoom: number;
}
export class NodeGraph implements m.Component<NodeGraphAttrs> {
// Unique ID for this instance's SVG marker, to avoid conflicts when multiple
// NodeGraph instances exist in the document (e.g. different tabs).
private readonly markerId = `pf-ng-arrow-${shortUuid()}`;
// The current viewport transform. Updated during zoom and pan operations, and
// applied to the canvas via CSS transform.
private viewport: NodeGraphViewport = {offset: {x: 0, y: 0}, zoom: 1.0};
// Cached references to important immutable DOM elements. Set in oncreate,
// used in event handlers.
private rootEl!: HTMLElement;
private workspaceEl!: HTMLElement;
private svgEl!: SVGSVGElement;
// Non-null while the user is dragging a wire from an output port.
private wireDrag?: {
fromPortId: string;
toPoint: Point2D;
toDir: PortDirection;
};
// Port ID of the output port whose context menu is currently open.
private openPortMenuId?: string;
private getNodePosition(nodeEl: HTMLElement): Point2D {
const rect = nodeEl.getBoundingClientRect();
const canvasRect = this.rootEl.getBoundingClientRect();
return this.getWorkspacePosition({
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
});
}
private getWorkspacePosition(pos: Point2D): Point2D {
const {offset, zoom} = this.viewport;
return {
x: pos.x / zoom + offset.x,
y: pos.y / zoom + offset.y,
};
}
private createGhostNode(nodeEl: HTMLElement) {
const ghost = nodeEl.cloneNode(true) as HTMLElement;
ghost.removeAttribute('data-node-id'); // avoid confusion with the real node
ghost.style.position = 'absolute';
ghost.style.zIndex = '-1'; // behind the node, but above the connections
ghost.style.pointerEvents = 'none';
ghost.style.filter = 'brightness(0) opacity(0.5)';
this.workspaceEl.appendChild(ghost);
const workspaceEl = this.workspaceEl; // capture for closure
return {
element: ghost,
moveTo(p: Point2D) {
ghost.style.left = `${p.x}px`;
ghost.style.top = `${p.y}px`;
},
dockToNode(nodeEl: HTMLElement) {
const card = assertIsInstance(
nodeEl.querySelector('.pf-ng__card'),
HTMLElement,
);
card.after(ghost);
ghost.style.removeProperty('left');
ghost.style.removeProperty('top');
ghost.style.removeProperty('position');
},
undock() {
ghost.style.position = 'absolute';
workspaceEl.appendChild(ghost);
},
[Symbol.dispose]() {
ghost.remove();
},
};
}
private moveNodeToWorkspace(nodeEl: HTMLElement) {
const previousStyle = nodeEl.getAttribute('style') ?? '';
const previousParent = nodeEl.parentElement;
this.workspaceEl.appendChild(nodeEl);
nodeEl.style.position = 'absolute';
return {
moveTo(p: Point2D) {
nodeEl.style.left = `${p.x}px`;
nodeEl.style.top = `${p.y}px`;
},
[Symbol.dispose]: () => {
// Move the element back to its original parent and reset styles
previousParent?.appendChild(nodeEl);
nodeEl.setAttribute('style', previousStyle);
},
};
}
view({attrs}: m.Vnode<NodeGraphAttrs>) {
const {
onViewportMove,
onNodeMove,
onNodeDock,
onSelect,
onSelectionAdd,
onSelectionRemove,
onSelectionClear,
onConnect,
nodes = [],
selectedNodeIds,
toolbarItems,
style,
className,
} = attrs;
// parentId is set for docked nodes; absent for root nodes.
const renderNode = (node: DockedNode, parentId?: string): m.Children => {
const isDocked = parentId !== undefined;
const rootNode = !isDocked ? (node as Node) : undefined;
return m(
NGNode,
{
key: rootNode ? node.id : undefined,
id: node.id,
position: rootNode ? {x: rootNode.x, y: rootNode.y} : undefined,
// Recursively render the whole tree
nextNode: node.next && renderNode(node.next, node.id),
onpointerdown: async (e: MithrilEvent<PointerEvent>) => {
// Let interactive elements (inputs, buttons, selects, etc.) handle
// their own events without triggering a node drag.
const target = e.target as HTMLElement;
if (target.closest('input, button, select, textarea, a')) return;
// Stop this pointer event from hitting the canvas
e.stopPropagation();
// Don't redraw just yet...
e.redraw = false;
// Secure the node element
const nodeEl = assertIsInstance(e.currentTarget, HTMLElement);
// Wait for this to turn into a proper drag
const drag = await captureDrag({el: this.rootEl, e, deadzone: 5});
if (drag) {
// Work out where in the workspace the node is right now
const startPosition = this.getNodePosition(nodeEl);
using tempNode = this.moveNodeToWorkspace(nodeEl);
tempNode.moveTo(startPosition);
using ghost = this.createGhostNode(nodeEl);
ghost.moveTo(startPosition);
let currentNodePos = startPosition;
let dockTargetId: string | undefined = undefined;
using edgePan = this.startEdgePanning((dx, dy) => {
currentNodePos = {
x: currentNodePos.x + dx,
y: currentNodePos.y + dy,
};
tempNode.moveTo(currentNodePos);
this.updateConnections(attrs);
});
for await (const mv of drag) {
const {zoom} = this.viewport;
edgePan.updatePointer(mv.client);
currentNodePos = {
x: currentNodePos.x + mv.delta.x / zoom,
y: currentNodePos.y + mv.delta.y / zoom,
};
tempNode.moveTo(currentNodePos);
dockTargetId = node.canDockTop
? this.findDockTarget(nodeEl)
: undefined;
console.log('Dock target:', dockTargetId);
if (dockTargetId) {
const targetEl = this.getNodeElement(dockTargetId);
ghost.dockToNode(targetEl);
} else {
ghost.undock();
ghost.moveTo({
x: snapToGrid(currentNodePos.x),
y: snapToGrid(currentNodePos.y),
});
}
this.updateConnections(attrs);
}
if (dockTargetId) {
if (dockTargetId !== parentId) {
onNodeDock?.(node.id, dockTargetId);
}
} else {
onNodeMove?.(
node.id,
snapToGrid(currentNodePos.x),
snapToGrid(currentNodePos.y),
);
}
m.redraw();
} else {
// Failed drag - treat as a click and select the node
if (e.ctrlKey || e.metaKey) {
if (selectedNodeIds?.has(node.id)) {
onSelectionRemove?.(node.id);
} else {
onSelectionAdd?.(node.id);
}
} else {
// No key held - just select this node and deselect everything else
onSelect?.([node.id]);
}
m.redraw();
}
},
},
m(
NGCard,
{
hue: node.hue,
accent: node.accentBar,
selected: selectedNodeIds?.has(node.id),
className: node.className,
},
[
node.titleBar &&
m(NGCardHeader, {
title: node.titleBar.title,
icon: node.titleBar.icon,
}),
node.inputs?.map((input) =>
m(NGPort, {
id: input.id,
direction: input.direction,
portType: 'input',
label: input.label,
connected:
attrs.connections?.some((c) => c.toPort === input.id) ??
false,
onpointerdown: (e: PointerEvent) => {
// Stop propagation to prevent the node from being dragged.
e.stopPropagation();
},
onclick: () => {
const connIdx =
attrs.connections?.findIndex(
(c) => c.toPort === input.id,
) ?? -1;
if (connIdx !== -1) {
attrs.onDisconnect?.(connIdx);
}
},
}),
),
m(NGCardBody, node.content),
node.outputs?.map((output) => {
const portEl = m(NGPort, {
id: output.id,
direction: output.direction,
portType: 'output',
label: output.label,
connected:
attrs.connections?.some((c) => c.fromPort === output.id) ??
false,
onpointerdown: async (e: PointerEvent) => {
e.stopPropagation();
const drag = await captureDrag({
el: e.currentTarget as HTMLElement,
e,
});
if (!drag) {
// Click (no drag): open context menu if available
if (output.contextMenuItems != null) {
this.openPortMenuId = output.id;
m.redraw();
}
return;
}
let clientX = e.clientX;
let clientY = e.clientY;
const toCanvasPoint = (): Point2D => {
const {zoom, offset} = this.viewport;
const rect = this.rootEl.getBoundingClientRect();
return {
x: (clientX - rect.left) / zoom + offset.x,
y: (clientY - rect.top) / zoom + offset.y,
};
};
const nodeDirToPortDir: Record<string, PortDirection> = {
north: 'top',
south: 'bottom',
east: 'right',
west: 'left',
};
const freeToDir = oppositeDir(
nodeDirToPortDir[output.direction] ?? 'right',
);
const makeWireDrag = (snap: typeof snapTarget) => ({
fromPortId: output.id,
toPoint: snap
? this.portCenterToCanvas(snap.el)
: toCanvasPoint(),
toDir: snap ? portDirFromEl(snap.el) : freeToDir,
});
this.wireDrag = makeWireDrag(undefined);
this.rootEl.classList.add('pf-ng--wire-dragging');
this.updateConnections(attrs);
let snapTarget: {el: HTMLElement; portId: string} | undefined;
using edgePan = this.startEdgePanning(() => {
this.wireDrag = makeWireDrag(snapTarget);
this.updateConnections(attrs);
});
const processMove = (client: {x: number; y: number}) => {
clientX = client.x;
clientY = client.y;
edgePan.updatePointer(client);
const prevSnap = snapTarget;
snapTarget = this.findWireSnapTarget(
node.id,
clientX,
clientY,
);
if (prevSnap?.el !== snapTarget?.el) {
prevSnap?.el.classList.remove('pf-wire-snap');
snapTarget?.el.classList.add('pf-wire-snap');
}
this.wireDrag = makeWireDrag(snapTarget);
this.updateConnections(attrs);
};
for await (const mv of drag) {
processMove(mv.client);
}
snapTarget?.el.classList.remove('pf-wire-snap');
this.rootEl.classList.remove('pf-ng--wire-dragging');
if (snapTarget !== undefined) {
onConnect?.({
fromPort: output.id,
toPort: snapTarget.portId,
});
} else {
// Fallback: check if the pointer is directly over an input port.
const target = document.elementFromPoint(clientX, clientY);
const inputPortEl =
target?.closest('.pf-ng__port-dot.pf-input') ?? null;
if (inputPortEl) {
const nodeEl = inputPortEl.closest(
'[data-node-id]',
) as HTMLElement | null;
const toNodeId = nodeEl?.getAttribute('data-node-id');
const portId = inputPortEl.getAttribute('data-port-id');
if (portId && toNodeId && toNodeId !== node.id) {
onConnect?.({
fromPort: output.id,
toPort: portId,
});
}
}
}
this.wireDrag = undefined;
this.updateConnections(attrs);
m.redraw();
},
});
if (output.contextMenuItems == null) return portEl;
return m(
PopupMenu,
{
trigger: portEl,
position: PopupPosition.Bottom,
isOpen: this.openPortMenuId === output.id,
onChange: (open) => {
this.openPortMenuId = open ? output.id : undefined;
m.redraw();
},
},
output.contextMenuItems,
);
}),
],
),
);
};
return m(
'.pf-ng',
{
style,
className,
onwheel: (e: MithrilEvent<WheelEvent>) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const newZoom =
this.viewport.zoom *
Math.exp(-e.deltaY * WHEEL_ZOOM_SCALING_FACTOR);
this.zoomViewport(newZoom, {x: e.clientX, y: e.clientY});
} else {
this.panViewport(e.deltaX, e.deltaY);
}
onViewportMove?.(this.viewport);
},
oncontextmenu: (e: MouseEvent) => {
e.preventDefault();
},
onpointerdown: async (e: MithrilEvent<PointerEvent>) => {
e.redraw = false;
const nodegraph = this.rootEl;
const isBoxSelect = e.shiftKey;
const drag = await captureDrag({
el: nodegraph,
e,
deadzone: 2,
});
if (drag) {
if (isBoxSelect) {
const boxEl = document.createElement('div');
boxEl.style.cssText =
'position:absolute;pointer-events:none;' +
'border:1px dashed var(--pf-color-primary);' +
'background:color-mix(in srgb,var(--pf-color-primary) 10%,transparent);' +
'z-index:10;';
this.rootEl.appendChild(boxEl);
const updateBox = (currentClient: {x: number; y: number}) => {
const ngRect = this.rootEl.getBoundingClientRect();
const x1 = Math.min(e.clientX, currentClient.x) - ngRect.left;
const y1 = Math.min(e.clientY, currentClient.y) - ngRect.top;
const x2 = Math.max(e.clientX, currentClient.x) - ngRect.left;
const y2 = Math.max(e.clientY, currentClient.y) - ngRect.top;
Object.assign(boxEl.style, {
left: `${x1}px`,
top: `${y1}px`,
width: `${x2 - x1}px`,
height: `${y2 - y1}px`,
});
};
let currentClient = {x: e.clientX, y: e.clientY};
for await (const mv of drag) {
currentClient = {x: mv.client.x, y: mv.client.y};
updateBox(currentClient);
}
boxEl.remove();
const boxLeft = Math.min(e.clientX, currentClient.x);
const boxTop = Math.min(e.clientY, currentClient.y);
const boxRight = Math.max(e.clientX, currentClient.x);
const boxBottom = Math.max(e.clientY, currentClient.y);
const ids: string[] = [];
for (const nodeEl of this.rootEl.querySelectorAll(
'[data-node-id]',
)) {
const r = nodeEl.getBoundingClientRect();
if (
r.left < boxRight &&
r.right > boxLeft &&
r.top < boxBottom &&
r.bottom > boxTop
) {
ids.push(nodeEl.getAttribute('data-node-id')!);
}
}
if (ids.length > 0) {
onSelect?.(ids);
}
} else {
for await (const mv of drag) {
this.panViewport(-mv.delta.x, -mv.delta.y);
}
onViewportMove?.(this.viewport);
}
} else {
onSelectionClear?.();
}
m.redraw();
},
},
m(
'.pf-ng__workspace',
nodes.map((n) => renderNode(n)),
m('svg.pf-ng__connections', {
style: {
position: 'absolute',
left: '0',
top: '0',
overflow: 'visible',
pointerEvents: 'none',
},
}),
),
m(NGToolbar, {
zoom: this.viewport.zoom,
onZoom: (level) => {
this.zoomViewport(level);
onViewportMove?.(this.viewport);
},
onFit: () => {
this.autofit();
onViewportMove?.(this.viewport);
},
extraItems: toolbarItems,
}),
m('.pf-ng__trashcan'),
);
}
oncreate({dom, attrs}: m.VnodeDOM<NodeGraphAttrs>) {
this.rootEl = assertIsInstance(dom, HTMLElement);
this.workspaceEl = assertIsInstance(
dom.querySelector('.pf-ng__workspace'),
HTMLElement,
);
this.svgEl = assertIsInstance(
dom.querySelector('.pf-ng__connections'),
SVGSVGElement,
);
const {initialViewport = {offset: {x: 0, y: 0}, zoom: 1.0}} = attrs;
this.viewport = {
zoom: initialViewport.zoom,
offset: {...initialViewport.offset},
};
this.updateViewport();
this.updateConnections(attrs);
}
onupdate({attrs}: m.VnodeDOM<NodeGraphAttrs>) {
this.updateConnections(attrs);
}
private getNodeElement(nodeId: string) {
return assertIsInstance(
this.rootEl.querySelector(`[data-node-id="${nodeId}"]`),
HTMLElement,
);
}
// Returns the ID of the root node whose chain bottom is close enough to
// snap-dock the dropped node onto. The dragged node must have canDockTop and
// the candidate must have canDockBottom. Position comparison is done in
// viewport space using the rendered wrapper's bounding rect.
// Returns the ID of a node whose bottom edge is close to the top of the
// dragged node element, with some horizontal overlap.
private findDockTarget(draggedNodeEl: HTMLElement): string | undefined {
const THRESHOLD = 30; // viewport px
const draggedRect = draggedNodeEl.getBoundingClientRect();
for (const el of this.rootEl.querySelectorAll('[data-node-id]')) {
const id = el.getAttribute('data-node-id')!;
// Use the node-content bottom so nested docked children don't inflate the rect.
const body = el.querySelector(
':scope > .pf-ng__card',
) as HTMLElement | null;
const rect = (body ?? (el as HTMLElement)).getBoundingClientRect();
if (
Math.abs(draggedRect.top - rect.bottom) < THRESHOLD &&
draggedRect.right > rect.left - THRESHOLD &&
draggedRect.left < rect.right + THRESHOLD
) {
return id;
}
}
return undefined;
}
// Returns the input port element + connection target closest to (clientX,
// clientY), or undefined if nothing is within the snap threshold.
private findWireSnapTarget(
fromNodeId: string,
clientX: number,
clientY: number,
): {el: HTMLElement; portId: string} | undefined {
const THRESHOLD = 40; // viewport px
let best: {el: HTMLElement; portId: string; dist: number} | undefined;
for (const el of this.rootEl.querySelectorAll(
'.pf-ng__port-dot.pf-input',
)) {
if (el.closest('.pf-hidden')) continue;
const nodeEl = el.closest('[data-node-id]') as HTMLElement | null;
const toNodeId = nodeEl?.getAttribute('data-node-id');
if (!toNodeId || toNodeId === fromNodeId) continue;
const portId = el.getAttribute('data-port-id');
if (!portId) continue;
const rect = el.getBoundingClientRect();
const dist = Math.hypot(
clientX - (rect.left + rect.width / 2),
clientY - (rect.top + rect.height / 2),
);
if (dist < THRESHOLD && (!best || dist < best.dist)) {
best = {el: el as HTMLElement, portId, dist};
}
}
return best ? {el: best.el, portId: best.portId} : undefined;
}
// Converts an input port element's center to canvas coordinates.
private portCenterToCanvas(portEl: HTMLElement): Point2D {
const rect = portEl.getBoundingClientRect();
const ngRect = this.rootEl.getBoundingClientRect();
const {zoom, offset} = this.viewport;
return {
x: (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x,
y: (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y,
};
}
private updateViewport() {
const {offset, zoom} = this.viewport;
const canvas = this.rootEl;
this.workspaceEl.style.transform = `scale(${zoom}) translate(${-offset.x}px, ${-offset.y}px)`;
const {size, posX, posY} = dotBackground(zoom, offset);
canvas.style.setProperty('--bg-size', `${size}px`);
canvas.style.setProperty('--bg-pos-x', `${-posX}px`);
canvas.style.setProperty('--bg-pos-y', `${-posY}px`);
}
private zoomViewport(zoom: number, center?: Point2D) {
const canvas = this.rootEl;
const rect = canvas.getBoundingClientRect();
if (!center) {
center = {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2};
}
const {offset, zoom: currentZoom} = this.viewport;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
const mouseX = center.x - rect.left;
const mouseY = center.y - rect.top;
const canvasX = mouseX / currentZoom + offset.x;
const canvasY = mouseY / currentZoom + offset.y;
this.viewport = {
zoom: newZoom,
offset: {
x: canvasX - mouseX / newZoom,
y: canvasY - mouseY / newZoom,
},
};
this.updateViewport();
}
private panViewport(dx: number, dy: number) {
const {offset, zoom} = this.viewport;
this.viewport = {
...this.viewport,
offset: {x: offset.x + dx / zoom, y: offset.y + dy / zoom},
};
this.updateViewport();
}
// Starts a RAF loop that pans the viewport when the pointer is near an edge.
// `getClientPos` returns the current pointer position in client coordinates.
// `onPan` (optional) is called with canvas-space deltas so the caller can
// update any accumulated canvas-space state (e.g. node position).
// Returns a stop function; call it when the drag ends.
private startEdgePanning(
onPan?: (canvasDx: number, canvasDy: number) => void,
) {
const ZONE = 30; // viewport px from edge where panning begins
const MAX_SPEED = 10; // viewport px per frame at the very edge
const edgeForce = (v: number, lo: number, hi: number): number => {
if (v < lo + ZONE) return (-(lo + ZONE - v) / ZONE) * MAX_SPEED;
if (v > hi - ZONE) return ((v - (hi - ZONE)) / ZONE) * MAX_SPEED;
return 0;
};
let rafId: number | undefined;
let latestPointerPos: Point2D | undefined;
const frame = () => {
const {x: cx, y: cy} = latestPointerPos!;
const rect = this.rootEl.getBoundingClientRect();
const vpDx = edgeForce(cx, rect.left, rect.right);
const vpDy = edgeForce(cy, rect.top, rect.bottom);
if (vpDx !== 0 || vpDy !== 0) {
const {zoom} = this.viewport;
this.panViewport(vpDx, vpDy);
onPan?.(vpDx / zoom, vpDy / zoom);
}
rafId = requestAnimationFrame(frame);
};
return {
updatePointer: (pos: Point2D) => {
latestPointerPos = pos;
if (rafId === undefined) rafId = requestAnimationFrame(frame);
},
[Symbol.dispose]: () => {
if (rafId !== undefined) cancelAnimationFrame(rafId);
},
};
}
private updateConnections(attrs: NodeGraphAttrs) {
const {connections = []} = attrs;
const ngRect = this.rootEl.getBoundingClientRect();
const {zoom, offset} = this.viewport;
const getPortInfo = (portId: string) => {
const candidates = this.rootEl.querySelectorAll(
`.pf-ng__port-dot[data-port-id="${portId}"]`,
);
const el = Array.from(candidates).find(
(e) => e.closest('.pf-hidden') === null,
) as HTMLElement | undefined;
if (!el) return undefined;
const rect = el.getBoundingClientRect();
const dir = portDirFromEl(el);
return {
x: (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x,
y: (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y,
dir,
};
};
const paths = connections.flatMap((conn) => {
const from = getPortInfo(conn.fromPort);
const to = getPortInfo(conn.toPort);
if (!from || !to) return [];
return [connectionPath(from, to, this.markerId, from.dir, to.dir)];
});
if (this.wireDrag) {
const {fromPortId, toPoint, toDir} = this.wireDrag;
const from = getPortInfo(fromPortId);
if (from) {
paths.push(
connectionPath(from, toPoint, this.markerId, from.dir, toDir, {
'stroke-dasharray': '6 3',
}),
);
}
}
m.render(this.svgEl, [m('defs', arrowheadMarker(this.markerId)), ...paths]);
}
private autofit() {
const PADDING = 40; // screen px of breathing room on each side
const nodeEls = Array.from(
this.workspaceEl.querySelectorAll(':scope > [data-node-id]'),
) as HTMLElement[];
// If there are no nodes, do nothing.
if (nodeEls.length === 0) {
return;
}
// Node left/top style is in canvas px; offsetWidth/Height are layout px
// (unaffected by the workspace CSS transform), so also canvas px.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const el of nodeEls) {
const x = parseFloat(el.style.left) || 0;
const y = parseFloat(el.style.top) || 0;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + el.offsetWidth);
maxY = Math.max(maxY, y + el.offsetHeight);
}
const containerW = this.rootEl.offsetWidth;
const containerH = this.rootEl.offsetHeight;
const bbW = maxX - minX;
const bbH = maxY - minY;
const zoom = Math.max(
MIN_ZOOM,
Math.min(
1.0,
Math.min(
(containerW - 2 * PADDING) / bbW,
(containerH - 2 * PADDING) / bbH,
),
),
);
// Center: canvas midpoint should map to screen midpoint.
// screen = (canvas - offset) * zoom → offset = canvas - screen / zoom
this.viewport = {
zoom,
offset: {
x: (minX + maxX) / 2 - containerW / (2 * zoom),
y: (minY + maxY) / 2 - containerH / (2 * zoom),
},
};
this.updateViewport();
}
}