Svelte NodeToolbar with E2E (#3643)

* Added NodeToolbar

* Added comments & fixed default behaviour in svelte

* Added e2e-tests for NodeToolbar component & added data-id to react NodeToolbar

* refactor(svelte/NodeToolbar): cleanup

* refactor(node-toolbar): add system utils

---------

Co-authored-by: moklick <info@moritzklack.com>
This commit is contained in:
peterkogo
2023-11-20 18:40:43 +01:00
committed by GitHub
co-authored by moklick
parent b659443507
commit b8affc1c82
22 changed files with 564 additions and 71 deletions
@@ -0,0 +1,19 @@
import { memo, FC } from 'react';
import { Handle, Position, NodeProps, NodeToolbar } from '@xyflow/react';
const CustomNode: FC<NodeProps> = ({ id, data }) => {
return (
<>
<NodeToolbar isVisible={data.toolbarVisible} position={data.toolbarPosition} align={data.toolbarAlign}>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</>
);
};
export default memo(CustomNode);
@@ -0,0 +1,50 @@
import { Position, type Node } from '@xyflow/react';
import ToolbarNode from './components/ToolbarNode';
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const nodes: Node[] = [
{
id: 'default-node',
type: 'ToolbarNode',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
className: 'react-flow__node-default',
},
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
nodes.push({
id,
type: 'ToolbarNode',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true,
},
className: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 },
});
});
});
export default {
flowProps: {
fitView: true,
nodeTypes: {
ToolbarNode,
},
nodes,
edges: [
{
id: 'first-edge',
source: 'default-node',
target: 'node-start-top',
},
],
},
} satisfies FlowConfig;
@@ -12,6 +12,7 @@
'figma',
'interaction',
'intersections',
'node-toolbar',
'overview',
'stress',
'subflows',
@@ -0,0 +1,30 @@
<script lang="ts">
import { NodeToolbar, type NodeProps, Handle, Position } from '@xyflow/svelte';
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
<NodeToolbar
isVisible={data.toolbarVisible}
position={data.toolbarPosition}
align={data.toolbarAlign}
>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div class="node">
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</div>
<style>
.node {
width: 200px;
height: 50px;
border: solid 1px black;
}
</style>
@@ -0,0 +1,50 @@
import { Position, type Node } from '@xyflow/svelte';
import ToolbarNode from './components/ToolbarNode.svelte';
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const nodes: Node[] = [
{
id: 'default-node',
type: 'ToolbarNode',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
class: 'react-flow__node-default'
}
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
nodes.push({
id,
type: 'ToolbarNode',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true
},
class: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 }
});
});
});
export default {
flowProps: {
fitView: true,
nodeTypes: {
ToolbarNode
},
nodes,
edges: [
{
id: 'first-edge',
source: 'default-node',
target: 'node-start-top'
}
]
}
} satisfies FlowConfig;
@@ -0,0 +1,63 @@
<script lang="ts">
import { writable } from 'svelte/store';
import {
SvelteFlow,
Background,
type Edge,
type Node,
Position,
type NodeTypes
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import CustomNode from './CustomNode.svelte';
import SelectedNodesToolbar from './SelectedNodesToolbar.svelte';
const nodeTypes: NodeTypes = {
custom: CustomNode
};
const positions = ['top', 'right', 'bottom', 'left'];
const alignments = ['start', 'center', 'end'];
const initialNodes: Node[] = [
{
id: 'default-node',
type: 'custom',
data: { label: 'toolbar top', toolbarPosition: Position.Top },
position: { x: 0, y: -200 },
class: 'react-flow__node-default'
}
];
positions.forEach((position, posIndex) => {
alignments.forEach((align, alignIndex) => {
const id = `node-${align}-${position}`;
initialNodes.push({
id,
type: 'custom',
data: {
label: `toolbar ${position} ${align}`,
toolbarPosition: position as Position,
toolbarAlign: align,
toolbarVisible: true
},
class: 'react-flow__node-default',
position: { x: posIndex * 300, y: alignIndex * 100 }
});
});
});
const initialEdges: Edge[] = [];
const nodes = writable<Node[]>(initialNodes);
const edges = writable<Edge[]>(initialEdges);
</script>
<div style="height: 100vh;">
<SvelteFlow {nodes} {edges} {nodeTypes} fitView>
<Background />
<SelectedNodesToolbar />
</SvelteFlow>
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import { NodeToolbar, type NodeProps, Handle, Position } from '@xyflow/svelte';
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
<NodeToolbar
isVisible={data.toolbarVisible}
position={data.toolbarPosition}
align={data.toolbarAlign}
>
<button>delete</button>
<button>copy</button>
<button>expand</button>
</NodeToolbar>
<div class="node">
<div>{data.label}</div>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</div>
<style>
.node {
width: 180px;
height: 50px;
border: solid 1px black;
background: white;
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { NodeToolbar, useNodes } from '@xyflow/svelte';
const nodes = useNodes();
$: selectedNodeIds = $nodes.filter((node) => node.selected).map((node) => node.id);
$: isVisible = selectedNodeIds.length > 1;
</script>
<NodeToolbar nodeId={selectedNodeIds} {isVisible}>
<button>Selection action</button>
</NodeToolbar>
@@ -1,13 +1,13 @@
import { useCallback, CSSProperties } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { getNodesBounds, Transform, Rect, Position, internalsSymbol } from '@xyflow/system';
import { getNodesBounds, Rect, Position, internalsSymbol, getNodeToolbarTransform } from '@xyflow/system';
import { Node, ReactFlowState } from '../../types';
import { useStore } from '../../hooks/useStore';
import { useNodeId } from '../../contexts/NodeIdContext';
import NodeToolbarPortal from './NodeToolbarPortal';
import { Align, NodeToolbarProps } from './types';
import { NodeToolbarProps } from './types';
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
@@ -22,53 +22,15 @@ const nodesEqualityFn = (a: Node[], b: Node[]) => {
};
const storeSelector = (state: ReactFlowState) => ({
transform: state.transform,
viewport: {
x: state.transform[0],
y: state.transform[1],
zoom: state.transform[2],
},
nodeOrigin: state.nodeOrigin,
selectedNodesCount: state.nodes.filter((node) => node.selected).length,
});
function getTransform(nodeRect: Rect, transform: Transform, position: Position, offset: number, align: Align): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * transform[2] + transform[0],
nodeRect.y * transform[2] + transform[1] - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * transform[2] + transform[0] + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * transform[2] + transform[1] + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * transform[2] + transform[0] - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * transform[2] + transform[1],
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
function NodeToolbar({
nodeId,
children,
@@ -97,7 +59,9 @@ function NodeToolbar({
[nodeId, contextNodeId]
);
const nodes = useStore(nodesSelector, nodesEqualityFn);
const { transform, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
const { viewport, nodeOrigin, selectedNodesCount } = useStore(storeSelector, shallow);
// if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected
const isActive =
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
@@ -110,14 +74,19 @@ function NodeToolbar({
const wrapperStyle: CSSProperties = {
position: 'absolute',
transform: getTransform(nodeRect, transform, position, offset, align),
transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align),
zIndex,
...style,
};
return (
<NodeToolbarPortal>
<div style={wrapperStyle} className={cc(['react-flow__node-toolbar', className])} {...rest}>
<div
style={wrapperStyle}
className={cc(['react-flow__node-toolbar', className])}
{...rest}
data-id={nodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()}
>
{children}
</div>
</NodeToolbarPortal>
@@ -1,5 +1,5 @@
import type { HTMLAttributes } from 'react';
import type { Position } from '@xyflow/system';
import type { Position, Align } from '@xyflow/system';
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
nodeId?: string | string[];
@@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
offset?: number;
align?: Align;
};
export type Align = 'center' | 'start' | 'end';
@@ -1,11 +1,27 @@
export default function (node: Element, target = 'body') {
const targetEl = document.querySelector(target);
type PortalOptions = {
target?: string;
domNode: Element | null;
};
function tryToMount(node: Element, domNode: Element | null, target: string | undefined) {
if (!domNode) {
return;
}
const targetEl = target ? domNode.querySelector(target) : domNode;
if (targetEl) {
targetEl.appendChild(node);
}
}
export default function (node: Element, { target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
return {
async update({ target, domNode }: PortalOptions) {
tryToMount(node, domNode, target);
},
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
@@ -1,9 +1,12 @@
<script lang="ts">
import portal from '$lib/actions/portal';
import { useStore } from '$lib/store';
type $$Props = {};
const { domNode } = useStore();
</script>
<div use:portal={'.svelte-flow__edgelabel-renderer'}>
<div use:portal={{ target: '.svelte-flow__edgelabel-renderer', domNode: $domNode }}>
<slot />
</div>
+1
View File
@@ -14,6 +14,7 @@ export * from '$lib/components/Handle';
export * from '$lib/plugins/Controls';
export * from '$lib/plugins/Background';
export * from '$lib/plugins/Minimap';
export * from '$lib/plugins/NodeToolbar';
// store
export { useStore } from '$lib/store';
@@ -0,0 +1,94 @@
<script lang="ts">
import { getContext } from 'svelte';
import {
getNodesBounds,
Position,
type Rect,
internalsSymbol,
getNodeToolbarTransform
} from '@xyflow/system';
import portal from '$lib/actions/portal';
import type { Node } from '$lib/types';
import { useStore } from '$lib/store';
import type { NodeToolbarProps } from './types';
type $$Props = NodeToolbarProps;
export let nodeId: $$Props['nodeId'] = undefined;
export let position: $$Props['position'] = undefined;
export let align: $$Props['align'] = undefined;
export let offset: $$Props['offset'] = undefined;
export let isVisible: $$Props['isVisible'] = undefined;
const { domNode, viewport, nodeLookup, nodes, nodeOrigin } = useStore();
const contextNodeId = getContext<string>('svelteflow__node_id');
let transform: string;
let toolbarNodes: Node[] = [];
let _offset = offset !== undefined ? offset : 10;
let _position = position !== undefined ? position : Position.Top;
let _align = align !== undefined ? align : 'center';
$: {
// $nodes only needed to trigger updates, $nodeLookup is just a helper that does not trigger any updates
if ($nodes) {
const nodeIds = Array.isArray(nodeId) ? nodeId : [nodeId || contextNodeId];
toolbarNodes = nodeIds.reduce<Node[]>((res, nodeId) => {
const node = $nodeLookup.get(nodeId);
if (node) {
res.push(node);
}
return res;
}, []);
}
}
$: {
let nodeRect: Rect | undefined = undefined;
if (toolbarNodes.length === 1) {
nodeRect = {
...toolbarNodes[0].position,
width: toolbarNodes[0].width ?? 0,
height: toolbarNodes[0].height ?? 0
};
} else if (toolbarNodes.length > 1) {
nodeRect = getNodesBounds(toolbarNodes, $nodeOrigin);
}
if (nodeRect) {
transform = getNodeToolbarTransform(nodeRect, $viewport, _position, _offset, _align);
}
}
$: zIndex =
toolbarNodes.length === 0
? 1
: Math.max(...toolbarNodes.map((node) => (node[internalsSymbol]?.z || 5) + 1));
//FIXME: Possible performance bottleneck
$: selectedNodesCount = $nodes.filter((node) => node.selected).length;
// if isVisible is not set, we show the toolbar only if its node is selected and no other node is selected
$: isActive =
typeof isVisible === 'boolean'
? isVisible
: toolbarNodes.length === 1 && toolbarNodes[0].selected && selectedNodesCount === 1;
</script>
{#if $domNode && isActive && toolbarNodes}
<div
data-id={toolbarNodes.reduce((acc, node) => `${acc}${node.id} `, '').trim()}
class="svelte-flow__node-toolbar"
use:portal={{ domNode: $domNode }}
style:position="absolute"
style:transform
style:z-index={zIndex}
>
<slot />
</div>
{/if}
@@ -0,0 +1,2 @@
export { default as NodeToolbar } from './NodeToolbar.svelte';
export * from './types';
@@ -0,0 +1,9 @@
import type { Position, Align } from '@xyflow/system';
export type NodeToolbarProps = {
nodeId?: string | string[];
position?: Position;
align?: Align;
offset?: number;
isVisible?: boolean;
};
+2
View File
@@ -97,3 +97,5 @@ export type OnNodeDrag = (event: MouseEvent, node: NodeBase, nodes: NodeBase[])
export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void;
export type NodeHandle = Optional<HandleElement, 'width' | 'height'>;
export type Align = 'center' | 'start' | 'end';
+1
View File
@@ -3,4 +3,5 @@ export * from './edges';
export * from './graph';
export * from './general';
export * from './marker';
export * from './node-toolbar';
export * from './store';
+49
View File
@@ -0,0 +1,49 @@
import { Position, type Rect, type Viewport, type Align } from '../';
export function getNodeToolbarTransform(
nodeRect: Rect,
viewport: Viewport,
position: Position,
offset: number,
align: Align
): string {
let alignmentOffset = 0.5;
if (align === 'start') {
alignmentOffset = 0;
} else if (align === 'end') {
alignmentOffset = 1;
}
// position === Position.Top
// we set the x any y position of the toolbar based on the nodes position
let pos = [
(nodeRect.x + nodeRect.width * alignmentOffset) * viewport.zoom + viewport.x,
nodeRect.y * viewport.zoom + viewport.y - offset,
];
// and than shift it based on the alignment. The shift values are in %.
let shift = [-100 * alignmentOffset, -100];
switch (position) {
case Position.Right:
pos = [
(nodeRect.x + nodeRect.width) * viewport.zoom + viewport.x + offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y,
];
shift = [0, -100 * alignmentOffset];
break;
case Position.Bottom:
pos[1] = (nodeRect.y + nodeRect.height) * viewport.zoom + viewport.y + offset;
shift[1] = 0;
break;
case Position.Left:
pos = [
nodeRect.x * viewport.zoom + viewport.x - offset,
(nodeRect.y + nodeRect.height * alignmentOffset) * viewport.zoom + viewport.y,
];
shift = [-100, -100 * alignmentOffset];
break;
}
return `translate(${pos[0]}px, ${pos[1]}px) translate(${shift[0]}%, ${shift[1]}%)`;
}
+93
View File
@@ -0,0 +1,93 @@
import { test, expect, Locator } from '@playwright/test';
import { FRAMEWORK } from './constants';
type Position = 'top' | 'right' | 'bottom' | 'left';
const positions: Position[] = ['top', 'right', 'bottom', 'left'];
type Alignment = 'start' | 'center' | 'end';
const alignments: Alignment[] = ['start', 'center', 'end'];
type Permutation = {
id: string;
position: Position;
align: Alignment;
};
const permutations: Permutation[] = [];
positions.forEach((position) => {
alignments.forEach((align) => {
permutations.push({
id: `node-${align}-${position}`,
position,
align,
});
});
});
test.describe('Node Toolbar', async () => {
test.beforeEach(async ({ page }) => {
// Go to the starting url before each test.
await page.goto('/tests/generic/node-toolbar/general');
// Wait till the edges are rendered
await page.waitForSelector('[data-id="first-edge"]', { timeout: 5000 });
});
test('all toolbars are positioned correctly', async ({ page }) => {
permutations.forEach(async (permutation) => {
const toolbar = page
.locator(`[data-id="${permutation.id}"]`)
.and(page.locator(`.${FRAMEWORK}-flow__node-toolbar`));
const node = page.locator(`[data-id="${permutation.id}"]`).and(page.locator(`.${FRAMEWORK}-flow__node`));
await expect(toolbar).toBeAttached();
await expect(node).toBeAttached();
const toolbarBox = await toolbar.boundingBox();
const nodeBox = await node.boundingBox();
switch (permutation.position) {
case 'top':
expect(toolbarBox!.y).toBeLessThan(nodeBox!.y);
break;
case 'right':
expect(toolbarBox!.x).toBeGreaterThan(nodeBox!.x);
break;
case 'bottom':
expect(toolbarBox!.y).toBeGreaterThan(nodeBox!.y);
break;
case 'left':
expect(toolbarBox!.x).toBeLessThan(nodeBox!.x);
break;
}
const dimension = permutation.position === 'top' || permutation.position === 'bottom' ? 'x' : 'y';
const extent = permutation.position === 'top' || permutation.position === 'bottom' ? 'width' : 'height';
switch (permutation.align) {
case 'start':
expect(Math.floor(toolbarBox![dimension])).toBe(Math.floor(nodeBox![dimension]));
break;
case 'center':
expect(Math.floor(toolbarBox![dimension] + toolbarBox![extent] * 0.5)).toBe(
Math.floor(nodeBox![dimension] + nodeBox![extent] * 0.5)
);
break;
case 'end':
expect(Math.floor(toolbarBox![dimension] + toolbarBox![extent])).toBe(
Math.floor(nodeBox![dimension] + nodeBox![extent])
);
break;
}
});
});
test('toolbar default behaviour', async ({ page }) => {
const node = page.locator('[data-id="default-node"]').and(page.locator(`.${FRAMEWORK}-flow__node`));
const toolbar = page.locator('[data-id="default-node"]').and(page.locator(`.${FRAMEWORK}-flow__node-toolbar`));
await expect(node).toBeAttached();
await expect(toolbar).not.toBeAttached();
await node.click();
await expect(toolbar).toBeAttached();
});
});
+1 -17
View File
@@ -1,23 +1,7 @@
import { test, expect } from '@playwright/test';
import { FRAMEWORK } from './constants';
const MATCH_ALL_NUMBERS = /[\d\.]+/g;
// Type "Locator" not exported...
async function getTransform(element) {
const transformString = await element.evaluate((el) => {
return el.style.transform;
});
// Parses all numbers in f.ex "translate(590px, 324px) scale(2)""
const transforms = transformString.match(MATCH_ALL_NUMBERS);
return {
translateX: parseFloat(transforms![0]),
translateY: parseFloat(transforms![1]),
scale: parseFloat(transforms![2]),
};
}
import { getTransform } from './utils';
test.describe('PANE DEFAULT', () => {
test.beforeEach(async ({ page }) => {
+16
View File
@@ -0,0 +1,16 @@
const MATCH_ALL_NUMBERS = /[\d\.]+/g;
// Type "Locator" not exported...
export async function getTransform(element) {
const transformString = await element.evaluate((el) => {
return el.style.transform;
});
// Parses all numbers in f.ex "translate(590px, 324px) scale(2)""
const transforms = transformString.match(MATCH_ALL_NUMBERS);
return {
translateX: parseFloat(transforms![0]),
translateY: parseFloat(transforms![1]),
scale: parseFloat(transforms![2]),
};
}