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>
This commit is contained in:
@@ -54,7 +54,7 @@ const linePath = computed(() => {
|
||||
<svg
|
||||
data-flow-background=""
|
||||
:data-variant="variant"
|
||||
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
|
||||
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, color }"
|
||||
>
|
||||
<pattern
|
||||
:id="patternId"
|
||||
|
||||
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
|
||||
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
|
||||
event.stopPropagation();
|
||||
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
|
||||
ctx.emitEdgeClick(id, event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<g
|
||||
v-if="endpoints"
|
||||
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
|
||||
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
|
||||
data-flow-edge=""
|
||||
:data-id="id"
|
||||
:data-type="resolvedType"
|
||||
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
|
||||
:style="interactionPathStyle"
|
||||
@pointerdown="onPointerdown"
|
||||
/>
|
||||
<!-- The halo (paint-order + stroke) keeps the text legible over the
|
||||
path and the background without the consumer styling anything. -->
|
||||
<text
|
||||
v-if="edge?.label"
|
||||
data-flow-edge-label=""
|
||||
:x="path[1]"
|
||||
:y="path[2]"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
fill="currentColor"
|
||||
stroke="var(--flow-edge-label-halo, white)"
|
||||
stroke-width="3"
|
||||
paint-order="stroke"
|
||||
:style="{ pointerEvents: 'none', fontSize: '12px' }"
|
||||
>{{ edge.label }}</text>
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
|
||||
|
||||
useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
|
||||
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
|
||||
ctx.clearSelection();
|
||||
ctx.emitPaneClick(event as PointerEvent);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
||||
:data-interactive="ctx.interactive.value ? '' : undefined"
|
||||
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
|
||||
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
|
||||
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
touchAction: 'none',
|
||||
// Everything inside is absolutely positioned, so content-sizing always
|
||||
// collapsed to 0×N and the graph rendered into an invisible strip.
|
||||
// Vue merges a consumer's style attr over this, so it stays overridable.
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
|
||||
|
||||
const style = computed<CSSProperties>(() => {
|
||||
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
||||
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
|
||||
// Above the viewport's explicit layer (zIndex 1): a positioned sibling
|
||||
// with z-index auto would otherwise paint underneath the graph.
|
||||
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
|
||||
s[v] = '0';
|
||||
if (h === 'center') {
|
||||
s.left = '50%';
|
||||
|
||||
@@ -73,6 +73,14 @@ export interface FlowRootProps extends PrimitiveProps {
|
||||
isValidConnection?: IsValidConnection;
|
||||
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
|
||||
onlyRenderVisibleElements?: boolean;
|
||||
/**
|
||||
* Frame the whole graph once after the initial nodes are measured. Skipped
|
||||
* when an explicit `viewport` / `defaultViewport` is provided — a restored
|
||||
* viewport must not be stomped by a fit. With virtualization the fit uses
|
||||
* whatever is measured plus declared node sizes; fully unmeasured nodes are
|
||||
* framed by position alone. @default false
|
||||
*/
|
||||
fitViewOnMount?: boolean | FitViewParams;
|
||||
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
|
||||
virtualizationBuffer?: number;
|
||||
}
|
||||
@@ -87,12 +95,13 @@ export interface FlowRootEmits {
|
||||
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
||||
paneClick: [event: PointerEvent];
|
||||
nodeClick: [id: string, event: PointerEvent];
|
||||
nodeDoubleClick: [id: string, event: PointerEvent];
|
||||
edgeClick: [id: string, event: PointerEvent];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
|
||||
import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
|
||||
import { useId } from '@robonen/vue';
|
||||
import FlowPane from './FlowPane.vue';
|
||||
import FlowViewport from './FlowViewport.vue';
|
||||
@@ -124,6 +133,7 @@ const {
|
||||
disableKeyboardA11y = false,
|
||||
isValidConnection,
|
||||
onlyRenderVisibleElements = false,
|
||||
fitViewOnMount = false,
|
||||
virtualizationBuffer = 200,
|
||||
as = 'div',
|
||||
} = defineProps<FlowRootProps>();
|
||||
@@ -330,6 +340,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
|
||||
// pick up the fresh measurement / handle geometry.
|
||||
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
|
||||
triggerRef(nodeLookup);
|
||||
maybeFitOnMount();
|
||||
}
|
||||
|
||||
function updateNode(id: string, patch: Partial<FlowNode>): void {
|
||||
@@ -515,12 +526,56 @@ const context: FlowContext = {
|
||||
endConnection,
|
||||
emitNodesChange: changes => emit('nodesChange', changes),
|
||||
emitEdgesChange: changes => emit('edgesChange', changes),
|
||||
emitNodeClick: (id, event) => emit('nodeClick', id, event),
|
||||
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
|
||||
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
|
||||
emitPaneClick: event => emit('paneClick', event),
|
||||
};
|
||||
provideFlowContext(context);
|
||||
|
||||
// Imperative API, also exposed so consumers can drive the flow via a template ref.
|
||||
const api = useViewportApi(context);
|
||||
|
||||
// ── fitViewOnMount ────────────────────────────────────────────────────────
|
||||
// A viewport the consumer controls (v-model:viewport) or seeds
|
||||
// (defaultViewport) is restored state; a fit must never stomp it. Model
|
||||
// getters fall back to a local default, so controlledness is read off the
|
||||
// vnode, not the value.
|
||||
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
|
||||
let fitOnMountPending = fitViewOnMount !== false
|
||||
&& defaultViewport === undefined
|
||||
&& !('viewport' in vnodeProps)
|
||||
&& !('onUpdate:viewport' in vnodeProps);
|
||||
|
||||
/**
|
||||
* Armed until it fires once: waits for every RENDERED node to report a
|
||||
* measurement — fitting to unmeasured nodes fits to nothing. Under
|
||||
* virtualization only the rendered subset ever measures; the rest contribute
|
||||
* their declared or positional bounds through `fitView` itself.
|
||||
*/
|
||||
function maybeFitOnMount(): void {
|
||||
if (!fitOnMountPending) return;
|
||||
|
||||
// Nodes can finish measuring before the pane has a size (or the reverse);
|
||||
// the shot must not burn against a 0×0 container, so both gates hold it and
|
||||
// the pane-rect watcher below re-arms the attempt.
|
||||
const rect = paneRect.value;
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const map = nodeLookup.value;
|
||||
if (map.size === 0) return;
|
||||
|
||||
for (const id of visibleNodeIds.value) {
|
||||
const n = map.get(id);
|
||||
if (n && n.measured.width === 0 && n.measured.height === 0) return;
|
||||
}
|
||||
|
||||
fitOnMountPending = false;
|
||||
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
|
||||
}
|
||||
|
||||
watch(paneRect, maybeFitOnMount);
|
||||
|
||||
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
|
||||
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ const transform = computed(() => {
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// The slot (background, panels) renders after this element; explicit
|
||||
// layers keep the graph above the background and below the chrome.
|
||||
zIndex: 1,
|
||||
transformOrigin: '0 0',
|
||||
transform,
|
||||
willChange: ctx.isInteracting.value ? 'transform' : undefined,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import type { FlowEdge, FlowNode } from '../index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { h, nextTick } from 'vue';
|
||||
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
|
||||
|
||||
/**
|
||||
* Regressions found by building a real story-map consumer: the pane rendered
|
||||
* into zero area, the background painted over the graph, edge labels never
|
||||
* rendered, the declared click emits never fired, and dblclick on a node
|
||||
* zoomed the canvas. Each test pins the fixed contract.
|
||||
*/
|
||||
|
||||
const wrappers: Array<VueWrapper<any>> = [];
|
||||
afterEach(() => {
|
||||
while (wrappers.length) wrappers.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
function track<T extends VueWrapper<any>>(w: T): T {
|
||||
wrappers.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
const nodes: FlowNode[] = [
|
||||
{ id: 'a', position: { x: 0, y: 0 } },
|
||||
{ id: 'b', position: { x: 300, y: 200 } },
|
||||
];
|
||||
|
||||
function pointer(el: Element, type: string, x = 10, y = 10) {
|
||||
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
|
||||
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
|
||||
const el = w.element as HTMLElement;
|
||||
el.style.width = `${width}px`;
|
||||
el.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
const edges: FlowEdge[] = [
|
||||
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
|
||||
];
|
||||
|
||||
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
|
||||
return track(mount(FlowRoot, {
|
||||
attachTo: document.body,
|
||||
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
|
||||
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
|
||||
}));
|
||||
}
|
||||
|
||||
describe('pane sizing', () => {
|
||||
it('fills its parent instead of collapsing to zero height', () => {
|
||||
const w = flow();
|
||||
sizeWrapper(w);
|
||||
|
||||
const pane = w.find('[data-flow-pane]').element as HTMLElement;
|
||||
|
||||
// All pane content is absolutely positioned; without an own height the
|
||||
// whole graph rendered inside an invisible 0px strip.
|
||||
expect(pane.clientHeight).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stacking', () => {
|
||||
it('layers background under the graph and panels above it', () => {
|
||||
const w = flow({}, {
|
||||
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
|
||||
});
|
||||
|
||||
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
|
||||
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
|
||||
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
|
||||
|
||||
// The slot chrome renders AFTER the viewport in DOM order; without these
|
||||
// layers the background dots painted over every node.
|
||||
expect(Number(background)).toBeLessThan(Number(viewport));
|
||||
expect(Number(panel)).toBeGreaterThan(Number(viewport));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge labels', () => {
|
||||
it('renders the label the type always promised', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
const label = w.find('[data-flow-edge-label]');
|
||||
|
||||
expect(label.exists()).toBe(true);
|
||||
expect(label.text()).toBe('take me');
|
||||
});
|
||||
|
||||
it('renders no label element when there is none', async () => {
|
||||
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
|
||||
await nextTick();
|
||||
|
||||
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the click family', () => {
|
||||
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
|
||||
const el = w.find(selector).element;
|
||||
|
||||
for (let index = 0; index < times; index++) {
|
||||
pointer(el, 'pointerdown');
|
||||
pointer(el, 'pointerup');
|
||||
await nextTick();
|
||||
if (gap)
|
||||
await new Promise(resolve => setTimeout(resolve, gap));
|
||||
}
|
||||
}
|
||||
|
||||
it('emits nodeClick for a settled click', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
await settle(w, '[data-flow-node][data-id="a"]');
|
||||
|
||||
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
|
||||
});
|
||||
|
||||
it('pairs two settled clicks into nodeDoubleClick', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
|
||||
|
||||
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
|
||||
});
|
||||
|
||||
it('emits paneClick only for background clicks', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
await w.find('[data-flow-pane]').trigger('click');
|
||||
expect(w.emitted('paneClick')).toHaveLength(1);
|
||||
|
||||
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
|
||||
expect(w.emitted('paneClick')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits edgeClick when the edge is picked', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
|
||||
await nextTick();
|
||||
|
||||
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
|
||||
});
|
||||
|
||||
it('does not zoom on a node double click', async () => {
|
||||
const w = flow();
|
||||
await nextTick();
|
||||
|
||||
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
|
||||
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
|
||||
await nextTick();
|
||||
|
||||
// The gesture belongs to the node (nodeDoubleClick), not the camera.
|
||||
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitViewOnMount', () => {
|
||||
it('frames the graph once nodes are measured', async () => {
|
||||
const w = flow({ fitViewOnMount: true });
|
||||
sizeWrapper(w);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
|
||||
expect(t).not.toBe('translate(0px, 0px) scale(1)');
|
||||
});
|
||||
});
|
||||
|
||||
it('never stomps a consumer-controlled viewport', async () => {
|
||||
const w = flow({
|
||||
fitViewOnMount: true,
|
||||
viewport: { x: 17, y: 23, zoom: 1.5 },
|
||||
'onUpdate:viewport': () => {},
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
|
||||
// A bound viewport is restored state; the fit must skip it entirely.
|
||||
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
|
||||
.toBe('translate(17px, 23px) scale(1.5)');
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
|
||||
/** 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
|
||||
@@ -57,6 +60,7 @@ export function useNodeDrag(
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let started = false;
|
||||
let lastClickAt = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let rafId: number | null = null;
|
||||
@@ -150,6 +154,26 @@ export function useNodeDrag(
|
||||
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;
|
||||
|
||||
@@ -158,7 +158,9 @@ export function usePanZoom(
|
||||
// ── double-click zoom ──────────────────────────────────────────────────────
|
||||
useEventListener(target, 'dblclick', (event: MouseEvent) => {
|
||||
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
|
||||
if (event.target instanceof Element && event.target.closest('.nopan')) return;
|
||||
// A double click on a node belongs to the node (nodeDoubleClick), not
|
||||
// to the zoom gesture.
|
||||
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
|
||||
const vp = current();
|
||||
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
|
||||
if (newZoom === vp.zoom) return;
|
||||
|
||||
@@ -121,6 +121,10 @@ export interface FlowContext {
|
||||
// ── change emission ──────────────────────────────────────────────────────
|
||||
emitNodesChange: (changes: NodeChange[]) => void;
|
||||
emitEdgesChange: (changes: EdgeChange[]) => void;
|
||||
emitNodeClick: (id: string, event: PointerEvent) => void;
|
||||
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
|
||||
emitEdgeClick: (id: string, event: PointerEvent) => void;
|
||||
emitPaneClick: (event: PointerEvent) => void;
|
||||
}
|
||||
|
||||
const flow = useContextFactory<FlowContext>('FlowContext');
|
||||
|
||||
Reference in New Issue
Block a user