Files
tools/vue/primitives/src/canvas/flow/composables/useNodeDrag.ts
T
robonen d2838ba8ee fix(primitives): flow renders visibly by default and its declared events fire
- pane fills its parent instead of collapsing to the 0px strip every
  consumer debugged as a data bug; background/viewport/panel get a
  default stacking triple (0/1/2) so chrome no longer paints over nodes
- nodeClick/edgeClick/paneClick were declared in FlowRootEmits but never
  emitted — wired for real; nodeDoubleClick synthesized in the drag
  layer (it already tells clicks from drags), and dblclick-zoom ignores
  [data-flow-node] so opening a node no longer also zooms the canvas
- FlowEdge.label was typed but never rendered — the default edge now
  draws a haloed midpoint label, and label joins the v-memo keys so
  edits are not frozen by the memo
- fitViewOnMount prop fits once nodes AND the pane are measured (either
  can finish first), skipped when the viewport is controlled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:22:28 +07:00

189 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MaybeRefOrGetter, Ref } from 'vue';
import { onScopeDispose, shallowRef, toValue } from 'vue';
import { useEventListener } from '@robonen/vue';
import { clamp } from '@robonen/stdlib';
import type { FlowContext } from '../context';
import type { InternalNode, XYPosition } from '../types';
import { snapPoint } from '../utils';
import { capturePointer, releasePointer } from './dom';
/** Constrain a dragged position by a node's `extent` (parent box or explicit rect). */
function applyExtent(pos: XYPosition, node: InternalNode, lookup: Map<string, InternalNode>): XYPosition {
if (node.extent === 'parent' && node.parentId) {
const parent = lookup.get(node.parentId);
if (!parent) return pos;
return {
x: clamp(pos.x, 0, Math.max(0, parent.measured.width - node.measured.width)),
y: clamp(pos.y, 0, Math.max(0, parent.measured.height - node.measured.height)),
};
}
if (Array.isArray(node.extent)) {
const [[x1, y1], [x2, y2]] = node.extent;
return {
x: clamp(pos.x, x1, x2 - node.measured.width),
y: clamp(pos.y, y1, y2 - node.measured.height),
};
}
return pos;
}
export interface NodeDragOptions {
/** Pixels the pointer must travel before a click becomes a drag. @default 1 */
threshold?: number;
}
/** Elements inside a node that must not initiate a drag. */
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
/** Two settled clicks within this window read as a double click. */
const DOUBLE_CLICK_MS = 350;
/**
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
* the grid. Positions are written to the transient drag overlay every frame and
* committed to the model once on pointerup — never 60×/sec.
*
* Stops propagation so the pane never pans while a node is grabbed. A pointer
* that never crosses `threshold` stays a click (selection handled elsewhere).
*/
export function useNodeDrag(
target: Ref<HTMLElement | undefined>,
ctx: FlowContext,
nodeId: MaybeRefOrGetter<string>,
options: NodeDragOptions = {},
): { dragging: Readonly<Ref<boolean>> } {
const threshold = options.threshold ?? 1;
const dragging = shallowRef(false);
let pointerId = -1;
let startX = 0;
let startY = 0;
let started = false;
let lastClickAt = 0;
let lastX = 0;
let lastY = 0;
let rafId: number | null = null;
/** id → starting flow-space position, snapshot at pointerdown. */
let snapshot = new Map<string, XYPosition>();
/** Reused across RAF frames — only the container is pooled; entries are fresh. */
const nextPositions = new Map<string, XYPosition>();
function eligible(): boolean {
if (!ctx.interactive.value || !ctx.nodesDraggable.value) return false;
const node = ctx.nodeLookup.value.get(toValue(nodeId));
return !!node && node.draggable !== false;
}
function collectDragSet(id: string): Map<string, XYPosition> {
const set = new Map<string, XYPosition>();
const selected = ctx.selection.value.nodes;
const ids = selected.has(id) && selected.size > 1 ? selected : new Set([id]);
for (const nid of ids) {
const node = ctx.nodeLookup.value.get(nid);
if (node && node.draggable !== false)
set.set(nid, { x: node.position.x, y: node.position.y });
}
return set;
}
function flush() {
rafId = null;
const dx = (lastX - startX) / ctx.viewport.value.zoom;
const dy = (lastY - startY) / ctx.viewport.value.zoom;
const snap = ctx.snapToGrid.value;
const grid = ctx.snapGrid.value;
const next = nextPositions;
next.clear();
for (const [id, start] of snapshot) {
let pos: XYPosition = { x: start.x + dx, y: start.y + dy };
if (snap) pos = snapPoint(pos, grid);
const node = ctx.nodeLookup.value.get(id);
if (node?.extent) pos = applyExtent(pos, node, ctx.nodeLookup.value);
next.set(id, pos);
}
ctx.updateNodePositions(next, true);
}
function schedule() {
if (rafId === null) rafId = requestAnimationFrame(flush);
}
onScopeDispose(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
});
useEventListener(target, 'pointerdown', (event: PointerEvent) => {
if (event.button !== 0) return;
if (event.target instanceof Element && event.target.closest(NO_DRAG_SELECTOR)) return;
// Always stop the pane from treating this as a pan, even when not draggable.
event.stopPropagation();
if (!eligible()) return;
const id = toValue(nodeId);
snapshot = collectDragSet(id);
if (snapshot.size === 0) return;
pointerId = event.pointerId;
startX = lastX = event.clientX;
startY = lastY = event.clientY;
started = false;
capturePointer(target.value, event.pointerId);
});
useEventListener(target, 'pointermove', (event: PointerEvent) => {
if (pointerId !== event.pointerId || snapshot.size === 0) return;
lastX = event.clientX;
lastY = event.clientY;
if (!started) {
if (Math.abs(lastX - startX) < threshold && Math.abs(lastY - startY) < threshold) return;
started = true;
dragging.value = true;
}
schedule();
});
function endDrag(event: PointerEvent) {
if (pointerId !== event.pointerId) return;
releasePointer(target.value, event.pointerId);
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (started) {
flush();
ctx.commitNodeDrag();
lastClickAt = 0;
}
else if (snapshot.size > 0) {
// The pointer never crossed the drag threshold: this is a click. The
// pane cannot see it (propagation stopped on pointerdown), so the node
// is the only place that can report it — and pair two settled clicks
// into a double click.
const id = toValue(nodeId);
ctx.emitNodeClick(id, event);
const now = event.timeStamp;
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
ctx.emitNodeDoubleClick(id, event);
lastClickAt = 0;
}
else {
lastClickAt = now;
}
}
pointerId = -1;
started = false;
dragging.value = false;
snapshot = new Map();
}
useEventListener(target, 'pointerup', endDrag);
useEventListener(target, 'pointercancel', endDrag);
return { dragging };
}