diff --git a/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx new file mode 100644 index 00000000..30384445 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/components/ToolbarNode.tsx @@ -0,0 +1,19 @@ +import { memo, FC } from 'react'; +import { Handle, Position, NodeProps, NodeToolbar } from '@xyflow/react'; + +const CustomNode: FC = ({ id, data }) => { + return ( + <> + + + + + +
{data.label}
+ + + + ); +}; + +export default memo(CustomNode); diff --git a/examples/react/src/generic-tests/node-toolbar/general.ts b/examples/react/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..d7df3fa9 --- /dev/null +++ b/examples/react/src/generic-tests/node-toolbar/general.ts @@ -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; diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 0bd941b7..fde20817 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -12,6 +12,7 @@ 'figma', 'interaction', 'intersections', + 'node-toolbar', 'overview', 'stress', 'subflows', diff --git a/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte b/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte new file mode 100644 index 00000000..0a998217 --- /dev/null +++ b/examples/svelte/src/generic-tests/node-toolbar/components/ToolbarNode.svelte @@ -0,0 +1,30 @@ + + + + + + + +
+
{data.label}
+ + +
+ + diff --git a/examples/svelte/src/generic-tests/node-toolbar/general.ts b/examples/svelte/src/generic-tests/node-toolbar/general.ts new file mode 100644 index 00000000..6fce800f --- /dev/null +++ b/examples/svelte/src/generic-tests/node-toolbar/general.ts @@ -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; diff --git a/examples/svelte/src/routes/examples/node-toolbar/+page.svelte b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte new file mode 100644 index 00000000..1a4360e5 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/+page.svelte @@ -0,0 +1,63 @@ + + +
+ + + + +
diff --git a/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte new file mode 100644 index 00000000..af2dabaa --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/CustomNode.svelte @@ -0,0 +1,31 @@ + + + + + + + +
+
{data.label}
+ + +
+ + diff --git a/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte new file mode 100644 index 00000000..df62b856 --- /dev/null +++ b/examples/svelte/src/routes/examples/node-toolbar/SelectedNodesToolbar.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx index ad581bcf..c880edb7 100644 --- a/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx +++ b/packages/react/src/additional-components/NodeToolbar/NodeToolbar.tsx @@ -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 ( -
+
`${acc}${node.id} `, '').trim()} + > {children}
diff --git a/packages/react/src/additional-components/NodeToolbar/types.ts b/packages/react/src/additional-components/NodeToolbar/types.ts index 29478576..f08859dc 100644 --- a/packages/react/src/additional-components/NodeToolbar/types.ts +++ b/packages/react/src/additional-components/NodeToolbar/types.ts @@ -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 & { nodeId?: string | string[]; @@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes & { offset?: number; align?: Align; }; - -export type Align = 'center' | 'start' | 'end'; diff --git a/packages/svelte/src/lib/actions/portal/index.ts b/packages/svelte/src/lib/actions/portal/index.ts index 714e8b3e..fba6562c 100644 --- a/packages/svelte/src/lib/actions/portal/index.ts +++ b/packages/svelte/src/lib/actions/portal/index.ts @@ -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); diff --git a/packages/svelte/src/lib/components/EdgeLabelRenderer/EdgeLabelRenderer.svelte b/packages/svelte/src/lib/components/EdgeLabelRenderer/EdgeLabelRenderer.svelte index d681d388..f3cfeca9 100644 --- a/packages/svelte/src/lib/components/EdgeLabelRenderer/EdgeLabelRenderer.svelte +++ b/packages/svelte/src/lib/components/EdgeLabelRenderer/EdgeLabelRenderer.svelte @@ -1,9 +1,12 @@ -
+
diff --git a/packages/svelte/src/lib/index.ts b/packages/svelte/src/lib/index.ts index b8620d79..88bc483f 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -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'; diff --git a/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte b/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte new file mode 100644 index 00000000..b01bbb48 --- /dev/null +++ b/packages/svelte/src/lib/plugins/NodeToolbar/NodeToolbar.svelte @@ -0,0 +1,94 @@ + + +{#if $domNode && isActive && toolbarNodes} +
`${acc}${node.id} `, '').trim()} + class="svelte-flow__node-toolbar" + use:portal={{ domNode: $domNode }} + style:position="absolute" + style:transform + style:z-index={zIndex} + > + +
+{/if} diff --git a/packages/svelte/src/lib/plugins/NodeToolbar/index.ts b/packages/svelte/src/lib/plugins/NodeToolbar/index.ts new file mode 100644 index 00000000..7c38ab3d --- /dev/null +++ b/packages/svelte/src/lib/plugins/NodeToolbar/index.ts @@ -0,0 +1,2 @@ +export { default as NodeToolbar } from './NodeToolbar.svelte'; +export * from './types'; diff --git a/packages/svelte/src/lib/plugins/NodeToolbar/types.ts b/packages/svelte/src/lib/plugins/NodeToolbar/types.ts new file mode 100644 index 00000000..6c812de2 --- /dev/null +++ b/packages/svelte/src/lib/plugins/NodeToolbar/types.ts @@ -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; +}; diff --git a/packages/system/src/types/nodes.ts b/packages/system/src/types/nodes.ts index 22b4e6ab..cea41c65 100644 --- a/packages/system/src/types/nodes.ts +++ b/packages/system/src/types/nodes.ts @@ -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; + +export type Align = 'center' | 'start' | 'end'; diff --git a/packages/system/src/utils/index.ts b/packages/system/src/utils/index.ts index 30a7e961..380b7658 100644 --- a/packages/system/src/utils/index.ts +++ b/packages/system/src/utils/index.ts @@ -3,4 +3,5 @@ export * from './edges'; export * from './graph'; export * from './general'; export * from './marker'; +export * from './node-toolbar'; export * from './store'; diff --git a/packages/system/src/utils/node-toolbar.ts b/packages/system/src/utils/node-toolbar.ts new file mode 100644 index 00000000..1813513c --- /dev/null +++ b/packages/system/src/utils/node-toolbar.ts @@ -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]}%)`; +} diff --git a/tests/playwright/e2e/node-toolbar.spec.ts b/tests/playwright/e2e/node-toolbar.spec.ts new file mode 100644 index 00000000..daf76070 --- /dev/null +++ b/tests/playwright/e2e/node-toolbar.spec.ts @@ -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(); + }); +}); diff --git a/tests/playwright/e2e/pane.spec.ts b/tests/playwright/e2e/pane.spec.ts index 31777c97..15a79655 100644 --- a/tests/playwright/e2e/pane.spec.ts +++ b/tests/playwright/e2e/pane.spec.ts @@ -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 }) => { diff --git a/tests/playwright/e2e/utils.ts b/tests/playwright/e2e/utils.ts new file mode 100644 index 00000000..76643dd7 --- /dev/null +++ b/tests/playwright/e2e/utils.ts @@ -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]), + }; +}