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
|
<svg
|
||||||
data-flow-background=""
|
data-flow-background=""
|
||||||
:data-variant="variant"
|
: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
|
<pattern
|
||||||
:id="patternId"
|
:id="patternId"
|
||||||
|
|||||||
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
|
|||||||
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
|
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
|
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
|
||||||
|
ctx.emitEdgeClick(id, event);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<g
|
<g
|
||||||
v-if="endpoints"
|
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-flow-edge=""
|
||||||
:data-id="id"
|
:data-id="id"
|
||||||
:data-type="resolvedType"
|
:data-type="resolvedType"
|
||||||
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
|
|||||||
:style="interactionPathStyle"
|
:style="interactionPathStyle"
|
||||||
@pointerdown="onPointerdown"
|
@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>
|
</template>
|
||||||
</g>
|
</g>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
|
|||||||
|
|
||||||
useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
||||||
const target = event.target as Element | null;
|
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.clearSelection();
|
||||||
|
ctx.emitPaneClick(event as PointerEvent);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
|||||||
:data-interactive="ctx.interactive.value ? '' : undefined"
|
:data-interactive="ctx.interactive.value ? '' : undefined"
|
||||||
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
|
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
|
||||||
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
|
: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 />
|
<slot />
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
|
|||||||
|
|
||||||
const style = computed<CSSProperties>(() => {
|
const style = computed<CSSProperties>(() => {
|
||||||
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
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';
|
s[v] = '0';
|
||||||
if (h === 'center') {
|
if (h === 'center') {
|
||||||
s.left = '50%';
|
s.left = '50%';
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export interface FlowRootProps extends PrimitiveProps {
|
|||||||
isValidConnection?: IsValidConnection;
|
isValidConnection?: IsValidConnection;
|
||||||
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
|
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
|
||||||
onlyRenderVisibleElements?: boolean;
|
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 */
|
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
|
||||||
virtualizationBuffer?: number;
|
virtualizationBuffer?: number;
|
||||||
}
|
}
|
||||||
@@ -87,12 +95,13 @@ export interface FlowRootEmits {
|
|||||||
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
||||||
paneClick: [event: PointerEvent];
|
paneClick: [event: PointerEvent];
|
||||||
nodeClick: [id: string, event: PointerEvent];
|
nodeClick: [id: string, event: PointerEvent];
|
||||||
|
nodeDoubleClick: [id: string, event: PointerEvent];
|
||||||
edgeClick: [id: string, event: PointerEvent];
|
edgeClick: [id: string, event: PointerEvent];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useId } from '@robonen/vue';
|
||||||
import FlowPane from './FlowPane.vue';
|
import FlowPane from './FlowPane.vue';
|
||||||
import FlowViewport from './FlowViewport.vue';
|
import FlowViewport from './FlowViewport.vue';
|
||||||
@@ -124,6 +133,7 @@ const {
|
|||||||
disableKeyboardA11y = false,
|
disableKeyboardA11y = false,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
onlyRenderVisibleElements = false,
|
onlyRenderVisibleElements = false,
|
||||||
|
fitViewOnMount = false,
|
||||||
virtualizationBuffer = 200,
|
virtualizationBuffer = 200,
|
||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<FlowRootProps>();
|
} = defineProps<FlowRootProps>();
|
||||||
@@ -330,6 +340,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
|
|||||||
// pick up the fresh measurement / handle geometry.
|
// pick up the fresh measurement / handle geometry.
|
||||||
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
|
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
|
||||||
triggerRef(nodeLookup);
|
triggerRef(nodeLookup);
|
||||||
|
maybeFitOnMount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNode(id: string, patch: Partial<FlowNode>): void {
|
function updateNode(id: string, patch: Partial<FlowNode>): void {
|
||||||
@@ -515,12 +526,56 @@ const context: FlowContext = {
|
|||||||
endConnection,
|
endConnection,
|
||||||
emitNodesChange: changes => emit('nodesChange', changes),
|
emitNodesChange: changes => emit('nodesChange', changes),
|
||||||
emitEdgesChange: changes => emit('edgesChange', 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);
|
provideFlowContext(context);
|
||||||
|
|
||||||
// Imperative API, also exposed so consumers can drive the flow via a template ref.
|
// Imperative API, also exposed so consumers can drive the flow via a template ref.
|
||||||
const api = useViewportApi(context);
|
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 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-')));
|
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const transform = computed(() => {
|
|||||||
left: '0',
|
left: '0',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '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',
|
transformOrigin: '0 0',
|
||||||
transform,
|
transform,
|
||||||
willChange: ctx.isInteracting.value ? 'transform' : undefined,
|
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. */
|
/** Elements inside a node that must not initiate a drag. */
|
||||||
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
|
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-capture node drag. Moves the node (and every co-selected node) by the
|
||||||
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
|
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
|
||||||
@@ -57,6 +60,7 @@ export function useNodeDrag(
|
|||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let started = false;
|
let started = false;
|
||||||
|
let lastClickAt = 0;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
@@ -150,6 +154,26 @@ export function useNodeDrag(
|
|||||||
if (started) {
|
if (started) {
|
||||||
flush();
|
flush();
|
||||||
ctx.commitNodeDrag();
|
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;
|
pointerId = -1;
|
||||||
started = false;
|
started = false;
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function usePanZoom(
|
|||||||
// ── double-click zoom ──────────────────────────────────────────────────────
|
// ── double-click zoom ──────────────────────────────────────────────────────
|
||||||
useEventListener(target, 'dblclick', (event: MouseEvent) => {
|
useEventListener(target, 'dblclick', (event: MouseEvent) => {
|
||||||
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
|
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 vp = current();
|
||||||
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
|
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
|
||||||
if (newZoom === vp.zoom) return;
|
if (newZoom === vp.zoom) return;
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export interface FlowContext {
|
|||||||
// ── change emission ──────────────────────────────────────────────────────
|
// ── change emission ──────────────────────────────────────────────────────
|
||||||
emitNodesChange: (changes: NodeChange[]) => void;
|
emitNodesChange: (changes: NodeChange[]) => void;
|
||||||
emitEdgesChange: (changes: EdgeChange[]) => 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');
|
const flow = useContextFactory<FlowContext>('FlowContext');
|
||||||
|
|||||||
Reference in New Issue
Block a user