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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './edges';
|
||||
export * from './graph';
|
||||
export * from './general';
|
||||
export * from './marker';
|
||||
export * from './node-toolbar';
|
||||
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]}%)`;
|
||||
}
|
||||
Reference in New Issue
Block a user