@loradb/lora-graph-canvas 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/LoraGraphCanvas.stories.d.ts +1 -0
- package/dist/engines/createEngineUnified.d.ts +19 -0
- package/dist/engines/rafAnim.d.ts +12 -1
- package/dist/engines/types.d.ts +25 -0
- package/dist/hooks/useGraphClipboard.d.ts +7 -1
- package/dist/hooks/useGraphData.d.ts +1 -1
- package/dist/hooks/useGraphDeleteGate.d.ts +38 -0
- package/dist/hooks/useGraphEngine.d.ts +2 -2
- package/dist/hooks/useGraphKeybindings.d.ts +7 -0
- package/dist/hooks/useGraphSelection.d.ts +11 -2
- package/dist/hooks/useImperativeGraphHandle.d.ts +13 -1
- package/dist/hooks/useMarqueeAndCursor.d.ts +5 -0
- package/dist/hooks/usePrefersReducedMotion.d.ts +10 -0
- package/dist/index.cjs +45 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5933 -5159
- package/dist/index.js.map +1 -1
- package/dist/internal/runGuard.d.ts +7 -0
- package/dist/style.css +1 -1
- package/dist/tools/MarqueeOverlay.d.ts +8 -2
- package/dist/types.d.ts +68 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -114,6 +114,37 @@ import { LoraGraphCanvas, darkTheme } from "@loradb/lora-graph-canvas";
|
|
|
114
114
|
|
|
115
115
|
Two presets are exported: `lightTheme` and `darkTheme`.
|
|
116
116
|
|
|
117
|
+
## Confirm-before-delete
|
|
118
|
+
|
|
119
|
+
Gate every node / link removal — keyboard, toolbar, context menu,
|
|
120
|
+
selection panel, cut, and the imperative `removeNode` / `removeLink`
|
|
121
|
+
handle methods — through an async guard:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
<LoraGraphCanvas
|
|
125
|
+
onBeforeNodeDelete={(nodes, { source }) =>
|
|
126
|
+
source === "imperative"
|
|
127
|
+
? true // trust your own code
|
|
128
|
+
: openMyConfirmDialog(nodes) // returns Promise<boolean>
|
|
129
|
+
}
|
|
130
|
+
onBeforeLinkDelete={(links) => openMyConfirmDialog([], links)}
|
|
131
|
+
onNodeDeleted={(nodes, { source }) => analytics.track("nodes.removed", {
|
|
132
|
+
n: nodes.length,
|
|
133
|
+
source,
|
|
134
|
+
})}
|
|
135
|
+
/>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The guard receives every item in the batch (one selection-wide call,
|
|
139
|
+
not per-item), the originating `source` (`"keyboard" | "toolbar" |
|
|
140
|
+
"contextMenu" | "selectionPanel" | "cut" | "imperative"`), and may
|
|
141
|
+
return either a boolean or a `Promise<boolean>`. A thrown error is
|
|
142
|
+
treated as a cancel — your host won't silently destroy data.
|
|
143
|
+
|
|
144
|
+
When no guard is wired, deletion happens immediately as before; the
|
|
145
|
+
imperative methods become `Promise<boolean>` but resolve on the same
|
|
146
|
+
tick.
|
|
147
|
+
|
|
117
148
|
## Performance knobs
|
|
118
149
|
|
|
119
150
|
For large graphs, cap `cooldownTicks` (default ∞) and increase
|
|
@@ -4,6 +4,25 @@ export interface UnifiedEngine<N extends NodeObject = NodeObject, L extends Link
|
|
|
4
4
|
/** Switch presentation mode in place. Animates the camera + the
|
|
5
5
|
* per-node z constraints; does not destroy the engine. */
|
|
6
6
|
setMode(mode: GraphMode, durationMs?: number): void;
|
|
7
|
+
/** First-load reveal that runs concurrently with the force
|
|
8
|
+
* simulation: every animation frame, recompute the bbox-fitted
|
|
9
|
+
* pose and ease the camera toward it via a critically-damped
|
|
10
|
+
* spring. The spring's time constant is tuned by node count
|
|
11
|
+
* (small graphs settle in ~200 ms; large graphs ease over ~900 ms
|
|
12
|
+
* so we don't over-react to early ticks where the bbox is still
|
|
13
|
+
* exploding outward). Cancels on user interaction, on
|
|
14
|
+
* `onEngineStop`, or after `maxDurationMs`. Padding is in CSS
|
|
15
|
+
* pixels and reserved on every side of the viewport.
|
|
16
|
+
*
|
|
17
|
+
* Use on initial mount only — calling this on a graph the user
|
|
18
|
+
* has already explored will yank their camera. */
|
|
19
|
+
introFollow(opts?: {
|
|
20
|
+
padding?: number;
|
|
21
|
+
maxDurationMs?: number;
|
|
22
|
+
/** Optional time-constant override (seconds). If omitted, derived
|
|
23
|
+
* from node count via a perf-tier-style log scale. */
|
|
24
|
+
tauSeconds?: number;
|
|
25
|
+
}): void;
|
|
7
26
|
}
|
|
8
27
|
export declare function createEngineUnified<N extends NodeObject, L extends LinkObject>(mount: HTMLElement, opts: CreateEngineOptions<N, L> & {
|
|
9
28
|
initialMode: GraphMode;
|
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
* the animation at the current frame (no further `step` invocations).
|
|
9
9
|
* Returns null when running in an environment without
|
|
10
10
|
* `requestAnimationFrame` (jsdom in unit tests). */
|
|
11
|
-
export declare function runAnim(durationMs: number, step: (t: number) => void, onDone?: () => void): () => void;
|
|
11
|
+
export declare function runAnim(durationMs: number, step: (t: number) => void, onDone?: () => void, ease?: (t: number) => number): () => void;
|
|
12
|
+
/** Open-ended RAF loop. Unlike `runAnim` there's no fixed duration —
|
|
13
|
+
* `step(dt)` receives the seconds elapsed since the previous frame
|
|
14
|
+
* (capped at 1/30 s so a tab-switch doesn't deliver a giant dt that
|
|
15
|
+
* blows past spring targets) and decides for itself when it has
|
|
16
|
+
* converged. Returning `true` from `step` stops the loop the same way
|
|
17
|
+
* the returned `cancel()` would. */
|
|
18
|
+
export declare function runFollow(step: (dtSeconds: number) => boolean | void): () => void;
|
|
12
19
|
export declare function easeOutQuad(t: number): number;
|
|
20
|
+
/** Smoother S-curve — slow start, fast middle, slow end. Reads as
|
|
21
|
+
* more "cinematic" than easeOutQuad for longer cross-mode camera
|
|
22
|
+
* tweens where the user is watching the whole motion. */
|
|
23
|
+
export declare function easeInOutCubic(t: number): number;
|
|
13
24
|
export declare function lerp(a: number, b: number, t: number): number;
|
package/dist/engines/types.d.ts
CHANGED
|
@@ -70,6 +70,31 @@ export interface GraphEngine<N extends NodeObject = NodeObject, L extends LinkOb
|
|
|
70
70
|
zoom?: number;
|
|
71
71
|
durationMs?: number;
|
|
72
72
|
}): void;
|
|
73
|
+
/** Translate the view by world-space delta. Moves camera AND
|
|
74
|
+
* lookAt by the same vector so the orbit / view direction is
|
|
75
|
+
* preserved (a true "pan" rather than an orbit step). In 2D the
|
|
76
|
+
* z component is ignored — the top-down camera is locked to a
|
|
77
|
+
* constant height. */
|
|
78
|
+
panBy(delta: {
|
|
79
|
+
x?: number;
|
|
80
|
+
y?: number;
|
|
81
|
+
z?: number;
|
|
82
|
+
}, durationMs?: number): void;
|
|
83
|
+
/** Jump the view to a world coordinate, preserving the current
|
|
84
|
+
* viewing direction. Differs from `focusOn` in that it accepts a
|
|
85
|
+
* raw coordinate rather than a node-style target and doesn't
|
|
86
|
+
* re-tighten the zoom. Useful for "go to coordinates" UI. */
|
|
87
|
+
goTo(target: {
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
z?: number;
|
|
91
|
+
}, opts?: {
|
|
92
|
+
durationMs?: number;
|
|
93
|
+
}): void;
|
|
94
|
+
/** Fit the camera to a subset of nodes. Same camera math as `fit()`
|
|
95
|
+
* but the bbox is computed over `nodeIds` instead of the whole
|
|
96
|
+
* graph. Falls back to a full fit when `nodeIds` is empty. */
|
|
97
|
+
fitToNodes(nodeIds: ReadonlyArray<string | number>, durationMs?: number, padding?: number): void;
|
|
73
98
|
/** Snapshot the current camera so it can be restored later. */
|
|
74
99
|
getCameraState(): CameraState;
|
|
75
100
|
/** Restore a snapshot produced by `getCameraState`. */
|
|
@@ -2,10 +2,12 @@ import { MutableRefObject } from 'react';
|
|
|
2
2
|
import { GraphEngine } from '../engines/types';
|
|
3
3
|
import { LinkObject, NodeObject } from '../types';
|
|
4
4
|
import { GraphDataApi } from './useGraphData';
|
|
5
|
+
import { GraphDeleteGateApi } from './useGraphDeleteGate';
|
|
5
6
|
import { SelectionApi } from './useGraphSelection';
|
|
6
7
|
export interface UseGraphClipboardParams<N extends NodeObject, L extends LinkObject> {
|
|
7
8
|
enableClipboard: boolean;
|
|
8
9
|
dataApi: GraphDataApi<N, L>;
|
|
10
|
+
deleteGate: GraphDeleteGateApi<L>;
|
|
9
11
|
selection: SelectionApi;
|
|
10
12
|
setSelectedLinkIds: React.Dispatch<React.SetStateAction<Array<string | number>>>;
|
|
11
13
|
engineRef: MutableRefObject<GraphEngine<N, L> | null>;
|
|
@@ -23,7 +25,11 @@ export interface GraphClipboardApi<N extends NodeObject> {
|
|
|
23
25
|
* callers that need to react on every keystroke. */
|
|
24
26
|
hasClipboard(): boolean;
|
|
25
27
|
copy(): N[];
|
|
26
|
-
cut
|
|
28
|
+
/** Async because cut funnels through the host's delete guard — if the
|
|
29
|
+
* guard rejects, the cut becomes a no-op (clipboard untouched, nodes
|
|
30
|
+
* not removed). Callers that fire-and-forget can ignore the
|
|
31
|
+
* promise. */
|
|
32
|
+
cut(): Promise<N[]>;
|
|
27
33
|
paste(at?: {
|
|
28
34
|
x: number;
|
|
29
35
|
y: number;
|
|
@@ -24,7 +24,7 @@ export interface GraphDataApi<N extends NodeObject, L extends LinkObject> {
|
|
|
24
24
|
}>): N[];
|
|
25
25
|
updateNode(id: string | number, patch: Partial<N>): void;
|
|
26
26
|
removeNode(id: string | number): void;
|
|
27
|
-
removeNodes(ids:
|
|
27
|
+
removeNodes(ids: ReadonlyArray<string | number>): void;
|
|
28
28
|
addLink(link: {
|
|
29
29
|
source: string | number;
|
|
30
30
|
target: string | number;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DeletionGuard, DeletionSource, LinkObject, NodeObject } from '../types';
|
|
2
|
+
import { GraphDataApi } from './useGraphData';
|
|
3
|
+
export interface UseGraphDeleteGateParams<N extends NodeObject, L extends LinkObject> {
|
|
4
|
+
dataApi: GraphDataApi<N, L>;
|
|
5
|
+
beforeNode?: DeletionGuard<N>;
|
|
6
|
+
beforeLink?: DeletionGuard<L>;
|
|
7
|
+
onNodeDeleted?: (nodes: N[], ctx: {
|
|
8
|
+
source: DeletionSource;
|
|
9
|
+
}) => void;
|
|
10
|
+
onLinkDeleted?: (links: L[], ctx: {
|
|
11
|
+
source: DeletionSource;
|
|
12
|
+
}) => void;
|
|
13
|
+
/** Called after a successful node delete so the caller can clear its
|
|
14
|
+
* own selection / hover state. Skipped if the guard rejected. */
|
|
15
|
+
afterNodeDelete?: (ids: ReadonlyArray<string | number>) => void;
|
|
16
|
+
afterLinkDelete?: (ids: ReadonlyArray<string | number>) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface GraphDeleteGateApi<L extends LinkObject> {
|
|
19
|
+
/** Resolves the selected node ids against current data, runs the guard,
|
|
20
|
+
* and removes them. Returns `false` if the guard rejected or nothing
|
|
21
|
+
* matched. */
|
|
22
|
+
requestNodeDelete: (ids: ReadonlyArray<string | number>, source: DeletionSource) => Promise<boolean>;
|
|
23
|
+
/** Same, for links. Accepts either an id list or a predicate so context
|
|
24
|
+
* menus that hold the link reference can still target it precisely
|
|
25
|
+
* (links sometimes lack an id). */
|
|
26
|
+
requestLinkDelete: (target: ReadonlyArray<string | number> | ((l: L) => boolean), source: DeletionSource) => Promise<boolean>;
|
|
27
|
+
/** Convenience: run node + link guards in sequence. Used by the
|
|
28
|
+
* "delete selection" sites (toolbar / selection panel / keyboard)
|
|
29
|
+
* where a mixed selection is common. Each guard fires independently;
|
|
30
|
+
* rejecting one doesn't cancel the other. Returns true if anything
|
|
31
|
+
* was actually deleted. */
|
|
32
|
+
requestMixedDelete: (nodeIds: ReadonlyArray<string | number>, linkIds: ReadonlyArray<string | number>, source: DeletionSource) => Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
/** Single chokepoint for every gated delete in the canvas. Centralising
|
|
35
|
+
* here keeps the guard semantics (batched calls, post-delete callbacks,
|
|
36
|
+
* selection cleanup) consistent across keyboard, toolbar, context menu,
|
|
37
|
+
* selection panel, and imperative paths. */
|
|
38
|
+
export declare function useGraphDeleteGate<N extends NodeObject, L extends LinkObject>(params: UseGraphDeleteGateParams<N, L>): GraphDeleteGateApi<L>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UnifiedEngine } from '../engines/createEngineUnified';
|
|
2
2
|
import { GraphData, GraphMode, LinkObject, LoraGraphCanvasProps, NodeObject } from '../types';
|
|
3
3
|
interface UseGraphEngineParams<N extends NodeObject, L extends LinkObject> {
|
|
4
4
|
mount: HTMLElement | null;
|
|
@@ -19,5 +19,5 @@ interface UseGraphEngineParams<N extends NodeObject, L extends LinkObject> {
|
|
|
19
19
|
* to 0, top-down camera) and 3D (z released, orbit camera). The
|
|
20
20
|
* engine is destroyed only when the host element changes or the
|
|
21
21
|
* component unmounts. */
|
|
22
|
-
export declare function useGraphEngine<N extends NodeObject, L extends LinkObject>(params: UseGraphEngineParams<N, L>):
|
|
22
|
+
export declare function useGraphEngine<N extends NodeObject, L extends LinkObject>(params: UseGraphEngineParams<N, L>): UnifiedEngine<N, L> | null;
|
|
23
23
|
export {};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
1
2
|
import { GraphEngine } from '../engines/types';
|
|
2
3
|
import { GraphMode, LinkObject, NodeObject, ToolId } from '../types';
|
|
3
4
|
import { GraphDataApi } from './useGraphData';
|
|
5
|
+
import { GraphDeleteGateApi } from './useGraphDeleteGate';
|
|
4
6
|
import { SelectionApi } from './useGraphSelection';
|
|
5
7
|
export interface UseGraphKeybindingsParams<N extends NodeObject, L extends LinkObject> {
|
|
6
8
|
engine: GraphEngine<N, L> | null;
|
|
7
9
|
dataApi: GraphDataApi<N, L>;
|
|
10
|
+
deleteGate: GraphDeleteGateApi<L>;
|
|
8
11
|
selection: SelectionApi;
|
|
9
12
|
mode: GraphMode;
|
|
10
13
|
setMode: (next: GraphMode) => void;
|
|
@@ -19,6 +22,10 @@ export interface UseGraphKeybindingsParams<N extends NodeObject, L extends LinkO
|
|
|
19
22
|
duplicate: () => unknown;
|
|
20
23
|
addConnectedNode: () => unknown;
|
|
21
24
|
togglePin: (id: string | number) => void;
|
|
25
|
+
/** Host element. Bindings only fire while focus is inside this
|
|
26
|
+
* element — otherwise hitting `f` while typing into a sibling text
|
|
27
|
+
* field on the page would trigger the canvas fit shortcut. */
|
|
28
|
+
hostRef: RefObject<HTMLElement | null>;
|
|
22
29
|
}
|
|
23
30
|
/** Global keyboard shortcuts for the canvas. The listener is bound once
|
|
24
31
|
* per mount; live state is read through a ref so we avoid the
|
|
@@ -3,12 +3,21 @@ export interface UseGraphSelectionOptions {
|
|
|
3
3
|
onChange?: (selectedIds: Array<string | number>) => void;
|
|
4
4
|
}
|
|
5
5
|
export interface SelectionApi {
|
|
6
|
-
|
|
6
|
+
/** Selected ids as a stable array. Identity flips on every mutation
|
|
7
|
+
* so it can drive React effect dep arrays. Backed by the same `Set`
|
|
8
|
+
* as `selectedSet`. */
|
|
9
|
+
selected: ReadonlyArray<string | number>;
|
|
10
|
+
/** O(1)-membership view of the same selection. Wrappers that test
|
|
11
|
+
* `selectedSet.has(id)` on every node/link must read this instead
|
|
12
|
+
* of the array — `selected.includes(id)` is O(N) and on a 10k
|
|
13
|
+
* selection the kapsule digest paid that cost per node per
|
|
14
|
+
* frame. */
|
|
15
|
+
selectedSet: ReadonlySet<string | number>;
|
|
7
16
|
isSelected(id: string | number): boolean;
|
|
8
17
|
toggle(id: string | number, opts?: {
|
|
9
18
|
additive?: boolean;
|
|
10
19
|
}): void;
|
|
11
|
-
set(ids:
|
|
20
|
+
set(ids: ReadonlyArray<string | number> | ReadonlySet<string | number>): void;
|
|
12
21
|
clear(): void;
|
|
13
22
|
}
|
|
14
23
|
export declare function useGraphSelection(opts: UseGraphSelectionOptions): SelectionApi;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { GraphEngine } from '../engines/types';
|
|
2
2
|
import { GraphMode, LinkObject, LoraGraphCanvasHandle, NodeObject } from '../types';
|
|
3
3
|
import { GraphDataApi } from './useGraphData';
|
|
4
|
+
import { GraphDeleteGateApi } from './useGraphDeleteGate';
|
|
4
5
|
import { SelectionApi } from './useGraphSelection';
|
|
5
6
|
import { GraphClipboardApi } from './useGraphClipboard';
|
|
6
7
|
export interface UseImperativeGraphHandleParams<N extends NodeObject, L extends LinkObject> {
|
|
7
8
|
ref: React.Ref<LoraGraphCanvasHandle<N, L>>;
|
|
8
9
|
dataApi: GraphDataApi<N, L>;
|
|
10
|
+
deleteGate: GraphDeleteGateApi<L>;
|
|
9
11
|
selection: SelectionApi;
|
|
10
12
|
engine: GraphEngine<N, L> | null;
|
|
11
13
|
mode: GraphMode;
|
|
@@ -15,8 +17,18 @@ export interface UseImperativeGraphHandleParams<N extends NodeObject, L extends
|
|
|
15
17
|
exportJSON: () => string;
|
|
16
18
|
importJSON: (json: string) => void;
|
|
17
19
|
downloadJSON: (filename?: string) => void;
|
|
20
|
+
/** Live link selection — needed for fitToSelection to expand into
|
|
21
|
+
* link endpoints. */
|
|
22
|
+
selectedLinkIds: Array<string | number>;
|
|
18
23
|
}
|
|
19
24
|
/** Hooks `useImperativeHandle` to expose the canvas's full
|
|
20
25
|
* programmatic API surface to consumers via a forwardRef. Kept as a
|
|
21
|
-
* hook so the main component file stays focused on rendering.
|
|
26
|
+
* hook so the main component file stays focused on rendering.
|
|
27
|
+
*
|
|
28
|
+
* Live state (selection, link selection, data, engine) is read inside
|
|
29
|
+
* the handle methods via a ref so the handle object identity doesn't
|
|
30
|
+
* churn on every click. Without this, every selection change rebuilt
|
|
31
|
+
* the whole handle and any host holding `ref.current` saw a fresh set
|
|
32
|
+
* of method identities each click — defeating downstream memoisation
|
|
33
|
+
* on the host side. */
|
|
22
34
|
export declare function useImperativeGraphHandle<N extends NodeObject, L extends LinkObject>(params: UseImperativeGraphHandleParams<N, L>): void;
|
|
@@ -8,6 +8,11 @@ export interface MarqueeRect {
|
|
|
8
8
|
x1: number;
|
|
9
9
|
y1: number;
|
|
10
10
|
additive: boolean;
|
|
11
|
+
/** Live count of nodes inside the rectangle. Updated on rAF
|
|
12
|
+
* throttle during drag — cheap to compute (one graph2Screen per
|
|
13
|
+
* node) but we still coalesce by frame so a 5k-node graph doesn't
|
|
14
|
+
* re-render the host 60×/s. Undefined while inactive. */
|
|
15
|
+
count?: number;
|
|
11
16
|
}
|
|
12
17
|
export interface UseMarqueeAndCursorParams<N extends NodeObject, L extends LinkObject> {
|
|
13
18
|
mount: HTMLDivElement | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Track the user's `prefers-reduced-motion` media query. Returns
|
|
2
|
+
* `true` when the user has asked the OS to minimise non-essential
|
|
3
|
+
* motion — our camera tweens (intro zoom, mode transition, focus
|
|
4
|
+
* fly-in) skip the animation in that case and snap directly to the
|
|
5
|
+
* final state.
|
|
6
|
+
*
|
|
7
|
+
* Re-reads on media-query change so we react when the user flips the
|
|
8
|
+
* setting mid-session. Returns `false` in non-browser environments
|
|
9
|
+
* (SSR / jsdom without matchMedia) so animations play by default. */
|
|
10
|
+
export declare function usePrefersReducedMotion(): boolean;
|