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:
@@ -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',
|
'figma',
|
||||||
'interaction',
|
'interaction',
|
||||||
'intersections',
|
'intersections',
|
||||||
|
'node-toolbar',
|
||||||
'overview',
|
'overview',
|
||||||
'stress',
|
'stress',
|
||||||
'subflows',
|
'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 { useCallback, CSSProperties } from 'react';
|
||||||
import cc from 'classcat';
|
import cc from 'classcat';
|
||||||
import { shallow } from 'zustand/shallow';
|
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 { Node, ReactFlowState } from '../../types';
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
import { useNodeId } from '../../contexts/NodeIdContext';
|
import { useNodeId } from '../../contexts/NodeIdContext';
|
||||||
import NodeToolbarPortal from './NodeToolbarPortal';
|
import NodeToolbarPortal from './NodeToolbarPortal';
|
||||||
import { Align, NodeToolbarProps } from './types';
|
import { NodeToolbarProps } from './types';
|
||||||
|
|
||||||
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
|
const nodeEqualityFn = (a: Node | undefined, b: Node | undefined) =>
|
||||||
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
|
a?.positionAbsolute?.x === b?.positionAbsolute?.x &&
|
||||||
@@ -22,53 +22,15 @@ const nodesEqualityFn = (a: Node[], b: Node[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const storeSelector = (state: ReactFlowState) => ({
|
const storeSelector = (state: ReactFlowState) => ({
|
||||||
transform: state.transform,
|
viewport: {
|
||||||
|
x: state.transform[0],
|
||||||
|
y: state.transform[1],
|
||||||
|
zoom: state.transform[2],
|
||||||
|
},
|
||||||
nodeOrigin: state.nodeOrigin,
|
nodeOrigin: state.nodeOrigin,
|
||||||
selectedNodesCount: state.nodes.filter((node) => node.selected).length,
|
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({
|
function NodeToolbar({
|
||||||
nodeId,
|
nodeId,
|
||||||
children,
|
children,
|
||||||
@@ -97,7 +59,9 @@ function NodeToolbar({
|
|||||||
[nodeId, contextNodeId]
|
[nodeId, contextNodeId]
|
||||||
);
|
);
|
||||||
const nodes = useStore(nodesSelector, nodesEqualityFn);
|
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 =
|
const isActive =
|
||||||
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
|
typeof isVisible === 'boolean' ? isVisible : nodes.length === 1 && nodes[0].selected && selectedNodesCount === 1;
|
||||||
|
|
||||||
@@ -110,14 +74,19 @@ function NodeToolbar({
|
|||||||
|
|
||||||
const wrapperStyle: CSSProperties = {
|
const wrapperStyle: CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
transform: getTransform(nodeRect, transform, position, offset, align),
|
transform: getNodeToolbarTransform(nodeRect, viewport, position, offset, align),
|
||||||
zIndex,
|
zIndex,
|
||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeToolbarPortal>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</NodeToolbarPortal>
|
</NodeToolbarPortal>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import type { Position } from '@xyflow/system';
|
import type { Position, Align } from '@xyflow/system';
|
||||||
|
|
||||||
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
|
export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
nodeId?: string | string[];
|
nodeId?: string | string[];
|
||||||
@@ -8,5 +8,3 @@ export type NodeToolbarProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
align?: Align;
|
align?: Align;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Align = 'center' | 'start' | 'end';
|
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
export default function (node: Element, target = 'body') {
|
type PortalOptions = {
|
||||||
const targetEl = document.querySelector(target);
|
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) {
|
if (targetEl) {
|
||||||
targetEl.appendChild(node);
|
targetEl.appendChild(node);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (node: Element, { target, domNode }: PortalOptions) {
|
||||||
|
tryToMount(node, domNode, target);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async update({ target, domNode }: PortalOptions) {
|
||||||
|
tryToMount(node, domNode, target);
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
if (node.parentNode) {
|
if (node.parentNode) {
|
||||||
node.parentNode.removeChild(node);
|
node.parentNode.removeChild(node);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import portal from '$lib/actions/portal';
|
import portal from '$lib/actions/portal';
|
||||||
|
import { useStore } from '$lib/store';
|
||||||
|
|
||||||
type $$Props = {};
|
type $$Props = {};
|
||||||
|
|
||||||
|
const { domNode } = useStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div use:portal={'.svelte-flow__edgelabel-renderer'}>
|
<div use:portal={{ target: '.svelte-flow__edgelabel-renderer', domNode: $domNode }}>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from '$lib/components/Handle';
|
|||||||
export * from '$lib/plugins/Controls';
|
export * from '$lib/plugins/Controls';
|
||||||
export * from '$lib/plugins/Background';
|
export * from '$lib/plugins/Background';
|
||||||
export * from '$lib/plugins/Minimap';
|
export * from '$lib/plugins/Minimap';
|
||||||
|
export * from '$lib/plugins/NodeToolbar';
|
||||||
|
|
||||||
// store
|
// store
|
||||||
export { useStore } from '$lib/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;
|
||||||
|
};
|
||||||
@@ -97,3 +97,5 @@ export type OnNodeDrag = (event: MouseEvent, node: NodeBase, nodes: NodeBase[])
|
|||||||
export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void;
|
export type OnSelectionDrag = (event: MouseEvent, nodes: NodeBase[]) => void;
|
||||||
|
|
||||||
export type NodeHandle = Optional<HandleElement, 'width' | 'height'>;
|
export type NodeHandle = Optional<HandleElement, 'width' | 'height'>;
|
||||||
|
|
||||||
|
export type Align = 'center' | 'start' | 'end';
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export * from './edges';
|
|||||||
export * from './graph';
|
export * from './graph';
|
||||||
export * from './general';
|
export * from './general';
|
||||||
export * from './marker';
|
export * from './marker';
|
||||||
|
export * from './node-toolbar';
|
||||||
export * from './store';
|
export * from './store';
|
||||||
|
|||||||
@@ -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]}%)`;
|
||||||
|
}
|
||||||
@@ -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,23 +1,7 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
import { FRAMEWORK } from './constants';
|
import { FRAMEWORK } from './constants';
|
||||||
|
import { getTransform } from './utils';
|
||||||
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]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('PANE DEFAULT', () => {
|
test.describe('PANE DEFAULT', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user