feat(utils): vanilla interactive minimap

This commit is contained in:
moklick
2023-06-05 17:32:40 +02:00
parent 1b4f4a034e
commit e0ccc0f980
22 changed files with 584 additions and 475 deletions

View File

@@ -3,4 +3,5 @@ export * from './types';
export * from './utils';
export * from './xydrag';
export * from './xyhandle';
export * from './xyminimap';
export * from './xypanzoom';

View File

@@ -0,0 +1,311 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type BaseNode,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: BaseNode[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
const alwaysValid = () => true;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
connectionMode,
connectionRadius,
handleId,
nodeId,
edgeUpdaterType,
isTarget,
domNode,
nodes,
lib,
autoPanOnConnect,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
updateConnection,
getTransform,
}: OnPointerDownParams
) {
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect();
if (!containerBounds || !handleType) {
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
nodeId,
handleId,
handleType,
});
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle: {
nodeId,
handleId,
type: handleType,
},
connectionEndHandle: null,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
if (!autoPanStarted) {
autoPan();
autoPanStarted = true;
}
const result = isValidHandle(event, {
handle: closestHandle,
connectionMode,
fromNodeId: nodeId,
fromHandleId: handleId,
fromType: isTarget ? 'target' : 'source',
isValidConnection,
doc,
lib,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
updateConnection({
connectionPosition:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
// @todo: remove the old class names "react-flow__handle-" in the next major version
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
}
function onPointerUp(event: MouseEvent | TouchEvent) {
if ((closestHandle || handleDomNode) && connection && isValid) {
onConnect?.(connection);
}
// it's important to get a fresh reference from the store here
// in order to get the latest state of onConnectEnd
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
doc.removeEventListener('touchmove', onPointerMove as EventListener);
doc.removeEventListener('touchend', onPointerUp as EventListener);
}
doc.addEventListener('mousemove', onPointerMove as EventListener);
doc.addEventListener('mouseup', onPointerUp as EventListener);
doc.addEventListener('touchmove', onPointerMove as EventListener);
doc.addEventListener('touchend', onPointerUp as EventListener);
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
function isValidHandle(
event: MouseEvent | TouchEvent,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
isValidConnection = alwaysValid,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
// because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode;
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
};
if (handleToCheck) {
const handleType = getHandleType(undefined, handleToCheck);
const handleNodeId = handleToCheck.getAttribute('data-nodeid');
const handleId = handleToCheck.getAttribute('data-handleid');
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
target: isTarget ? fromNodeId : handleNodeId,
targetHandle: isTarget ? fromHandleId : handleId,
};
result.connection = connection;
const isConnectable = connectable && connectableEnd;
// in strict mode we don't allow target to target or source to source connections
const isValid =
isConnectable &&
(connectionMode === ConnectionMode.Strict
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValidConnection(connection);
}
}
return result;
}
export const XYHandle: XYHandleInstance = {
onPointerDown,
isValid: isValidHandle,
};

View File

@@ -1,311 +1 @@
import { pointToRendererPoint, rendererPointToPoint, getHostForElement, calcAutoPan, getEventPosition } from '../utils';
import {
ConnectionMode,
type OnConnect,
type OnConnectStart,
type HandleType,
type Connection,
type PanBy,
type BaseNode,
type Transform,
type ConnectingHandle,
type OnConnectEnd,
type UpdateConnection,
type IsValidConnection,
type ConnectionHandle,
} from '../types';
import { getClosestHandle, getConnectionStatus, getHandleLookup, getHandleType, resetRecentHandle } from './utils';
export type OnPointerDownParams = {
autoPanOnConnect: boolean;
connectionMode: ConnectionMode;
connectionRadius: number;
domNode: HTMLDivElement | null;
handleId: string | null;
nodeId: string;
isTarget: boolean;
nodes: BaseNode[];
lib: string;
edgeUpdaterType?: HandleType;
updateConnection: UpdateConnection;
panBy: PanBy;
cancelConnection: () => void;
onConnectStart?: OnConnectStart;
onConnect?: OnConnect;
onConnectEnd?: OnConnectEnd;
isValidConnection?: IsValidConnection;
onEdgeUpdateEnd?: (evt: MouseEvent | TouchEvent) => void;
getTransform: () => Transform;
};
export type IsValidParams = {
handle: Pick<ConnectionHandle, 'nodeId' | 'id' | 'type'> | null;
connectionMode: ConnectionMode;
fromNodeId: string;
fromHandleId: string | null;
fromType: HandleType;
isValidConnection?: IsValidConnection;
doc: Document | ShadowRoot;
lib: string;
};
export type XYHandleInstance = {
onPointerDown: (event: MouseEvent | TouchEvent, params: OnPointerDownParams) => void;
isValid: (event: MouseEvent | TouchEvent, params: IsValidParams) => Result;
};
type Result = {
handleDomNode: Element | null;
isValid: boolean;
connection: Connection;
endHandle: ConnectingHandle | null;
};
const nullConnection: Connection = { source: null, target: null, sourceHandle: null, targetHandle: null };
const alwaysValid = () => true;
function onPointerDown(
event: MouseEvent | TouchEvent,
{
connectionMode,
connectionRadius,
handleId,
nodeId,
edgeUpdaterType,
isTarget,
domNode,
nodes,
lib,
autoPanOnConnect,
panBy,
cancelConnection,
onConnectStart,
onConnect,
onConnectEnd,
isValidConnection = alwaysValid,
onEdgeUpdateEnd,
updateConnection,
getTransform,
}: OnPointerDownParams
) {
// when react-flow is used inside a shadow root we can't use document
const doc = getHostForElement(event.target as HTMLElement);
let autoPanId = 0;
let closestHandle: ConnectionHandle | null;
const { x, y } = getEventPosition(event);
const clickedHandle = doc?.elementFromPoint(x, y);
const handleType = getHandleType(edgeUpdaterType, clickedHandle);
const containerBounds = domNode?.getBoundingClientRect();
if (!containerBounds || !handleType) {
return;
}
let prevActiveHandle: Element;
let connectionPosition = getEventPosition(event, containerBounds);
let autoPanStarted = false;
let connection: Connection | null = null;
let isValid = false;
let handleDomNode: Element | null = null;
const handleLookup = getHandleLookup({
nodes,
nodeId,
handleId,
handleType,
});
// when the user is moving the mouse close to the edge of the canvas while connecting we move the canvas
function autoPan(): void {
if (!autoPanOnConnect || !containerBounds) {
return;
}
const [x, y] = calcAutoPan(connectionPosition, containerBounds);
panBy({ x, y });
autoPanId = requestAnimationFrame(autoPan);
}
updateConnection({
connectionPosition,
connectionStatus: null,
// connectionNodeId etc will be removed in the next major in favor of connectionStartHandle
connectionStartHandle: {
nodeId,
handleId,
type: handleType,
},
connectionEndHandle: null,
});
onConnectStart?.(event, { nodeId, handleId, handleType });
function onPointerMove(event: MouseEvent | TouchEvent) {
const transform = getTransform();
connectionPosition = getEventPosition(event, containerBounds);
closestHandle = getClosestHandle(
pointToRendererPoint(connectionPosition, transform, false, [1, 1]),
connectionRadius,
handleLookup
);
if (!autoPanStarted) {
autoPan();
autoPanStarted = true;
}
const result = isValidHandle(event, {
handle: closestHandle,
connectionMode,
fromNodeId: nodeId,
fromHandleId: handleId,
fromType: isTarget ? 'target' : 'source',
isValidConnection,
doc,
lib,
});
handleDomNode = result.handleDomNode;
connection = result.connection;
isValid = result.isValid;
updateConnection({
connectionPosition:
closestHandle && isValid
? rendererPointToPoint(
{
x: closestHandle.x,
y: closestHandle.y,
},
transform
)
: connectionPosition,
connectionStatus: getConnectionStatus(!!closestHandle, isValid),
connectionEndHandle: result.endHandle,
});
if (!closestHandle && !isValid && !handleDomNode) {
return resetRecentHandle(prevActiveHandle, lib);
}
if (connection.source !== connection.target && handleDomNode) {
resetRecentHandle(prevActiveHandle, lib);
prevActiveHandle = handleDomNode;
// @todo: remove the old class names "react-flow__handle-" in the next major version
handleDomNode.classList.add('connecting', `${lib}-flow__handle-connecting`);
handleDomNode.classList.toggle('valid', isValid);
handleDomNode.classList.toggle(`${lib}-flow__handle-valid`, isValid);
}
}
function onPointerUp(event: MouseEvent | TouchEvent) {
if ((closestHandle || handleDomNode) && connection && isValid) {
onConnect?.(connection);
}
// it's important to get a fresh reference from the store here
// in order to get the latest state of onConnectEnd
onConnectEnd?.(event);
if (edgeUpdaterType) {
onEdgeUpdateEnd?.(event);
}
resetRecentHandle(prevActiveHandle, lib);
cancelConnection();
cancelAnimationFrame(autoPanId);
autoPanStarted = false;
isValid = false;
connection = null;
handleDomNode = null;
doc.removeEventListener('mousemove', onPointerMove as EventListener);
doc.removeEventListener('mouseup', onPointerUp as EventListener);
doc.removeEventListener('touchmove', onPointerMove as EventListener);
doc.removeEventListener('touchend', onPointerUp as EventListener);
}
doc.addEventListener('mousemove', onPointerMove as EventListener);
doc.addEventListener('mouseup', onPointerUp as EventListener);
doc.addEventListener('touchmove', onPointerMove as EventListener);
doc.addEventListener('touchend', onPointerUp as EventListener);
}
// checks if and returns connection in fom of an object { source: 123, target: 312 }
function isValidHandle(
event: MouseEvent | TouchEvent,
{
handle,
connectionMode,
fromNodeId,
fromHandleId,
fromType,
doc,
lib,
isValidConnection = alwaysValid,
}: IsValidParams
) {
const isTarget = fromType === 'target';
const handleDomNode = doc.querySelector(
`.${lib}-flow__handle[data-id="${handle?.nodeId}-${handle?.id}-${handle?.type}"]`
);
const { x, y } = getEventPosition(event);
const handleBelow = doc.elementFromPoint(x, y);
// we always want to prioritize the handle below the mouse cursor over the closest distance handle,
// because it could be that the center of another handle is closer to the mouse pointer than the handle below the cursor
const handleToCheck = handleBelow?.classList.contains(`${lib}-flow__handle`) ? handleBelow : handleDomNode;
const result: Result = {
handleDomNode: handleToCheck,
isValid: false,
connection: nullConnection,
endHandle: null,
};
if (handleToCheck) {
const handleType = getHandleType(undefined, handleToCheck);
const handleNodeId = handleToCheck.getAttribute('data-nodeid');
const handleId = handleToCheck.getAttribute('data-handleid');
const connectable = handleToCheck.classList.contains('connectable');
const connectableEnd = handleToCheck.classList.contains('connectableend');
const connection: Connection = {
source: isTarget ? handleNodeId : fromNodeId,
sourceHandle: isTarget ? handleId : fromHandleId,
target: isTarget ? fromNodeId : handleNodeId,
targetHandle: isTarget ? fromHandleId : handleId,
};
result.connection = connection;
const isConnectable = connectable && connectableEnd;
// in strict mode we don't allow target to target or source to source connections
const isValid =
isConnectable &&
(connectionMode === ConnectionMode.Strict
? (isTarget && handleType === 'source') || (!isTarget && handleType === 'target')
: handleNodeId !== fromNodeId || handleId !== fromHandleId);
if (isValid) {
result.endHandle = {
nodeId: handleNodeId as string,
handleId,
type: handleType as HandleType,
};
result.isValid = isValidConnection(connection);
}
}
return result;
}
export const XYHandle: XYHandleInstance = {
onPointerDown,
isValid: isValidHandle,
};
export * from './XYHandle';

View File

@@ -0,0 +1,104 @@
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import type { CoordinateExtent, PanZoomInstance, Transform } from '../types';
export type XYMinimapInstance = {
update: (params: XYMinimapUpdate) => void;
destroy: () => void;
pointer: typeof pointer;
};
export type XYMinimapParams = {
panZoom: PanZoomInstance;
domNode: Element;
getTransform: () => Transform;
getViewScale: () => number;
};
export type XYMinimapUpdate = {
translateExtent: CoordinateExtent;
width: number;
height: number;
inversePan?: boolean;
zoomStep?: number;
pannable?: boolean;
zoomable?: boolean;
};
export function XYMinimap({ domNode, panZoom, getTransform, getViewScale }: XYMinimapParams) {
const selection = select(domNode);
function update({
translateExtent,
width,
height,
zoomStep = 10,
pannable = true,
zoomable = true,
inversePan = false,
}: XYMinimapUpdate) {
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const transform = getTransform();
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
const pinchDelta =
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const transform = getTransform();
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
// @TODO: how to calculate the correct next position? Math.max(1, transform[2]) is a workaround.
const moveScale = getViewScale() * Math.max(1, transform[2]) * (inversePan ? -1 : 1);
const position = {
x: transform[0] - event.sourceEvent.movementX * moveScale,
y: transform[1] - event.sourceEvent.movementY * moveScale,
};
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
// @ts-ignore
.on('zoom', pannable ? panHandler : null)
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler, {});
}
function destroy() {
selection.on('zoom', null);
}
return {
update,
destroy,
pointer,
};
}