feat(svelte): selection box

This commit is contained in:
moklick
2023-02-20 19:01:43 +01:00
parent fe84a5d21a
commit 1f44bc4da5
17 changed files with 501 additions and 154 deletions

View File

@@ -16,8 +16,9 @@
".": "./index.js"
},
"dependencies": {
"@reactflow/system": "workspace:11.5.5",
"@reactflow/utils": "workspace:^11.5.5",
"@reactflow/system": "workspace:*",
"@reactflow/utils": "workspace:*",
"@svelte-put/shortcut": "^2.0.0",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",

View File

@@ -0,0 +1,119 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Writable } from 'svelte/store';
import { select } from 'd3-selection';
import { zoom as d3Zoom, zoomIdentity } from 'd3-zoom';
import type { D3ZoomEvent } from 'd3-zoom';
import type { D3SelectionInstance, D3ZoomInstance, Transform } from '@reactflow/system';
const isWrappedWithClass = (event: any, className: string | undefined) =>
event.target.closest(`.${className}`);
function filter(event: any, params: ZoomParams): boolean {
const zoomScroll = true;
const pinchZoom = true;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'react-flow__node') || isWrappedWithClass(event, 'react-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
// if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
// return false;
// }
// // during a selection we prevent all other interactions
if (params.selecting) {
return false;
}
// // if zoom on double click is disabled, we prevent the double click event
// if (!zoomOnDoubleClick && event.type === 'dblclick') {
// return false;
// }
// // if the target element is inside an element with the nowheel class, we prevent zooming
// if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
// return false;
// }
// // if the target element is inside an element with the nopan class, we prevent panning
// if (isWrappedWithClass(event, noPanClassName) && event.type !== 'wheel') {
// return false;
// }
// if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
// return false;
// }
// // when there is no scroll handling enabled, we prevent all wheel events
// if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
// return false;
// }
// // if the pane is not movable, we prevent dragging it with mousestart or touchstart
// if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
// return false;
// }
// // if the pane is only movable using allowed clicks
// if (
// Array.isArray(panOnDrag) &&
// !panOnDrag.includes(event.button) &&
// (event.type === 'mousedown' || event.type === 'touchstart')
// ) {
// return false;
// }
// // We only allow right clicks if pan on drag is set to right click
// const buttonAllowed =
// (Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) ||
// !event.button ||
// event.button <= 1;
// default filter for d3-zoom
return ((!event.ctrlKey || event.type === 'wheel') && !event.button) || event.button <= 1;
}
type ZoomParams = {
transformStore: Writable<Transform>;
selecting: boolean;
d3Store: Writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>;
};
export default function zoom(domNode: Element, params: ZoomParams) {
const { transformStore, d3Store } = params;
const d3ZoomInstance = d3Zoom();
const selection = select(domNode).call(d3ZoomInstance);
const d3ZoomHandler = selection.on('wheel.zoom');
d3ZoomInstance.transform(selection, zoomIdentity);
selection.on('wheel.zoom', function (event: any, d: any) {
if (isWrappedWithClass(event, 'nowheel')) {
return null;
}
event.preventDefault();
d3ZoomHandler!.call(this, event, d);
});
d3Store.set({
zoom: d3ZoomInstance,
selection
});
d3ZoomInstance.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
transformStore.set([event.transform.x, event.transform.y, event.transform.k]);
});
d3ZoomInstance.filter((event: any) => filter(event, params));
return {
update(params: ZoomParams) {
d3ZoomInstance.filter((event: any) => filter(event, params));
}
};
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { shortcut } from '@svelte-put/shortcut';
import { useStore } from '$lib/store';
const { selectionKeyPressedStore } = useStore();
function onSelectionKeyDown() {
selectionKeyPressedStore.set(true);
}
function onSelectionKeyUp() {
selectionKeyPressedStore.set(false);
}
</script>
<svelte:window
use:shortcut={{
trigger: [{ key: 'Shift', callback: onSelectionKeyDown }],
type: 'keydown'
}}
use:shortcut={{
trigger: [{ key: 'Shift', callback: onSelectionKeyUp }],
type: 'keyup'
}}
/>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { getRectOfNodes } from '@reactflow/utils';
import { useStore } from '$lib/store';
import Selection from '$lib/components/Selection/index.svelte';
const { selectionRectModeStore, nodesStore, nodeOriginStore } = useStore();
$: selectedNodes = $nodesStore.filter(n => n.selected);
$: rect = getRectOfNodes(selectedNodes, $nodeOriginStore);
// @todo: use zoom action for selection
</script>
{#if selectedNodes}
<Selection
isVisible={$selectionRectModeStore === 'nodes'}
width={rect.width}
height={rect.height}
x={rect.x}
y={rect.y}
/>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
export let x: number | null = 0;
export let y: number | null = 0;
export let width: number | null = 0;
export let height: number | null = 0;
export let isVisible: boolean = true;
</script>
{#if isVisible}
<div
class="react-flow__selection"
style={`width: ${width}px; height: ${height}px;transform: translate(${x}px, ${y}px)`}
/>
{/if}
<style>
.react-flow__selection {
position: absolute;
top: 0;
left: 0;
background: rgba(0, 89, 220, 0.08);
border: 1px dotted rgba(0, 89, 220, 0.8);
z-index: 6;
}
.react-flow__selection:focus,
.react-flow__selection:focus-visible {
outline: none;
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { useStore } from '$lib/store';
import Selection from '$lib/components/Selection/index.svelte';
const { selectionRectStore, selectionRectModeStore } = useStore();
</script>
<Selection
isVisible={!!($selectionRectStore && $selectionRectModeStore === 'user')}
width={$selectionRectStore?.width}
height={$selectionRectStore?.height}
x={$selectionRectStore?.x}
y={$selectionRectStore?.y}
/>

View File

@@ -2,7 +2,7 @@
import { onMount, setContext } from 'svelte';
import type { XYPosition } from '@reactflow/system';
import drag from '$lib/hooks/drag'
import drag from '$lib/actions/drag'
import { useStore } from '$lib/store';
import DefaultNode from './DefaultNode.svelte';
@@ -29,7 +29,6 @@
resizeObserver?.unobserve(nodeRef);
}
});
</script>
<div
@@ -58,6 +57,8 @@
background-color: white;
position: absolute;
cursor: grab;
pointer-events: all;
user-select: none;
}
.dragging {

View File

@@ -31,5 +31,7 @@
.react-flow__nodes {
width: 100%;
height: 100%;
pointer-events: none;
transform-origin: 0 0;
}
</style>

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { useStore } from '$lib/store';
import { SelectionMode, type Node, type Edge } from '@reactflow/system';
import { getConnectedEdges, getEventPosition, getNodesInside } from '@reactflow/utils';
const { nodesStore, edgesStore, transformStore, nodeOriginStore, draggingStore, selectionRectStore, selectionRectModeStore, selectionKeyPressedStore, resetSelectedElements } = useStore();
const wrapHandler = (
handler: (evt: MouseEvent) => void,
container: HTMLDivElement
): (evt: MouseEvent) => void => {
return (event: MouseEvent) => {
if (event.target !== container) {
return;
}
handler?.(event);
};
};
// @todo take from props
const elementsSelectable = true;
const selectionMode = SelectionMode.Partial
let container: HTMLDivElement;
let containerBounds: DOMRect | null = null
let selectedNodes: Node[] = [];
$: isSelecting = $selectionKeyPressedStore;
$: hasActiveSelection = elementsSelectable && (isSelecting || $selectionRectModeStore === 'user');
function onClick (event: MouseEvent) {
// onPaneClick?.(event);
resetSelectedElements();
selectionRectModeStore.set(null)
}
function onMouseDown(event: MouseEvent) {
containerBounds = container.getBoundingClientRect();
if (
!elementsSelectable ||
!isSelecting ||
event.button !== 0 ||
event.target !== container ||
!containerBounds
) {
return;
}
const { x, y } = getEventPosition(event, containerBounds);
resetSelectedElements();
selectionRectStore.set({
width: 0,
height: 0,
startX: x,
startY: y,
x,
y
});
// onSelectionStart?.(event);
};
function toggleSelected<Item extends Node | Edge>(ids: string[]) {
return (item: Item) => {
const isSelected = ids.includes(item.id);
if (item.selected !== isSelected) {
return {
...item,
selected: isSelected,
};
}
return item;
}
}
function onMouseMove(event: MouseEvent) {
if (!isSelecting || !containerBounds || !$selectionRectStore) {
return;
}
const mousePos = getEventPosition(event, containerBounds);
const startX = $selectionRectStore.startX ?? 0;
const startY = $selectionRectStore.startY ?? 0;
const nextUserSelectRect = {
...$selectionRectStore,
x: mousePos.x < startX ? mousePos.x : startX,
y: mousePos.y < startY ? mousePos.y : startY,
width: Math.abs(mousePos.x - startX),
height: Math.abs(mousePos.y - startY),
};
selectedNodes = getNodesInside(
new Map($nodesStore.map(node => [node.id, node])),
nextUserSelectRect,
$transformStore,
selectionMode === SelectionMode.Partial,
true,
$nodeOriginStore
);
const selectedEdgeIds = getConnectedEdges(selectedNodes, $edgesStore).map((e) => e.id);
const selectedNodeIds = selectedNodes.map((n) => n.id);
nodesStore.update(nodes => nodes.map(toggleSelected(selectedNodeIds)));
edgesStore.update(edges => edges.map(toggleSelected(selectedEdgeIds)));
selectionRectModeStore.set('user');
selectionRectStore.set(nextUserSelectRect);
}
function onMouseUp(event: MouseEvent) {
if (event.button !== 0) {
return;
}
// We only want to trigger click functions when in selection mode if
// the user did not move the mouse.
// if (!userSelectionActive && userSelectionRect && event.target === container.current) {
// onClick?.(event);
// }
selectionRectStore.set(null);
if (selectedNodes.length > 0) {
selectionRectModeStore.set('nodes');
}
// onSelectionEnd?.(event);
}
const onMouseLeave = (event: MouseEvent) => {
if ($selectionRectModeStore === 'user') {
selectionRectModeStore.set(selectedNodes.length > 0 ? 'nodes' : null);
// onSelectionEnd?.(event);
}
selectionRectStore.set(null);
};
</script>
<div
bind:this={container}
class="react-flow__pane"
class:dragging={$draggingStore}
class:selection={isSelecting}
on:click={hasActiveSelection ? undefined : wrapHandler(onClick, container)}
on:mousedown={hasActiveSelection ? onMouseDown : undefined}
on:mousemove={hasActiveSelection? onMouseMove : undefined}
on:mouseup={hasActiveSelection ? onMouseUp : undefined}
on:mouseleave={hasActiveSelection ? onMouseLeave : undefined}
>
<slot />
</div>
<style>
.react-flow__pane {
cursor: grab;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.selection {
cursor: pointer;
}
.dragging {
cursor: grabbing;
}
</style>

View File

@@ -4,18 +4,23 @@
import type { Node, Edge } from '@reactflow/system';
import { key, createStore } from '$lib/store';
import Zoom from '$lib/container/Zoom/index.svelte';
import Pane from '$lib/container/Pane/index.svelte';
import Viewport from '$lib/container/Viewport.svelte';
import NodeRenderer from '$lib/container/NodeRenderer.svelte';
import EdgeRenderer from '$lib/container/EdgeRenderer/index.svelte';
import UserSelection from '$lib/components/UserSelection/index.svelte';
import NodeSelection from '$lib/components/NodeSelection/index.svelte';
import KeyHandler from '$lib/components/KeyHandler/index.svelte';
export let nodes: Node[];
export let edges: Edge[];
export let fitView: boolean;
export let className: string | null = null;
export let id: string = '1';
export let nodes: Node[] = [];
export let edges: Edge[] = [];
export let fitView: boolean = false;
let className: string = '';
export { className as class };
let props = { ...$$restProps };
let domNode: Element;
const store = createStore({
@@ -35,19 +40,24 @@
})
</script>
<div
{...props}
{...$$restProps}
class={cc(['react-flow', className])}
data-testid="rf__wrapper"
id={id}
bind:this={domNode}
>
<Viewport>
<NodeRenderer />
<EdgeRenderer />
</Viewport>
<KeyHandler />
<Zoom>
<Pane>
<Viewport>
<NodeSelection />
<NodeRenderer />
<EdgeRenderer />
</Viewport>
<UserSelection />
</Pane>
</Zoom>
<slot />
</div>
@@ -59,5 +69,4 @@
position: relative;
z-index: 0;
}
</style>

View File

@@ -1,35 +1,25 @@
<script lang="ts">
import zoom from '$lib/hooks/zoom'
import { useStore } from '$lib/store';
const { transformStore, d3Store } = useStore();
const { transformStore} = useStore();
</script>
<div class="react-flow__pane" use:zoom={{ transformStore, d3Store }}>
<div
class="react-flow__viewport"
style="transform: translate({$transformStore[0]}px, {$transformStore[1]}px) scale({$transformStore[2]})"
>
<slot></slot>
</div>
<div
class="react-flow__viewport"
style="transform: translate({$transformStore[0]}px, {$transformStore[1]}px) scale({$transformStore[2]})"
>
<slot />
</div>
<style>
.react-flow__pane {
width: 100%;
height: 100%;
position: absolute;
top:0;
left:0;
cursor: grab;
}
.react-flow__viewport {
width: 100%;
height: 100%;
position: absolute;
transform-origin: 0 0;
top:0;
left:0;
top: 0;
left: 0;
z-index: 2;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { useStore } from '$lib/store';
import zoom from '$lib/actions/zoom';
const { transformStore, d3Store, selectionKeyPressedStore, selectionRectModeStore } = useStore();
$: selecting = $selectionKeyPressedStore || $selectionRectModeStore === 'user';
</script>
<div class="react-flow__zoom" use:zoom={{ transformStore, d3Store, selecting }}>
<slot />
</div>
<style>
.react-flow__zoom {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 4;
}
</style>

View File

@@ -1,114 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Writable } from 'svelte/store';
import { select } from 'd3-selection';
import { zoom as d3Zoom, zoomIdentity } from 'd3-zoom';
import type { D3ZoomEvent } from 'd3-zoom';
import type { D3SelectionInstance, D3ZoomInstance, Transform } from '@reactflow/system';
const isWrappedWithClass = (event: any, className: string | undefined) =>
event.target.closest(`.${className}`);
export default function zoom(
domNode: Element,
{
transformStore,
d3Store
}: {
transformStore: Writable<Transform>;
d3Store: Writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>;
}
) {
const d3ZoomInstance = d3Zoom();
const selection = select(domNode).call(d3ZoomInstance);
const d3ZoomHandler = selection.on('wheel.zoom');
d3ZoomInstance.transform(selection, zoomIdentity);
selection.on('wheel.zoom', function (event: any, d: any) {
if (isWrappedWithClass(event, 'nowheel')) {
return null;
}
event.preventDefault();
d3ZoomHandler!.call(this, event, d);
});
d3Store.set({
zoom: d3ZoomInstance,
selection
});
d3ZoomInstance.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
transformStore.set([event.transform.x, event.transform.y, event.transform.k]);
});
d3ZoomInstance.filter((event: any) => {
const zoomScroll = true;
const pinchZoom = true;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'react-flow__node') ||
isWrappedWithClass(event, 'react-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
// if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
// return false;
// }
// // during a selection we prevent all other interactions
// if (userSelectionActive) {
// return false;
// }
// // if zoom on double click is disabled, we prevent the double click event
// if (!zoomOnDoubleClick && event.type === 'dblclick') {
// return false;
// }
// // if the target element is inside an element with the nowheel class, we prevent zooming
// if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
// return false;
// }
// // if the target element is inside an element with the nopan class, we prevent panning
// if (isWrappedWithClass(event, noPanClassName) && event.type !== 'wheel') {
// return false;
// }
// if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
// return false;
// }
// // when there is no scroll handling enabled, we prevent all wheel events
// if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
// return false;
// }
// // if the pane is not movable, we prevent dragging it with mousestart or touchstart
// if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
// return false;
// }
// // if the pane is only movable using allowed clicks
// if (
// Array.isArray(panOnDrag) &&
// !panOnDrag.includes(event.button) &&
// (event.type === 'mousedown' || event.type === 'touchstart')
// ) {
// return false;
// }
// // We only allow right clicks if pan on drag is set to right click
// const buttonAllowed =
// (Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) ||
// !event.button ||
// event.button <= 1;
// default filter for d3-zoom
return ((!event.ctrlKey || event.type === 'wheel') && !event.button) || event.button <= 1;
});
}

View File

@@ -11,7 +11,8 @@ import {
type NodeOrigin,
type D3ZoomInstance,
type D3SelectionInstance,
type ViewportHelperFunctionOptions
type ViewportHelperFunctionOptions,
type SelectionRect
} from '@reactflow/system';
import { fitView, getD3Transition, getDimensions } from '@reactflow/utils';
@@ -22,6 +23,7 @@ import {
getNodeData,
type EdgePosition
} from '$lib/container/EdgeRenderer/utils';
import { SelectionMode } from 'reactflow';
export const key = Symbol();
@@ -52,6 +54,11 @@ type SvelteFlowStore = {
edgesWithDataStore: Readable<EdgeWithData[]>;
idStore: Writable<string>;
nodeOriginStore: Writable<NodeOrigin>;
draggingStore: Writable<boolean>;
selectionRectStore: Writable<SelectionRect | null>;
selectionRectModeStore: Writable<string | null>;
selectionMode: Writable<SelectionMode>;
selectionKeyPressedStore: Writable<boolean>;
zoomIn: (options?: ViewportHelperFunctionOptions) => void;
zoomOut: (options?: ViewportHelperFunctionOptions) => void;
fitView: (options?: ViewportHelperFunctionOptions) => boolean;
@@ -61,6 +68,7 @@ type SvelteFlowStore = {
dragging?: boolean
) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
resetSelectedElements: () => void;
};
export function createStore({
@@ -81,6 +89,11 @@ export function createStore({
selection: null
});
const idStore = writable(id);
const draggingStore = writable(false);
const selectionRectStore = writable(null);
const selectionKeyPressedStore = writable(false);
const selectionRectModeStore = writable(null);
const selectionMode = writable(SelectionMode.Partial);
let fitViewOnInitDone = false;
@@ -235,6 +248,22 @@ export function createStore({
);
}
function resetSelectedItem<T extends Node | Edge>(item: T) {
if (item.selected) {
return {
...item,
selected: false
};
}
return item;
}
function resetSelectedElements() {
nodesStore.update((ns) => ns.map(resetSelectedItem));
edgesStore.update((es) => es.map(resetSelectedItem));
}
return {
nodesStore,
edgesStore,
@@ -245,11 +274,17 @@ export function createStore({
edgesWithDataStore,
idStore,
nodeOriginStore,
draggingStore,
selectionRectStore,
selectionKeyPressedStore,
selectionRectModeStore,
selectionMode,
updateNodePositions,
updateNodeDimensions,
zoomIn,
zoomOut,
fitView: _fitView
fitView: _fitView,
resetSelectedElements
};
}

8
pnpm-lock.yaml generated
View File

@@ -312,6 +312,7 @@ importers:
specifiers:
'@reactflow/system': workspace:11.5.5
'@reactflow/utils': workspace:^11.5.5
'@svelte-put/shortcut': ^2.0.0
'@sveltejs/adapter-auto': ^2.0.0
'@sveltejs/kit': ^1.5.6
'@sveltejs/package': ^1.0.2
@@ -336,6 +337,7 @@ importers:
dependencies:
'@reactflow/system': link:../system
'@reactflow/utils': link:../utils
'@svelte-put/shortcut': registry.npmjs.org/@svelte-put/shortcut/2.0.0
classcat: registry.npmjs.org/classcat/5.0.4
d3-drag: registry.npmjs.org/d3-drag/3.0.0
d3-selection: registry.npmjs.org/d3-selection/3.0.0
@@ -1964,6 +1966,12 @@ packages:
version: 2.0.0
dev: true
registry.npmjs.org/@svelte-put/shortcut/2.0.0:
resolution: {integrity: sha512-dDyxIFlRz+W6wxwQtl2jUen0rX8ofV2VBWaJdz0QehLb0IMDzP3aWM9W4k8cAZQIGmTT0adr1+Gt7HPPUugcpQ==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-2.0.0.tgz}
name: '@svelte-put/shortcut'
version: 2.0.0
dev: false
registry.npmjs.org/@sveltejs/adapter-auto/2.0.0_@sveltejs+kit@1.5.6:
resolution: {integrity: sha512-b+gkHFZgD771kgV3aO4avHFd7y1zhmMYy9i6xOK7m/rwmwaRO8gnF5zBc0Rgca80B2PMU1bKNxyBTHA14OzUAQ==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.0.0.tgz}
id: registry.npmjs.org/@sveltejs/adapter-auto/2.0.0