canvas/dist/gestures/index.js

7397 lines
215 KiB
JavaScript
Raw Normal View History

2026-03-11 18:42:08 -07:00
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/gestures/index.ts
var index_exports = {};
__export(index_exports, {
ACTIVE_INTERACTION_CONTEXT: () => ACTIVE_INTERACTION_CONTEXT,
DEFAULT_CONTEXT: () => DEFAULT_CONTEXT,
GestureProvider: () => GestureProvider,
IDLE: () => IDLE,
INPUT_MODE_CONTEXTS: () => INPUT_MODE_CONTEXTS,
InputProvider: () => InputProvider,
KEYBOARD_MANIPULATE_CONTEXT: () => KEYBOARD_MANIPULATE_CONTEXT,
KEYBOARD_NAVIGATE_CONTEXT: () => KEYBOARD_NAVIGATE_CONTEXT,
LONG_PRESS_TIMER: () => LONG_PRESS_TIMER,
NO_HELD_KEYS: () => NO_HELD_KEYS,
NO_MODIFIERS: () => NO_MODIFIERS,
PALM_REJECTION_CONTEXT: () => PALM_REJECTION_CONTEXT,
PICK_NODES_CONTEXT: () => PICK_NODES_CONTEXT,
PICK_NODE_CONTEXT: () => PICK_NODE_CONTEXT,
PICK_POINT_CONTEXT: () => PICK_POINT_CONTEXT,
PanInertia: () => PanInertia,
SEARCH_CONTEXT: () => SEARCH_CONTEXT,
SETTLE_TIMER: () => SETTLE_TIMER,
TimedStateRunner: () => TimedStateRunner,
VelocitySampler: () => VelocitySampler,
ZoomInertia: () => ZoomInertia,
activateFocusedNode: () => activateFocusedNode,
buildMappingIndex: () => buildMappingIndex,
cancelActiveInteraction: () => cancelActiveInteraction,
clearHandlers: () => clearHandlers,
createPinchHandlers: () => createPinchHandlers,
createWheelHandler: () => createWheelHandler,
cutSelection: () => cutSelection,
cycleFocus: () => cycleFocus,
deleteSelection: () => deleteSelection,
dispatch: () => dispatch,
escapeInput: () => escapeInput,
extractModifiers: () => extractModifiers,
findNearestNode: () => findNearestNode,
getCurrentSubject: () => getCurrentSubject,
getHandler: () => getHandler,
indexContext: () => indexContext,
isKeyInputEvent: () => isKeyInputEvent,
isPointerGestureEvent: () => isPointerGestureEvent,
navigateFocus: () => navigateFocus,
normalizePointer: () => normalizePointer,
nudgeSelection: () => nudgeSelection,
registerAction: () => registerAction,
resolve: () => resolve,
snapZoom: () => snapZoom,
specificity: () => specificity,
transition: () => transition,
unregisterAction: () => unregisterAction,
useCanvasGestures: () => useCanvasGestures,
useGestureContext: () => useGestureContext,
useGestureSystem: () => useGestureSystem,
useGuardContext: () => useGuardContext,
useInertia: () => useInertia,
useInputContext: () => useInputContext,
useInputModeGestureContext: () => useInputModeGestureContext,
useInputSystem: () => useInputSystem,
useNodeGestures: () => useNodeGestures,
useRegisterInputActions: () => useRegisterInputActions
});
module.exports = __toCommonJS(index_exports);
// src/gestures/types.ts
var NO_MODIFIERS = Object.freeze({
shift: false,
ctrl: false,
alt: false,
meta: false
});
var NO_HELD_KEYS = Object.freeze({
byKey: Object.freeze({}),
byCode: Object.freeze({})
});
function isKeyInputEvent(event) {
return event.kind === "key";
}
function isPointerGestureEvent(event) {
return event.kind !== "key";
}
// src/core/input-classifier.ts
function classifyPointer(e) {
const source = pointerTypeToSource(e.pointerType);
return {
source,
pointerId: e.pointerId,
pressure: e.pressure,
tiltX: e.tiltX,
tiltY: e.tiltY,
isPrimary: e.isPrimary,
rawPointerType: e.pointerType
};
}
function pointerTypeToSource(pointerType) {
switch (pointerType) {
case "pen":
return "pencil";
case "touch":
return "finger";
case "mouse":
return "mouse";
default:
return "mouse";
}
}
function detectInputCapabilities() {
if (typeof window === "undefined") {
return {
hasTouch: false,
hasStylus: false,
hasMouse: true,
hasCoarsePointer: false
};
}
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const supportsMatchMedia = typeof window.matchMedia === "function";
const hasCoarsePointer = supportsMatchMedia ? window.matchMedia("(pointer: coarse)").matches : false;
const hasFinePointer = supportsMatchMedia ? window.matchMedia("(pointer: fine)").matches : true;
const hasMouse = hasFinePointer || !hasTouch;
return {
hasTouch,
hasStylus: false,
// Set to true on first pen event
hasMouse,
hasCoarsePointer
};
}
// src/gestures/normalize.ts
function extractModifiers(e) {
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
return NO_MODIFIERS;
}
return {
shift: e.shiftKey,
ctrl: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey
};
}
function clampButton(raw) {
if (raw === 1) return 1;
if (raw === 2) return 2;
return 0;
}
function lifecycleFromEventType(type) {
switch (type) {
case "pointerdown":
return "down";
case "pointerup":
return "up";
case "pointercancel":
return "cancel";
default:
return "move";
}
}
function normalizePointer(e) {
const classified = classifyPointer(e);
return {
pointerId: e.pointerId,
lifecycle: lifecycleFromEventType(e.type),
source: classified.source,
button: clampButton(e.button),
modifiers: extractModifiers(e),
screenX: e.clientX,
screenY: e.clientY,
pressure: e.pressure,
timestamp: e.timeStamp
};
}
// src/gestures/timed-state.ts
var LONG_PRESS_TIMER = "long-press";
var SETTLE_TIMER = "settle";
var DEFAULT_LONG_PRESS_MS = 600;
var DEFAULT_MULTI_TAP_WINDOW_MS = 300;
var IDLE = {
tag: "idle",
tapCount: 0
};
var DEFAULT_CONFIG = {
longPressMs: DEFAULT_LONG_PRESS_MS,
multiTapWindowMs: DEFAULT_MULTI_TAP_WINDOW_MS
};
function transition(state, event, config = DEFAULT_CONFIG) {
switch (event) {
case "down":
return onDown(state, config);
case "up":
return onUp(state, config);
case "move-beyond-threshold":
return onMoveBeyond(state);
case "cancel":
return onCancel();
case "timer:long-press":
return onLongPressTimer(state);
case "timer:settle":
return onSettleTimer();
default:
return {
state
};
}
}
function onDown(state, config) {
switch (state.tag) {
case "idle":
return {
state: {
tag: "pressed",
tapCount: 0
},
cancelTimer: SETTLE_TIMER,
scheduleTimer: {
id: LONG_PRESS_TIMER,
delayMs: config.longPressMs
}
};
case "released":
return {
state: {
tag: "pressed",
tapCount: state.tapCount
},
cancelTimer: SETTLE_TIMER,
scheduleTimer: {
id: LONG_PRESS_TIMER,
delayMs: config.longPressMs
}
};
default:
return {
state
};
}
}
function onUp(state, config) {
if (state.tag !== "pressed") {
if (state.tag === "long-pressed") {
return {
state: IDLE,
cancelTimer: LONG_PRESS_TIMER
};
}
return {
state
};
}
const newCount = state.tapCount + 1;
const emit = tapTypeForCount(newCount);
return {
state: {
tag: "released",
tapCount: newCount
},
emit,
cancelTimer: LONG_PRESS_TIMER,
scheduleTimer: {
id: SETTLE_TIMER,
delayMs: config.multiTapWindowMs
}
};
}
function onMoveBeyond(state) {
if (state.tag === "pressed") {
return {
state: IDLE,
cancelTimer: LONG_PRESS_TIMER
};
}
return {
state
};
}
function onCancel() {
return {
state: IDLE,
cancelTimer: LONG_PRESS_TIMER
};
}
function onLongPressTimer(state) {
if (state.tag === "pressed") {
return {
state: {
tag: "long-pressed",
tapCount: 0
},
emit: "long-press"
};
}
return {
state
};
}
function onSettleTimer() {
return {
state: IDLE
};
}
function tapTypeForCount(count) {
switch (count) {
case 2:
return "double-tap";
case 3:
return "triple-tap";
default:
return "tap";
}
}
// src/gestures/timed-state-runner.ts
var TimedStateRunner = class {
constructor(config) {
__publicField(this, "state", IDLE);
__publicField(this, "timers", /* @__PURE__ */ new Map());
/** Called when the state machine emits a gesture (tap, double-tap, long-press, etc.) */
__publicField(this, "onEmit", null);
this.config = {
longPressMs: config?.longPressMs ?? DEFAULT_LONG_PRESS_MS,
multiTapWindowMs: config?.multiTapWindowMs ?? DEFAULT_MULTI_TAP_WINDOW_MS
};
}
/**
* Feed a lifecycle event into the state machine.
* Returns the emitted gesture type if any (synchronous emit only).
* Timer-based emits are delivered asynchronously via `onEmit`.
*/
feed(event) {
return this.apply(event);
}
/** Current state tag (for debugging/testing). */
get tag() {
return this.state.tag;
}
/** Destroy: cancel all pending timers. */
destroy() {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
this.state = IDLE;
this.onEmit = null;
}
apply(event) {
const result = transition(this.state, event, this.config);
this.state = result.state;
if (result.cancelTimer) {
const timer = this.timers.get(result.cancelTimer);
if (timer !== void 0) {
clearTimeout(timer);
this.timers.delete(result.cancelTimer);
}
}
if (result.scheduleTimer) {
const {
id,
delayMs
} = result.scheduleTimer;
const existing = this.timers.get(id);
if (existing !== void 0) {
clearTimeout(existing);
}
this.timers.set(id, setTimeout(() => {
this.timers.delete(id);
const timerEvent = `timer:${id}`;
const timerResult = transition(this.state, timerEvent, this.config);
this.state = timerResult.state;
if (timerResult.emit) {
this.onEmit?.(timerResult.emit);
}
}, delayMs));
}
return result.emit;
}
};
// src/gestures/specificity.ts
var SCORE_TYPE = 128;
var SCORE_KEY = 64;
var SCORE_CODE = 64;
var SCORE_SUBJECT = 32;
var SCORE_MODIFIER_POSITIVE = 16;
var SCORE_MODIFIER_NEGATIVE = 8;
var SCORE_HELD_KEY_POSITIVE = 16;
var SCORE_HELD_KEY_NEGATIVE = 8;
var SCORE_SOURCE = 4;
var SCORE_BUTTON = 2;
function specificity(pattern, event) {
let score = 0;
if (pattern.kind !== void 0) {
const actualKind = isKeyInputEvent(event) ? "key" : "pointer";
if (pattern.kind !== actualKind) return -1;
}
if (pattern.type !== void 0) {
if (!isPointerGestureEvent(event) || pattern.type !== event.type) return -1;
score += SCORE_TYPE;
}
if (pattern.subjectKind !== void 0) {
if (event.subject === void 0 || pattern.subjectKind !== event.subject.kind) return -1;
score += SCORE_SUBJECT;
}
if (pattern.phase !== void 0 && pattern.phase !== event.phase) {
return -1;
}
if (pattern.source !== void 0) {
if (!isPointerGestureEvent(event)) return -1;
if (pattern.source !== event.source) return -1;
score += SCORE_SOURCE;
}
if (pattern.button !== void 0) {
if (!isPointerGestureEvent(event)) return -1;
if (pattern.button !== event.button) return -1;
score += SCORE_BUTTON;
}
if (pattern.key !== void 0) {
if (!isKeyInputEvent(event) || pattern.key !== event.key) return -1;
score += SCORE_KEY;
}
if (pattern.code !== void 0) {
if (!isKeyInputEvent(event) || pattern.code !== event.code) return -1;
score += SCORE_CODE;
}
if (pattern.modifiers !== void 0) {
const ms = modifierScore(pattern.modifiers, event.modifiers);
if (ms === -1) return -1;
score += ms;
}
if (pattern.heldKeys !== void 0) {
const hs = heldKeyScore(pattern.heldKeys, event.heldKeys);
if (hs === -1) return -1;
score += hs;
}
return score;
}
function modifierScore(pattern, actual) {
let score = 0;
const keys = ["shift", "ctrl", "alt", "meta"];
for (const key of keys) {
const required = pattern[key];
if (required === void 0) continue;
if (required !== actual[key]) return -1;
score += required ? SCORE_MODIFIER_POSITIVE : SCORE_MODIFIER_NEGATIVE;
}
if (pattern.custom) {
const actualCustom = actual.custom ?? {};
for (const [key, required] of Object.entries(pattern.custom)) {
const actualVal = actualCustom[key] ?? false;
if (required !== actualVal) return -1;
score += required ? SCORE_MODIFIER_POSITIVE : SCORE_MODIFIER_NEGATIVE;
}
}
return score;
}
function heldKeyScore(pattern, actual) {
let score = 0;
for (const [key, required] of Object.entries(pattern.byKey ?? {})) {
const actualVal = actual.byKey[key] ?? false;
if (required !== actualVal) return -1;
score += required ? SCORE_HELD_KEY_POSITIVE : SCORE_HELD_KEY_NEGATIVE;
}
for (const [code, required] of Object.entries(pattern.byCode ?? {})) {
const actualVal = actual.byCode[code] ?? false;
if (required !== actualVal) return -1;
score += required ? SCORE_HELD_KEY_POSITIVE : SCORE_HELD_KEY_NEGATIVE;
}
return score;
}
// src/gestures/mapper.ts
var WILDCARD = "__wildcard__";
function getPatternBucketKey(binding) {
const {
pattern
} = binding;
if (pattern.type !== void 0) {
return `pointer:${pattern.type}`;
}
if (pattern.kind === "key" || pattern.key !== void 0 || pattern.code !== void 0) {
return pattern.phase === "down" || pattern.phase === "up" ? `key:${pattern.phase}` : null;
}
return null;
}
function getEventBucketKey(event) {
return isKeyInputEvent(event) ? `key:${event.phase}` : `pointer:${event.type}`;
}
function indexContext(ctx) {
const typed = /* @__PURE__ */ new Map();
const wildcards = [];
for (const binding of ctx.bindings) {
const key = getPatternBucketKey(binding);
if (key === null) {
wildcards.push(binding);
} else {
let bucket = typed.get(key);
if (!bucket) {
bucket = [];
typed.set(key, bucket);
}
bucket.push(binding);
}
}
const buckets = /* @__PURE__ */ new Map();
for (const [key, bucket] of typed) {
buckets.set(key, bucket);
}
if (wildcards.length > 0) {
buckets.set(WILDCARD, wildcards);
}
return {
contextId: ctx.id,
priority: ctx.priority,
enabled: ctx.enabled,
buckets
};
}
function buildMappingIndex(contexts) {
return contexts.map(indexContext).sort((a, b) => a.priority - b.priority);
}
function resolve(event, index, guard) {
let winner = null;
for (const ctx of index) {
if (!ctx.enabled) continue;
const bucket = ctx.buckets.get(getEventBucketKey(event));
const wildcardBucket = ctx.buckets.get(WILDCARD);
if (!bucket && !wildcardBucket) continue;
let bestScore = -1;
let bestBinding = null;
for (const binding of [...bucket ?? [], ...wildcardBucket ?? []]) {
const score = specificity(binding.pattern, event);
if (score === -1) continue;
if (binding.when && !binding.when(guard)) continue;
if (score > bestScore) {
bestScore = score;
bestBinding = binding;
}
}
if (bestBinding !== null) {
const action = {
actionId: bestBinding.actionId,
binding: bestBinding,
contextId: ctx.contextId,
score: bestScore,
consumed: bestBinding.consumeInput === true
};
if (action.consumed) {
return action;
}
if (winner === null) {
winner = action;
}
}
}
return winner;
}
// src/gestures/keyboard-contexts.ts
var SEARCH_CONTEXT = {
id: "search-active",
priority: 25,
enabled: true,
bindings: [{
id: "search-next-enter",
pattern: {
kind: "key",
phase: "down",
key: "Enter",
modifiers: {
shift: false
}
},
actionId: "search-next-result",
when: (ctx) => ctx.isSearchActive && !ctx.commandLineVisible,
consumeInput: true
}, {
id: "search-prev-enter",
pattern: {
kind: "key",
phase: "down",
key: "Enter",
modifiers: {
shift: true
}
},
actionId: "search-prev-result",
when: (ctx) => ctx.isSearchActive && !ctx.commandLineVisible,
consumeInput: true
}, {
id: "search-next-ctrl-g",
pattern: {
kind: "key",
phase: "down",
key: "g",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "search-next-result",
when: (ctx) => ctx.isSearchActive,
consumeInput: true
}, {
id: "search-prev-ctrl-g",
pattern: {
kind: "key",
phase: "down",
key: "g",
modifiers: {
ctrl: true,
shift: true
}
},
actionId: "search-prev-result",
when: (ctx) => ctx.isSearchActive,
consumeInput: true
}, {
id: "search-next-meta-g",
pattern: {
kind: "key",
phase: "down",
key: "g",
modifiers: {
meta: true,
shift: false
}
},
actionId: "search-next-result",
when: (ctx) => ctx.isSearchActive,
consumeInput: true
}, {
id: "search-prev-meta-g",
pattern: {
kind: "key",
phase: "down",
key: "g",
modifiers: {
meta: true,
shift: true
}
},
actionId: "search-prev-result",
when: (ctx) => ctx.isSearchActive,
consumeInput: true
}]
};
var KEYBOARD_MANIPULATE_CONTEXT = {
id: "keyboard:manipulate",
priority: 30,
enabled: true,
bindings: [{
id: "manipulate-arrow-up",
pattern: {
kind: "key",
phase: "down",
key: "ArrowUp",
modifiers: {
shift: false
}
},
actionId: "nudge-selection-up",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-arrow-down",
pattern: {
kind: "key",
phase: "down",
key: "ArrowDown",
modifiers: {
shift: false
}
},
actionId: "nudge-selection-down",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-arrow-left",
pattern: {
kind: "key",
phase: "down",
key: "ArrowLeft",
modifiers: {
shift: false
}
},
actionId: "nudge-selection-left",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-arrow-right",
pattern: {
kind: "key",
phase: "down",
key: "ArrowRight",
modifiers: {
shift: false
}
},
actionId: "nudge-selection-right",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-shift-arrow-up",
pattern: {
kind: "key",
phase: "down",
key: "ArrowUp",
modifiers: {
shift: true
}
},
actionId: "nudge-selection-up-large",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-shift-arrow-down",
pattern: {
kind: "key",
phase: "down",
key: "ArrowDown",
modifiers: {
shift: true
}
},
actionId: "nudge-selection-down-large",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-shift-arrow-left",
pattern: {
kind: "key",
phase: "down",
key: "ArrowLeft",
modifiers: {
shift: true
}
},
actionId: "nudge-selection-left-large",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-shift-arrow-right",
pattern: {
kind: "key",
phase: "down",
key: "ArrowRight",
modifiers: {
shift: true
}
},
actionId: "nudge-selection-right-large",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}, {
id: "manipulate-escape",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "exit-keyboard-manipulate-mode",
when: (ctx) => ctx.keyboardInteractionMode === "manipulate",
consumeInput: true
}]
};
var KEYBOARD_NAVIGATE_CONTEXT = {
id: "keyboard:navigate",
priority: 40,
enabled: true,
bindings: [{
id: "navigate-arrow-up",
pattern: {
kind: "key",
phase: "down",
key: "ArrowUp"
},
actionId: "navigate-focus-up",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-arrow-down",
pattern: {
kind: "key",
phase: "down",
key: "ArrowDown"
},
actionId: "navigate-focus-down",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-arrow-left",
pattern: {
kind: "key",
phase: "down",
key: "ArrowLeft"
},
actionId: "navigate-focus-left",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-arrow-right",
pattern: {
kind: "key",
phase: "down",
key: "ArrowRight"
},
actionId: "navigate-focus-right",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-enter",
pattern: {
kind: "key",
phase: "down",
key: "Enter"
},
actionId: "enter-keyboard-manipulate-mode",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-space",
pattern: {
kind: "key",
phase: "down",
code: "Space"
},
actionId: "activate-focused-node",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-tab",
pattern: {
kind: "key",
phase: "down",
key: "Tab",
modifiers: {
shift: false
}
},
actionId: "cycle-focus-forward",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}, {
id: "navigate-shift-tab",
pattern: {
kind: "key",
phase: "down",
key: "Tab",
modifiers: {
shift: true
}
},
actionId: "cycle-focus-backward",
when: (ctx) => ctx.keyboardInteractionMode === "navigate",
consumeInput: true
}]
};
// src/gestures/pointer-bindings.ts
var POINTER_BINDINGS = [
// --- Pointer taps ---
{
id: "tap-node",
pattern: {
type: "tap",
subjectKind: "node"
},
actionId: "select-node"
},
{
id: "tap-edge",
pattern: {
type: "tap",
subjectKind: "edge"
},
actionId: "select-edge"
},
{
id: "tap-bg",
pattern: {
type: "tap",
subjectKind: "background"
},
actionId: "clear-selection"
},
{
id: "shift-tap-node",
pattern: {
type: "tap",
subjectKind: "node",
modifiers: {
shift: true
}
},
actionId: "toggle-selection"
},
// --- Right-click ---
{
id: "rc-node",
pattern: {
type: "tap",
subjectKind: "node",
button: 2
},
actionId: "context-menu",
consumeInput: true
},
{
id: "rc-bg",
pattern: {
type: "tap",
subjectKind: "background",
button: 2
},
actionId: "context-menu",
consumeInput: true
},
// --- Double/triple tap ---
{
id: "dtap-node",
pattern: {
type: "double-tap",
subjectKind: "node"
},
actionId: "fit-to-view"
},
{
id: "ttap-node",
pattern: {
type: "triple-tap",
subjectKind: "node"
},
actionId: "toggle-lock"
},
// --- Drags ---
{
id: "drag-node",
pattern: {
type: "drag",
subjectKind: "node"
},
actionId: "move-node"
},
{
id: "drag-bg-finger",
pattern: {
type: "drag",
subjectKind: "background",
source: "finger"
},
actionId: "pan"
},
{
id: "drag-bg-mouse",
pattern: {
type: "drag",
subjectKind: "background",
source: "mouse"
},
actionId: "pan"
},
{
id: "drag-bg-pencil",
pattern: {
type: "drag",
subjectKind: "background",
source: "pencil"
},
actionId: "lasso-select"
},
{
id: "space-drag-pan",
pattern: {
type: "drag",
heldKeys: {
byCode: {
Space: true
}
}
},
actionId: "pan"
},
{
id: "shift-drag-bg",
pattern: {
type: "drag",
subjectKind: "background",
modifiers: {
shift: true
}
},
actionId: "rect-select"
},
// Right/middle drag — no-ops by default
{
id: "rdrag-node",
pattern: {
type: "drag",
subjectKind: "node",
button: 2
},
actionId: "none"
},
{
id: "rdrag-bg",
pattern: {
type: "drag",
subjectKind: "background",
button: 2
},
actionId: "none"
},
// --- Long-press ---
{
id: "lp-node",
pattern: {
type: "long-press",
subjectKind: "node"
},
actionId: "context-menu"
},
{
id: "lp-bg-finger",
pattern: {
type: "long-press",
subjectKind: "background",
source: "finger"
},
actionId: "create-node"
},
// --- Pinch / scroll ---
{
id: "pinch-bg",
pattern: {
type: "pinch",
subjectKind: "background"
},
actionId: "zoom"
},
{
id: "scroll-any",
pattern: {
type: "scroll"
},
actionId: "zoom"
},
{
id: "pinch-node",
pattern: {
type: "pinch",
subjectKind: "node"
},
actionId: "split-node"
}
];
// src/gestures/keyboard-bindings.ts
var isCommandShortcut = (ctx) => !ctx.commandLineVisible;
var KEYBOARD_BINDINGS = [
{
id: "slash-open-command-line",
pattern: {
kind: "key",
phase: "down",
code: "Slash",
modifiers: {
ctrl: false,
meta: false,
shift: false
}
},
actionId: "open-command-line",
when: isCommandShortcut,
consumeInput: true
},
{
id: "escape-fallback",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "escape-input",
when: isCommandShortcut,
consumeInput: true
},
{
id: "delete-selection",
pattern: {
kind: "key",
phase: "down",
key: "Delete"
},
actionId: "delete-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "backspace-delete-selection",
pattern: {
kind: "key",
phase: "down",
key: "Backspace",
modifiers: {
ctrl: false,
meta: false
}
},
actionId: "delete-selection",
when: isCommandShortcut,
consumeInput: true
},
// Search
{
id: "open-search-ctrl-f",
pattern: {
kind: "key",
phase: "down",
key: "f",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "open-search",
consumeInput: true
},
{
id: "open-search-meta-f",
pattern: {
kind: "key",
phase: "down",
key: "f",
modifiers: {
meta: true,
shift: false
}
},
actionId: "open-search",
consumeInput: true
},
// Copy / Cut / Paste
{
id: "copy-selection-ctrl-c",
pattern: {
kind: "key",
phase: "down",
key: "c",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "copy-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "copy-selection-meta-c",
pattern: {
kind: "key",
phase: "down",
key: "c",
modifiers: {
meta: true,
shift: false
}
},
actionId: "copy-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "cut-selection-ctrl-x",
pattern: {
kind: "key",
phase: "down",
key: "x",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "cut-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "cut-selection-meta-x",
pattern: {
kind: "key",
phase: "down",
key: "x",
modifiers: {
meta: true,
shift: false
}
},
actionId: "cut-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "paste-selection-ctrl-v",
pattern: {
kind: "key",
phase: "down",
key: "v",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "paste-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "paste-selection-meta-v",
pattern: {
kind: "key",
phase: "down",
key: "v",
modifiers: {
meta: true,
shift: false
}
},
actionId: "paste-selection",
when: isCommandShortcut,
consumeInput: true
},
// Duplicate
{
id: "duplicate-selection-ctrl-d",
pattern: {
kind: "key",
phase: "down",
key: "d",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "duplicate-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "duplicate-selection-meta-d",
pattern: {
kind: "key",
phase: "down",
key: "d",
modifiers: {
meta: true,
shift: false
}
},
actionId: "duplicate-selection",
when: isCommandShortcut,
consumeInput: true
},
// Select All
{
id: "select-all-ctrl-a",
pattern: {
kind: "key",
phase: "down",
key: "a",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "select-all",
when: isCommandShortcut,
consumeInput: true
},
{
id: "select-all-meta-a",
pattern: {
kind: "key",
phase: "down",
key: "a",
modifiers: {
meta: true,
shift: false
}
},
actionId: "select-all",
when: isCommandShortcut,
consumeInput: true
},
// Merge
{
id: "merge-selection-ctrl-m",
pattern: {
kind: "key",
phase: "down",
key: "m",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "merge-selection",
when: isCommandShortcut,
consumeInput: true
},
{
id: "merge-selection-meta-m",
pattern: {
kind: "key",
phase: "down",
key: "m",
modifiers: {
meta: true,
shift: false
}
},
actionId: "merge-selection",
when: isCommandShortcut,
consumeInput: true
},
// Undo / Redo
{
id: "undo-ctrl-z",
pattern: {
kind: "key",
phase: "down",
key: "z",
modifiers: {
ctrl: true,
shift: false
}
},
actionId: "undo",
when: isCommandShortcut,
consumeInput: true
},
{
id: "undo-meta-z",
pattern: {
kind: "key",
phase: "down",
key: "z",
modifiers: {
meta: true,
shift: false
}
},
actionId: "undo",
when: isCommandShortcut,
consumeInput: true
},
{
id: "redo-ctrl-shift-z",
pattern: {
kind: "key",
phase: "down",
key: "z",
modifiers: {
ctrl: true,
shift: true
}
},
actionId: "redo",
when: isCommandShortcut,
consumeInput: true
},
{
id: "redo-meta-shift-z",
pattern: {
kind: "key",
phase: "down",
key: "z",
modifiers: {
meta: true,
shift: true
}
},
actionId: "redo",
when: isCommandShortcut,
consumeInput: true
},
{
id: "redo-ctrl-y",
pattern: {
kind: "key",
phase: "down",
key: "y",
modifiers: {
ctrl: true
}
},
actionId: "redo",
when: isCommandShortcut,
consumeInput: true
},
{
id: "redo-meta-y",
pattern: {
kind: "key",
phase: "down",
key: "y",
modifiers: {
meta: true
}
},
actionId: "redo",
when: isCommandShortcut,
consumeInput: true
}
];
// src/gestures/pointer-contexts.ts
var DEFAULT_CONTEXT = {
id: "default",
priority: 100,
enabled: true,
bindings: [...POINTER_BINDINGS, ...KEYBOARD_BINDINGS]
};
var PICK_NODE_CONTEXT = {
id: "input-mode:pickNode",
priority: 5,
enabled: true,
bindings: [{
id: "pick-tap-node",
pattern: {
type: "tap",
subjectKind: "node"
},
actionId: "resolve-pick-node",
consumeInput: true
}, {
id: "pick-cancel-bg",
pattern: {
type: "tap",
subjectKind: "background"
},
actionId: "cancel-pick",
consumeInput: true
}, {
id: "pick-cancel-key",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "cancel-pick",
consumeInput: true
}]
};
var PICK_NODES_CONTEXT = {
id: "input-mode:pickNodes",
priority: 5,
enabled: true,
bindings: [{
id: "pick-tap-node",
pattern: {
type: "tap",
subjectKind: "node"
},
actionId: "resolve-pick-node",
consumeInput: true
}, {
id: "pick-done",
pattern: {
type: "double-tap",
subjectKind: "background"
},
actionId: "finish-pick-nodes",
consumeInput: true
}, {
id: "pick-cancel-bg",
pattern: {
type: "tap",
subjectKind: "background",
button: 2
},
actionId: "cancel-pick",
consumeInput: true
}, {
id: "pick-cancel-key",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "cancel-pick",
consumeInput: true
}]
};
var PICK_POINT_CONTEXT = {
id: "input-mode:pickPoint",
priority: 5,
enabled: true,
bindings: [{
id: "pick-tap",
pattern: {
type: "tap"
},
actionId: "resolve-pick-point",
consumeInput: true
}, {
id: "pick-cancel-key",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "cancel-pick",
consumeInput: true
}]
};
var INPUT_MODE_CONTEXTS = {
normal: null,
pickNode: PICK_NODE_CONTEXT,
pickNodes: PICK_NODES_CONTEXT,
pickPoint: PICK_POINT_CONTEXT,
text: null,
select: null
};
// src/gestures/contexts.ts
var PALM_REJECTION_CONTEXT = {
id: "palm-rejection",
priority: 0,
enabled: true,
bindings: [{
id: "pr-finger-tap",
pattern: {
type: "tap",
source: "finger"
},
actionId: "none",
when: (ctx) => ctx.isStylusActive,
consumeInput: true
}, {
id: "pr-finger-dtap",
pattern: {
type: "double-tap",
source: "finger"
},
actionId: "none",
when: (ctx) => ctx.isStylusActive,
consumeInput: true
}, {
id: "pr-finger-ttap",
pattern: {
type: "triple-tap",
source: "finger"
},
actionId: "none",
when: (ctx) => ctx.isStylusActive,
consumeInput: true
}, {
id: "pr-finger-lp",
pattern: {
type: "long-press",
source: "finger"
},
actionId: "none",
when: (ctx) => ctx.isStylusActive,
consumeInput: true
}, {
id: "pr-finger-drag-node",
pattern: {
type: "drag",
subjectKind: "node",
source: "finger"
},
actionId: "pan",
when: (ctx) => ctx.isStylusActive,
consumeInput: true
}]
};
var ACTIVE_INTERACTION_CONTEXT = {
id: "active-interaction",
priority: 15,
enabled: true,
bindings: [{
id: "escape-cancel-active-interaction",
pattern: {
kind: "key",
phase: "down",
key: "Escape"
},
actionId: "cancel-active-input",
when: (ctx) => ctx.isDragging || ctx.isSplitting || Boolean(ctx.custom.isSelecting) || Boolean(ctx.custom.isCreatingEdge),
consumeInput: true
}]
};
// src/gestures/dispatcher.ts
var handlers = /* @__PURE__ */ new Map();
function registerAction(actionId, handler) {
handlers.set(actionId, handler);
}
function unregisterAction(actionId) {
handlers.delete(actionId);
}
function getHandler(actionId) {
return handlers.get(actionId);
}
function clearHandlers() {
handlers.clear();
}
function dispatch(event, resolution) {
if (resolution.actionId === "none") return true;
const handler = handlers.get(resolution.actionId);
if (!handler) return false;
if (typeof handler === "function") {
if (isKeyInputEvent(event) && event.phase === "down" || !isKeyInputEvent(event) && (event.phase === "start" || event.phase === "instant")) {
handler(event);
}
return true;
}
routePhase(handler, event.phase, event);
return true;
}
function routePhase(handler, phase, event) {
if (isKeyInputEvent(event)) {
routeKeyPhase(handler, phase, event);
return;
}
switch (phase) {
case "start":
handler.onStart?.(event);
break;
case "move":
handler.onMove?.(event);
break;
case "end":
handler.onEnd?.(event);
break;
case "instant":
handler.onInstant?.(event);
break;
case "cancel":
handler.onCancel?.(event);
break;
}
}
function routeKeyPhase(handler, phase, event) {
switch (phase) {
case "down":
handler.onDown?.(event);
break;
case "up":
handler.onUp?.(event);
break;
}
}
// src/gestures/inertia.ts
var PAN_FRICTION = 0.92;
var ZOOM_FRICTION = 0.88;
var MIN_VELOCITY = 0.5;
var ZOOM_SNAP_THRESHOLD = 0.03;
var VELOCITY_SAMPLE_COUNT = 5;
var VelocitySampler = class {
constructor(maxSamples = VELOCITY_SAMPLE_COUNT) {
__publicField(this, "samples", []);
this.maxSamples = maxSamples;
}
sample(vx, vy, t) {
if (this.samples.length >= this.maxSamples) {
this.samples.shift();
}
this.samples.push({
vx,
vy,
t
});
}
average() {
const n = this.samples.length;
if (n === 0) return {
x: 0,
y: 0
};
let sx = 0;
let sy = 0;
for (const s of this.samples) {
sx += s.vx;
sy += s.vy;
}
return {
x: sx / n,
y: sy / n
};
}
reset() {
this.samples.length = 0;
}
};
var PanInertia = class {
constructor(velocity, friction = PAN_FRICTION, minVelocity = MIN_VELOCITY) {
this.friction = friction;
this.minVelocity = minVelocity;
this.vx = velocity.x;
this.vy = velocity.y;
}
/**
* Advance one frame. Returns the velocity delta to apply,
* or null if below threshold (animation complete).
*/
tick() {
this.vx *= this.friction;
this.vy *= this.friction;
if (Math.abs(this.vx) < this.minVelocity && Math.abs(this.vy) < this.minVelocity) {
return null;
}
return {
x: this.vx,
y: this.vy
};
}
};
var ZoomInertia = class {
constructor(velocity, origin, friction = ZOOM_FRICTION, minVelocity = MIN_VELOCITY, snapThreshold = ZOOM_SNAP_THRESHOLD) {
this.origin = origin;
this.friction = friction;
this.minVelocity = minVelocity;
this.snapThreshold = snapThreshold;
this.v = velocity;
}
/**
* Advance one frame. Returns zoom delta + origin,
* or null if below threshold.
*/
tick(currentZoom) {
this.v *= this.friction;
if (Math.abs(this.v) < this.minVelocity) {
if (Math.abs(currentZoom - 1) < this.snapThreshold) {
const snapDelta = 1 - currentZoom;
return Math.abs(snapDelta) > 1e-3 ? {
delta: snapDelta,
origin: this.origin
} : null;
}
return null;
}
return {
delta: this.v,
origin: this.origin
};
}
};
// src/core/graph-store.ts
var import_jotai = require("jotai");
var import_graphology = __toESM(require("graphology"));
var graphOptions = {
type: "directed",
multi: true,
allowSelfLoops: true
};
var currentGraphIdAtom = (0, import_jotai.atom)(null);
var graphAtom = (0, import_jotai.atom)(new import_graphology.default(graphOptions));
var graphUpdateVersionAtom = (0, import_jotai.atom)(0);
var edgeCreationAtom = (0, import_jotai.atom)({
isCreating: false,
sourceNodeId: null,
sourceNodePosition: null,
targetPosition: null,
hoveredTargetNodeId: null,
sourceHandle: null,
targetHandle: null,
sourcePort: null,
targetPort: null,
snappedTargetPosition: null
});
var draggingNodeIdAtom = (0, import_jotai.atom)(null);
var preDragNodeAttributesAtom = (0, import_jotai.atom)(null);
// src/core/graph-position.ts
var import_jotai3 = require("jotai");
var import_jotai_family = require("jotai-family");
var import_graphology2 = __toESM(require("graphology"));
// src/utils/debug.ts
var import_debug = __toESM(require("debug"));
var NAMESPACE = "canvas";
function createDebug(module2) {
const base = (0, import_debug.default)(`${NAMESPACE}:${module2}`);
const warn = (0, import_debug.default)(`${NAMESPACE}:${module2}:warn`);
const error = (0, import_debug.default)(`${NAMESPACE}:${module2}:error`);
warn.enabled = true;
error.enabled = true;
warn.log = console.warn.bind(console);
error.log = console.error.bind(console);
const debugFn = Object.assign(base, {
warn,
error
});
return debugFn;
}
var debug = {
graph: {
node: createDebug("graph:node"),
edge: createDebug("graph:edge"),
sync: createDebug("graph:sync")
},
ui: {
selection: createDebug("ui:selection"),
drag: createDebug("ui:drag"),
resize: createDebug("ui:resize")
},
sync: {
status: createDebug("sync:status"),
mutations: createDebug("sync:mutations"),
queue: createDebug("sync:queue")
},
viewport: createDebug("viewport")
};
// src/utils/mutation-queue.ts
var pendingNodeMutations = /* @__PURE__ */ new Map();
function clearAllPendingMutations() {
pendingNodeMutations.clear();
}
// src/core/perf.ts
var import_jotai2 = require("jotai");
var perfEnabledAtom = (0, import_jotai2.atom)(false);
var _enabled = false;
function setPerfEnabled(enabled) {
_enabled = enabled;
}
if (typeof window !== "undefined") {
window.__canvasPerf = setPerfEnabled;
}
function canvasMark(name) {
if (!_enabled) return _noop;
const markName = `canvas:${name}`;
try {
performance.mark(markName);
} catch {
return _noop;
}
return () => {
try {
performance.measure(`canvas:${name}`, markName);
} catch {
}
};
}
function _noop() {
}
// src/core/graph-position.ts
var debug2 = createDebug("graph:position");
var _positionCacheByGraph = /* @__PURE__ */ new WeakMap();
function getPositionCache(graph) {
let cache = _positionCacheByGraph.get(graph);
if (!cache) {
cache = /* @__PURE__ */ new Map();
_positionCacheByGraph.set(graph, cache);
}
return cache;
}
var nodePositionUpdateCounterAtom = (0, import_jotai3.atom)(0);
var nodePositionAtomFamily = (0, import_jotai_family.atomFamily)((nodeId) => (0, import_jotai3.atom)((get) => {
get(nodePositionUpdateCounterAtom);
const graph = get(graphAtom);
if (!graph.hasNode(nodeId)) {
return {
x: 0,
y: 0
};
}
const x = graph.getNodeAttribute(nodeId, "x");
const y = graph.getNodeAttribute(nodeId, "y");
const cache = getPositionCache(graph);
const prev = cache.get(nodeId);
if (prev && prev.x === x && prev.y === y) {
return prev;
}
const pos = {
x,
y
};
cache.set(nodeId, pos);
return pos;
}));
var updateNodePositionAtom = (0, import_jotai3.atom)(null, (get, set, {
nodeId,
position
}) => {
const end = canvasMark("drag-frame");
const graph = get(graphAtom);
if (graph.hasNode(nodeId)) {
debug2("Updating node %s position to %o", nodeId, position);
graph.setNodeAttribute(nodeId, "x", position.x);
graph.setNodeAttribute(nodeId, "y", position.y);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
}
end();
});
var cleanupNodePositionAtom = (0, import_jotai3.atom)(null, (get, _set, nodeId) => {
nodePositionAtomFamily.remove(nodeId);
const graph = get(graphAtom);
getPositionCache(graph).delete(nodeId);
debug2("Removed position atom for node: %s", nodeId);
});
var cleanupAllNodePositionsAtom = (0, import_jotai3.atom)(null, (get, _set) => {
const graph = get(graphAtom);
const nodeIds = graph.nodes();
nodeIds.forEach((nodeId) => {
nodePositionAtomFamily.remove(nodeId);
});
_positionCacheByGraph.delete(graph);
debug2("Removed %d position atoms", nodeIds.length);
});
var clearGraphOnSwitchAtom = (0, import_jotai3.atom)(null, (get, set) => {
debug2("Clearing graph for switch");
set(cleanupAllNodePositionsAtom);
clearAllPendingMutations();
const emptyGraph = new import_graphology2.default(graphOptions);
set(graphAtom, emptyGraph);
set(graphUpdateVersionAtom, (v) => v + 1);
});
// src/core/graph-derived.ts
var import_jotai8 = require("jotai");
var import_jotai_family2 = require("jotai-family");
// src/core/viewport-store.ts
var import_jotai5 = require("jotai");
// src/core/selection-store.ts
var import_jotai4 = require("jotai");
var debug3 = createDebug("selection");
var selectedNodeIdsAtom = (0, import_jotai4.atom)(/* @__PURE__ */ new Set());
var selectedEdgeIdAtom = (0, import_jotai4.atom)(null);
var handleNodePointerDownSelectionAtom = (0, import_jotai4.atom)(null, (get, set, {
nodeId,
isShiftPressed
}) => {
const currentSelection = get(selectedNodeIdsAtom);
debug3("handleNodePointerDownSelection: nodeId=%s, shift=%s, current=%o", nodeId, isShiftPressed, Array.from(currentSelection));
set(selectedEdgeIdAtom, null);
if (isShiftPressed) {
const newSelection = new Set(currentSelection);
if (newSelection.has(nodeId)) {
newSelection.delete(nodeId);
} else {
newSelection.add(nodeId);
}
debug3("Shift-click, setting selection to: %o", Array.from(newSelection));
set(selectedNodeIdsAtom, newSelection);
} else {
if (!currentSelection.has(nodeId)) {
debug3("Node not in selection, selecting: %s", nodeId);
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set([nodeId]));
} else {
debug3("Node already selected, preserving multi-select");
}
}
});
var selectSingleNodeAtom = (0, import_jotai4.atom)(null, (get, set, nodeId) => {
debug3("selectSingleNode: %s", nodeId);
set(selectedEdgeIdAtom, null);
if (nodeId === null || nodeId === void 0) {
debug3("Clearing selection");
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
} else {
const currentSelection = get(selectedNodeIdsAtom);
if (currentSelection.has(nodeId) && currentSelection.size === 1) {
return;
}
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set([nodeId]));
}
});
var toggleNodeInSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeId) => {
const currentSelection = get(selectedNodeIdsAtom);
const newSelection = new Set(currentSelection);
if (newSelection.has(nodeId)) {
newSelection.delete(nodeId);
} else {
newSelection.add(nodeId);
}
set(selectedNodeIdsAtom, newSelection);
});
var clearSelectionAtom = (0, import_jotai4.atom)(null, (_get, set) => {
debug3("clearSelection");
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
});
var addNodesToSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeIds) => {
const currentSelection = get(selectedNodeIdsAtom);
const newSelection = new Set(currentSelection);
for (const nodeId of nodeIds) {
newSelection.add(nodeId);
}
set(selectedNodeIdsAtom, newSelection);
});
var removeNodesFromSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeIds) => {
const currentSelection = get(selectedNodeIdsAtom);
const newSelection = new Set(currentSelection);
for (const nodeId of nodeIds) {
newSelection.delete(nodeId);
}
set(selectedNodeIdsAtom, newSelection);
});
var selectEdgeAtom = (0, import_jotai4.atom)(null, (get, set, edgeId) => {
set(selectedEdgeIdAtom, edgeId);
if (edgeId !== null) {
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
}
});
var clearEdgeSelectionAtom = (0, import_jotai4.atom)(null, (_get, set) => {
set(selectedEdgeIdAtom, null);
});
var focusedNodeIdAtom = (0, import_jotai4.atom)(null);
var setFocusedNodeAtom = (0, import_jotai4.atom)(null, (_get, set, nodeId) => {
set(focusedNodeIdAtom, nodeId);
});
var hasFocusedNodeAtom = (0, import_jotai4.atom)((get) => get(focusedNodeIdAtom) !== null);
var selectedNodesCountAtom = (0, import_jotai4.atom)((get) => get(selectedNodeIdsAtom).size);
var hasSelectionAtom = (0, import_jotai4.atom)((get) => get(selectedNodeIdsAtom).size > 0);
// src/utils/layout.ts
var FitToBoundsMode = /* @__PURE__ */ (function(FitToBoundsMode2) {
FitToBoundsMode2["Graph"] = "graph";
FitToBoundsMode2["Selection"] = "selection";
return FitToBoundsMode2;
})({});
var calculateBounds = (nodes) => {
if (nodes.length === 0) {
return {
x: 0,
y: 0,
width: 0,
height: 0
};
}
const minX = Math.min(...nodes.map((node) => node.x));
const minY = Math.min(...nodes.map((node) => node.y));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
};
// src/core/viewport-store.ts
var zoomAtom = (0, import_jotai5.atom)(1);
var panAtom = (0, import_jotai5.atom)({
x: 0,
y: 0
});
var viewportRectAtom = (0, import_jotai5.atom)(null);
var screenToWorldAtom = (0, import_jotai5.atom)((get) => {
return (screenX, screenY) => {
const pan = get(panAtom);
const zoom = get(zoomAtom);
const rect = get(viewportRectAtom);
if (!rect) {
return {
x: screenX,
y: screenY
};
}
const relativeX = screenX - rect.left;
const relativeY = screenY - rect.top;
return {
x: (relativeX - pan.x) / zoom,
y: (relativeY - pan.y) / zoom
};
};
});
var worldToScreenAtom = (0, import_jotai5.atom)((get) => {
return (worldX, worldY) => {
const pan = get(panAtom);
const zoom = get(zoomAtom);
const rect = get(viewportRectAtom);
if (!rect) {
return {
x: worldX,
y: worldY
};
}
return {
x: worldX * zoom + pan.x + rect.left,
y: worldY * zoom + pan.y + rect.top
};
};
});
var setZoomAtom = (0, import_jotai5.atom)(null, (get, set, {
zoom,
centerX,
centerY
}) => {
const currentZoom = get(zoomAtom);
const pan = get(panAtom);
const rect = get(viewportRectAtom);
const newZoom = Math.max(0.1, Math.min(5, zoom));
if (centerX !== void 0 && centerY !== void 0 && rect) {
const relativeX = centerX - rect.left;
const relativeY = centerY - rect.top;
const worldX = (relativeX - pan.x) / currentZoom;
const worldY = (relativeY - pan.y) / currentZoom;
const newPanX = relativeX - worldX * newZoom;
const newPanY = relativeY - worldY * newZoom;
set(panAtom, {
x: newPanX,
y: newPanY
});
}
set(zoomAtom, newZoom);
});
var resetViewportAtom = (0, import_jotai5.atom)(null, (_get, set) => {
set(zoomAtom, 1);
set(panAtom, {
x: 0,
y: 0
});
});
var fitToBoundsAtom = (0, import_jotai5.atom)(null, (get, set, {
mode,
padding = 20
}) => {
const normalizedMode = typeof mode === "string" ? mode === "graph" ? FitToBoundsMode.Graph : FitToBoundsMode.Selection : mode;
const viewportSize = get(viewportRectAtom);
if (!viewportSize || viewportSize.width <= 0 || viewportSize.height <= 0) return;
get(nodePositionUpdateCounterAtom);
let bounds;
if (normalizedMode === FitToBoundsMode.Graph) {
const graph = get(graphAtom);
const nodes = graph.nodes().map((node) => {
const attrs = graph.getNodeAttributes(node);
return {
x: attrs.x,
y: attrs.y,
width: attrs.width || 500,
height: attrs.height || 500
};
});
bounds = calculateBounds(nodes);
} else {
const selectedIds = get(selectedNodeIdsAtom);
const allNodes = get(uiNodesAtom);
const selectedNodes = allNodes.filter((n) => selectedIds.has(n.id)).map((n) => ({
x: n.position.x,
y: n.position.y,
width: n.width ?? 500,
height: n.height ?? 500
}));
bounds = calculateBounds(selectedNodes);
}
if (bounds.width <= 0 || bounds.height <= 0) return;
const maxHPad = Math.max(0, viewportSize.width / 2 - 1);
const maxVPad = Math.max(0, viewportSize.height / 2 - 1);
const safePadding = Math.max(0, Math.min(padding, maxHPad, maxVPad));
const effW = Math.max(1, viewportSize.width - 2 * safePadding);
const effH = Math.max(1, viewportSize.height - 2 * safePadding);
const scale = Math.min(effW / bounds.width, effH / bounds.height);
if (scale <= 0 || !isFinite(scale)) return;
set(zoomAtom, scale);
const scaledW = bounds.width * scale;
const scaledH = bounds.height * scale;
const startX = safePadding + (effW - scaledW) / 2;
const startY = safePadding + (effH - scaledH) / 2;
set(panAtom, {
x: startX - bounds.x * scale,
y: startY - bounds.y * scale
});
});
var centerOnNodeAtom = (0, import_jotai5.atom)(null, (get, set, nodeId) => {
const nodes = get(uiNodesAtom);
const node = nodes.find((n) => n.id === nodeId);
if (!node) return;
const {
x,
y,
width = 200,
height = 100
} = node;
const zoom = get(zoomAtom);
const centerX = x + width / 2;
const centerY = y + height / 2;
const rect = get(viewportRectAtom);
const halfWidth = rect ? rect.width / 2 : 400;
const halfHeight = rect ? rect.height / 2 : 300;
set(panAtom, {
x: halfWidth - centerX * zoom,
y: halfHeight - centerY * zoom
});
});
var zoomFocusNodeIdAtom = (0, import_jotai5.atom)(null);
var zoomTransitionProgressAtom = (0, import_jotai5.atom)(0);
var isZoomTransitioningAtom = (0, import_jotai5.atom)((get) => {
const progress = get(zoomTransitionProgressAtom);
return progress > 0 && progress < 1;
});
var zoomAnimationTargetAtom = (0, import_jotai5.atom)(null);
var animateZoomToNodeAtom = (0, import_jotai5.atom)(null, (get, set, {
nodeId,
targetZoom,
duration = 300
}) => {
const nodes = get(uiNodesAtom);
const node = nodes.find((n) => n.id === nodeId);
if (!node) return;
const {
x,
y,
width = 200,
height = 100
} = node;
const centerX = x + width / 2;
const centerY = y + height / 2;
const rect = get(viewportRectAtom);
const halfWidth = rect ? rect.width / 2 : 400;
const halfHeight = rect ? rect.height / 2 : 300;
const finalZoom = targetZoom ?? get(zoomAtom);
const targetPan = {
x: halfWidth - centerX * finalZoom,
y: halfHeight - centerY * finalZoom
};
set(zoomFocusNodeIdAtom, nodeId);
set(zoomAnimationTargetAtom, {
targetZoom: finalZoom,
targetPan,
startZoom: get(zoomAtom),
startPan: {
...get(panAtom)
},
duration,
startTime: performance.now()
});
});
var animateFitToBoundsAtom = (0, import_jotai5.atom)(null, (get, set, {
mode,
padding = 20,
duration = 300
}) => {
const viewportSize = get(viewportRectAtom);
if (!viewportSize || viewportSize.width <= 0 || viewportSize.height <= 0) return;
get(nodePositionUpdateCounterAtom);
let bounds;
if (mode === "graph") {
const graph = get(graphAtom);
const nodes = graph.nodes().map((node) => {
const attrs = graph.getNodeAttributes(node);
return {
x: attrs.x,
y: attrs.y,
width: attrs.width || 500,
height: attrs.height || 500
};
});
bounds = calculateBounds(nodes);
} else {
const selectedIds = get(selectedNodeIdsAtom);
const allNodes = get(uiNodesAtom);
const selectedNodes = allNodes.filter((n) => selectedIds.has(n.id)).map((n) => ({
x: n.position.x,
y: n.position.y,
width: n.width ?? 500,
height: n.height ?? 500
}));
bounds = calculateBounds(selectedNodes);
}
if (bounds.width <= 0 || bounds.height <= 0) return;
const safePadding = Math.max(0, Math.min(padding, viewportSize.width / 2 - 1, viewportSize.height / 2 - 1));
const effW = Math.max(1, viewportSize.width - 2 * safePadding);
const effH = Math.max(1, viewportSize.height - 2 * safePadding);
const scale = Math.min(effW / bounds.width, effH / bounds.height);
if (scale <= 0 || !isFinite(scale)) return;
const scaledW = bounds.width * scale;
const scaledH = bounds.height * scale;
const startX = safePadding + (effW - scaledW) / 2;
const startY = safePadding + (effH - scaledH) / 2;
const targetPan = {
x: startX - bounds.x * scale,
y: startY - bounds.y * scale
};
set(zoomAnimationTargetAtom, {
targetZoom: scale,
targetPan,
startZoom: get(zoomAtom),
startPan: {
...get(panAtom)
},
duration,
startTime: performance.now()
});
});
// src/core/group-store.ts
var import_jotai7 = require("jotai");
// src/core/history-store.ts
var import_jotai6 = require("jotai");
// src/core/history-actions.ts
function applyDelta(graph, delta) {
switch (delta.type) {
case "move-node": {
if (!graph.hasNode(delta.nodeId)) return false;
graph.setNodeAttribute(delta.nodeId, "x", delta.to.x);
graph.setNodeAttribute(delta.nodeId, "y", delta.to.y);
return false;
}
case "resize-node": {
if (!graph.hasNode(delta.nodeId)) return false;
graph.setNodeAttribute(delta.nodeId, "width", delta.to.width);
graph.setNodeAttribute(delta.nodeId, "height", delta.to.height);
return false;
}
case "add-node": {
if (graph.hasNode(delta.nodeId)) return false;
graph.addNode(delta.nodeId, delta.attributes);
return true;
}
case "remove-node": {
if (!graph.hasNode(delta.nodeId)) return false;
graph.dropNode(delta.nodeId);
return true;
}
case "add-edge": {
if (graph.hasEdge(delta.edgeId)) return false;
if (!graph.hasNode(delta.source) || !graph.hasNode(delta.target)) return false;
graph.addEdgeWithKey(delta.edgeId, delta.source, delta.target, delta.attributes);
return true;
}
case "remove-edge": {
if (!graph.hasEdge(delta.edgeId)) return false;
graph.dropEdge(delta.edgeId);
return true;
}
case "update-node-attr": {
if (!graph.hasNode(delta.nodeId)) return false;
graph.setNodeAttribute(delta.nodeId, delta.key, delta.to);
return false;
}
case "batch": {
let structuralChange = false;
for (const d of delta.deltas) {
if (applyDelta(graph, d)) structuralChange = true;
}
return structuralChange;
}
case "full-snapshot": {
graph.clear();
for (const node of delta.nodes) {
graph.addNode(node.id, node.attributes);
}
for (const edge of delta.edges) {
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge.attributes);
}
}
return true;
}
}
}
function invertDelta(delta) {
switch (delta.type) {
case "move-node":
return {
...delta,
from: delta.to,
to: delta.from
};
case "resize-node":
return {
...delta,
from: delta.to,
to: delta.from
};
case "add-node":
return {
type: "remove-node",
nodeId: delta.nodeId,
attributes: delta.attributes,
connectedEdges: []
};
case "remove-node": {
const batch = [{
type: "add-node",
nodeId: delta.nodeId,
attributes: delta.attributes
}, ...delta.connectedEdges.map((e) => ({
type: "add-edge",
edgeId: e.id,
source: e.source,
target: e.target,
attributes: e.attributes
}))];
return batch.length === 1 ? batch[0] : {
type: "batch",
deltas: batch
};
}
case "add-edge":
return {
type: "remove-edge",
edgeId: delta.edgeId,
source: delta.source,
target: delta.target,
attributes: delta.attributes
};
case "remove-edge":
return {
type: "add-edge",
edgeId: delta.edgeId,
source: delta.source,
target: delta.target,
attributes: delta.attributes
};
case "update-node-attr":
return {
...delta,
from: delta.to,
to: delta.from
};
case "batch":
return {
type: "batch",
deltas: delta.deltas.map(invertDelta).reverse()
};
case "full-snapshot":
return delta;
}
}
function createSnapshot(graph, label) {
const nodes = [];
const edges = [];
graph.forEachNode((nodeId, attributes) => {
nodes.push({
id: nodeId,
attributes: {
...attributes
}
});
});
graph.forEachEdge((edgeId, attributes, source, target) => {
edges.push({
id: edgeId,
source,
target,
attributes: {
...attributes
}
});
});
return {
timestamp: Date.now(),
label,
nodes,
edges
};
}
// src/core/history-store.ts
var debug4 = createDebug("history");
var MAX_HISTORY_SIZE = 50;
var historyStateAtom = (0, import_jotai6.atom)({
past: [],
future: [],
isApplying: false
});
var canUndoAtom = (0, import_jotai6.atom)((get) => {
const history = get(historyStateAtom);
return history.past.length > 0 && !history.isApplying;
});
var canRedoAtom = (0, import_jotai6.atom)((get) => {
const history = get(historyStateAtom);
return history.future.length > 0 && !history.isApplying;
});
var undoCountAtom = (0, import_jotai6.atom)((get) => get(historyStateAtom).past.length);
var redoCountAtom = (0, import_jotai6.atom)((get) => get(historyStateAtom).future.length);
var pushDeltaAtom = (0, import_jotai6.atom)(null, (get, set, delta) => {
const history = get(historyStateAtom);
if (history.isApplying) return;
const {
label,
...cleanDelta
} = delta;
const entry = {
forward: cleanDelta,
reverse: invertDelta(cleanDelta),
timestamp: Date.now(),
label
};
const newPast = [...history.past, entry];
if (newPast.length > MAX_HISTORY_SIZE) newPast.shift();
set(historyStateAtom, {
past: newPast,
future: [],
// Clear redo stack
isApplying: false
});
debug4("Pushed delta: %s (past: %d)", label || delta.type, newPast.length);
});
var pushHistoryAtom = (0, import_jotai6.atom)(null, (get, set, label) => {
const history = get(historyStateAtom);
if (history.isApplying) return;
const graph = get(graphAtom);
const snapshot = createSnapshot(graph, label);
const forward = {
type: "full-snapshot",
nodes: snapshot.nodes,
edges: snapshot.edges
};
const entry = {
forward,
reverse: forward,
// For full snapshots, reverse IS the current state
timestamp: Date.now(),
label
};
const newPast = [...history.past, entry];
if (newPast.length > MAX_HISTORY_SIZE) newPast.shift();
set(historyStateAtom, {
past: newPast,
future: [],
isApplying: false
});
debug4("Pushed snapshot: %s (past: %d)", label || "unnamed", newPast.length);
});
var undoAtom = (0, import_jotai6.atom)(null, (get, set) => {
const history = get(historyStateAtom);
if (history.past.length === 0 || history.isApplying) return false;
set(historyStateAtom, {
...history,
isApplying: true
});
try {
const graph = get(graphAtom);
const newPast = [...history.past];
const entry = newPast.pop();
let forwardForRedo = entry.forward;
if (entry.reverse.type === "full-snapshot") {
const currentSnapshot = createSnapshot(graph, "current");
forwardForRedo = {
type: "full-snapshot",
nodes: currentSnapshot.nodes,
edges: currentSnapshot.edges
};
}
const structuralChange = applyDelta(graph, entry.reverse);
if (structuralChange) {
set(graphAtom, graph);
set(graphUpdateVersionAtom, (v) => v + 1);
}
set(nodePositionUpdateCounterAtom, (c) => c + 1);
const redoEntry = {
forward: forwardForRedo,
reverse: entry.reverse,
timestamp: entry.timestamp,
label: entry.label
};
set(historyStateAtom, {
past: newPast,
future: [redoEntry, ...history.future],
isApplying: false
});
debug4("Undo: %s (past: %d, future: %d)", entry.label, newPast.length, history.future.length + 1);
return true;
} catch (error) {
debug4.error("Undo failed: %O", error);
set(historyStateAtom, {
...history,
isApplying: false
});
return false;
}
});
var redoAtom = (0, import_jotai6.atom)(null, (get, set) => {
const history = get(historyStateAtom);
if (history.future.length === 0 || history.isApplying) return false;
set(historyStateAtom, {
...history,
isApplying: true
});
try {
const graph = get(graphAtom);
const newFuture = [...history.future];
const entry = newFuture.shift();
let reverseForUndo = entry.reverse;
if (entry.forward.type === "full-snapshot") {
const currentSnapshot = createSnapshot(graph, "current");
reverseForUndo = {
type: "full-snapshot",
nodes: currentSnapshot.nodes,
edges: currentSnapshot.edges
};
}
const structuralChange = applyDelta(graph, entry.forward);
if (structuralChange) {
set(graphAtom, graph);
set(graphUpdateVersionAtom, (v) => v + 1);
}
set(nodePositionUpdateCounterAtom, (c) => c + 1);
const undoEntry = {
forward: entry.forward,
reverse: reverseForUndo,
timestamp: entry.timestamp,
label: entry.label
};
set(historyStateAtom, {
past: [...history.past, undoEntry],
future: newFuture,
isApplying: false
});
debug4("Redo: %s (past: %d, future: %d)", entry.label, history.past.length + 1, newFuture.length);
return true;
} catch (error) {
debug4.error("Redo failed: %O", error);
set(historyStateAtom, {
...history,
isApplying: false
});
return false;
}
});
var clearHistoryAtom = (0, import_jotai6.atom)(null, (_get, set) => {
set(historyStateAtom, {
past: [],
future: [],
isApplying: false
});
debug4("History cleared");
});
var historyLabelsAtom = (0, import_jotai6.atom)((get) => {
const history = get(historyStateAtom);
return {
past: history.past.map((e) => e.label || "Unnamed"),
future: history.future.map((e) => e.label || "Unnamed")
};
});
// src/core/group-store.ts
var collapsedGroupsAtom = (0, import_jotai7.atom)(/* @__PURE__ */ new Set());
var toggleGroupCollapseAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
const current = get(collapsedGroupsAtom);
const next = new Set(current);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
set(collapsedGroupsAtom, next);
});
var collapseGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
const current = get(collapsedGroupsAtom);
if (!current.has(groupId)) {
const next = new Set(current);
next.add(groupId);
set(collapsedGroupsAtom, next);
}
});
var expandGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
const current = get(collapsedGroupsAtom);
if (current.has(groupId)) {
const next = new Set(current);
next.delete(groupId);
set(collapsedGroupsAtom, next);
}
});
var nodeChildrenAtom = (0, import_jotai7.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
return (parentId) => {
const children = [];
graph.forEachNode((nodeId, attrs) => {
if (attrs.parentId === parentId) {
children.push(nodeId);
}
});
return children;
};
});
var nodeParentAtom = (0, import_jotai7.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
return (nodeId) => {
if (!graph.hasNode(nodeId)) return void 0;
return graph.getNodeAttribute(nodeId, "parentId");
};
});
var isGroupNodeAtom = (0, import_jotai7.atom)((get) => {
const getChildren = get(nodeChildrenAtom);
return (nodeId) => getChildren(nodeId).length > 0;
});
var groupChildCountAtom = (0, import_jotai7.atom)((get) => {
const getChildren = get(nodeChildrenAtom);
return (groupId) => getChildren(groupId).length;
});
var setNodeParentAtom = (0, import_jotai7.atom)(null, (get, set, {
nodeId,
parentId
}) => {
const graph = get(graphAtom);
if (!graph.hasNode(nodeId)) return;
if (parentId) {
if (parentId === nodeId) return;
let current = parentId;
while (current) {
if (current === nodeId) return;
if (!graph.hasNode(current)) break;
current = graph.getNodeAttribute(current, "parentId");
}
}
graph.setNodeAttribute(nodeId, "parentId", parentId);
set(graphUpdateVersionAtom, (v) => v + 1);
});
var moveNodesToGroupAtom = (0, import_jotai7.atom)(null, (get, set, {
nodeIds,
groupId
}) => {
for (const nodeId of nodeIds) {
set(setNodeParentAtom, {
nodeId,
parentId: groupId
});
}
});
var removeFromGroupAtom = (0, import_jotai7.atom)(null, (get, set, nodeId) => {
set(setNodeParentAtom, {
nodeId,
parentId: void 0
});
});
var groupSelectedNodesAtom = (0, import_jotai7.atom)(null, (get, set, {
nodeIds,
groupNodeId
}) => {
set(pushHistoryAtom, `Group ${nodeIds.length} nodes`);
const graph = get(graphAtom);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const nodeId of nodeIds) {
if (!graph.hasNode(nodeId)) continue;
const attrs = graph.getNodeAttributes(nodeId);
minX = Math.min(minX, attrs.x);
minY = Math.min(minY, attrs.y);
maxX = Math.max(maxX, attrs.x + (attrs.width || 200));
maxY = Math.max(maxY, attrs.y + (attrs.height || 100));
}
const padding = 20;
if (graph.hasNode(groupNodeId)) {
graph.setNodeAttribute(groupNodeId, "x", minX - padding);
graph.setNodeAttribute(groupNodeId, "y", minY - padding - 30);
graph.setNodeAttribute(groupNodeId, "width", maxX - minX + 2 * padding);
graph.setNodeAttribute(groupNodeId, "height", maxY - minY + 2 * padding + 30);
}
for (const nodeId of nodeIds) {
if (nodeId !== groupNodeId && graph.hasNode(nodeId)) {
graph.setNodeAttribute(nodeId, "parentId", groupNodeId);
}
}
set(graphUpdateVersionAtom, (v) => v + 1);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
});
var ungroupNodesAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
set(pushHistoryAtom, "Ungroup nodes");
const graph = get(graphAtom);
graph.forEachNode((nodeId, attrs) => {
if (attrs.parentId === groupId) {
graph.setNodeAttribute(nodeId, "parentId", void 0);
}
});
set(graphUpdateVersionAtom, (v) => v + 1);
});
var nestNodesOnDropAtom = (0, import_jotai7.atom)(null, (get, set, {
nodeIds,
targetId
}) => {
set(pushHistoryAtom, "Nest nodes");
for (const nodeId of nodeIds) {
if (nodeId === targetId) continue;
set(setNodeParentAtom, {
nodeId,
parentId: targetId
});
}
set(autoResizeGroupAtom, targetId);
});
var collapsedEdgeRemapAtom = (0, import_jotai7.atom)((get) => {
const collapsed = get(collapsedGroupsAtom);
if (collapsed.size === 0) return /* @__PURE__ */ new Map();
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
const remap = /* @__PURE__ */ new Map();
for (const nodeId of graph.nodes()) {
let current = nodeId;
let outermost = null;
while (true) {
if (!graph.hasNode(current)) break;
const parent = graph.getNodeAttribute(current, "parentId");
if (!parent) break;
if (collapsed.has(parent)) outermost = parent;
current = parent;
}
if (outermost) remap.set(nodeId, outermost);
}
return remap;
});
var autoResizeGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
const graph = get(graphAtom);
if (!graph.hasNode(groupId)) return;
const children = [];
graph.forEachNode((nodeId, attrs) => {
if (attrs.parentId === groupId) {
children.push(nodeId);
}
});
if (children.length === 0) return;
const padding = 20;
const headerHeight = 30;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const childId of children) {
const attrs = graph.getNodeAttributes(childId);
minX = Math.min(minX, attrs.x);
minY = Math.min(minY, attrs.y);
maxX = Math.max(maxX, attrs.x + (attrs.width || 200));
maxY = Math.max(maxY, attrs.y + (attrs.height || 100));
}
graph.setNodeAttribute(groupId, "x", minX - padding);
graph.setNodeAttribute(groupId, "y", minY - padding - headerHeight);
graph.setNodeAttribute(groupId, "width", maxX - minX + 2 * padding);
graph.setNodeAttribute(groupId, "height", maxY - minY + 2 * padding + headerHeight);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
});
// src/core/graph-derived.ts
var highestZIndexAtom = (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
let maxZ = 0;
graph.forEachNode((_node, attributes) => {
if (attributes.zIndex > maxZ) {
maxZ = attributes.zIndex;
}
});
return maxZ;
});
var _prevUiNodesByGraph = /* @__PURE__ */ new WeakMap();
var uiNodesAtom = (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
const currentDraggingId = get(draggingNodeIdAtom);
const collapsed = get(collapsedGroupsAtom);
const nodes = [];
graph.forEachNode((nodeId, attributes) => {
if (collapsed.size > 0) {
let current = nodeId;
let hidden = false;
while (true) {
if (!graph.hasNode(current)) break;
const pid = graph.getNodeAttributes(current).parentId;
if (!pid) break;
if (collapsed.has(pid)) {
hidden = true;
break;
}
current = pid;
}
if (hidden) return;
}
const position = get(nodePositionAtomFamily(nodeId));
nodes.push({
...attributes,
id: nodeId,
position,
isDragging: nodeId === currentDraggingId
});
});
const prev = _prevUiNodesByGraph.get(graph) ?? [];
if (nodes.length === prev.length && nodes.every((n, i) => n.id === prev[i].id && n.position === prev[i].position && n.isDragging === prev[i].isDragging)) {
return prev;
}
_prevUiNodesByGraph.set(graph, nodes);
return nodes;
});
var nodeKeysAtom = (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
return graph.nodes();
});
var nodeFamilyAtom = (0, import_jotai_family2.atomFamily)((nodeId) => (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
if (!graph.hasNode(nodeId)) {
return null;
}
const attributes = graph.getNodeAttributes(nodeId);
const position = get(nodePositionAtomFamily(nodeId));
const currentDraggingId = get(draggingNodeIdAtom);
return {
...attributes,
id: nodeId,
position,
isDragging: nodeId === currentDraggingId
};
}), (a, b) => a === b);
var edgeKeysAtom = (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
return graph.edges();
});
var edgeKeysWithTempEdgeAtom = (0, import_jotai8.atom)((get) => {
const keys = get(edgeKeysAtom);
const edgeCreation = get(edgeCreationAtom);
if (edgeCreation.isCreating) {
return [...keys, "temp-creating-edge"];
}
return keys;
});
var _edgeCacheByGraph = /* @__PURE__ */ new WeakMap();
function getEdgeCache(graph) {
let cache = _edgeCacheByGraph.get(graph);
if (!cache) {
cache = /* @__PURE__ */ new Map();
_edgeCacheByGraph.set(graph, cache);
}
return cache;
}
var edgeFamilyAtom = (0, import_jotai_family2.atomFamily)((key) => (0, import_jotai8.atom)((get) => {
get(graphUpdateVersionAtom);
if (key === "temp-creating-edge") {
const edgeCreationState = get(edgeCreationAtom);
const graph2 = get(graphAtom);
if (edgeCreationState.isCreating && edgeCreationState.sourceNodeId && edgeCreationState.targetPosition) {
const sourceNodeAttrs = graph2.getNodeAttributes(edgeCreationState.sourceNodeId);
const sourceNodePosition = get(nodePositionAtomFamily(edgeCreationState.sourceNodeId));
const pan = get(panAtom);
const zoom = get(zoomAtom);
const viewportRect = get(viewportRectAtom);
if (sourceNodeAttrs && viewportRect) {
const mouseX = edgeCreationState.targetPosition.x - viewportRect.left;
const mouseY = edgeCreationState.targetPosition.y - viewportRect.top;
const worldTargetX = (mouseX - pan.x) / zoom;
const worldTargetY = (mouseY - pan.y) / zoom;
const tempEdge = {
key: "temp-creating-edge",
sourceId: edgeCreationState.sourceNodeId,
targetId: "temp-cursor",
sourcePosition: sourceNodePosition,
targetPosition: {
x: worldTargetX,
y: worldTargetY
},
sourceNodeSize: sourceNodeAttrs.size,
sourceNodeWidth: sourceNodeAttrs.width,
sourceNodeHeight: sourceNodeAttrs.height,
targetNodeSize: 0,
targetNodeWidth: 0,
targetNodeHeight: 0,
type: "dashed",
color: "#FF9800",
weight: 2,
label: void 0,
dbData: {
id: "temp-creating-edge",
graph_id: get(currentGraphIdAtom) || "",
source_node_id: edgeCreationState.sourceNodeId,
target_node_id: "temp-cursor",
edge_type: "temp",
filter_condition: null,
ui_properties: null,
data: null,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
updated_at: (/* @__PURE__ */ new Date()).toISOString()
}
};
return tempEdge;
}
}
return null;
}
const graph = get(graphAtom);
if (!graph.hasEdge(key)) {
getEdgeCache(graph).delete(key);
return null;
}
const sourceId = graph.source(key);
const targetId = graph.target(key);
const attributes = graph.getEdgeAttributes(key);
const remap = get(collapsedEdgeRemapAtom);
const effectiveSourceId = remap.get(sourceId) ?? sourceId;
const effectiveTargetId = remap.get(targetId) ?? targetId;
if (!graph.hasNode(effectiveSourceId) || !graph.hasNode(effectiveTargetId)) {
getEdgeCache(graph).delete(key);
return null;
}
const sourceAttributes = graph.getNodeAttributes(effectiveSourceId);
const targetAttributes = graph.getNodeAttributes(effectiveTargetId);
const sourcePosition = get(nodePositionAtomFamily(effectiveSourceId));
const targetPosition = get(nodePositionAtomFamily(effectiveTargetId));
if (sourceAttributes && targetAttributes) {
const next = {
...attributes,
key,
sourceId: effectiveSourceId,
targetId: effectiveTargetId,
sourcePosition,
targetPosition,
sourceNodeSize: sourceAttributes.size,
targetNodeSize: targetAttributes.size,
sourceNodeWidth: sourceAttributes.width ?? sourceAttributes.size,
sourceNodeHeight: sourceAttributes.height ?? sourceAttributes.size,
targetNodeWidth: targetAttributes.width ?? targetAttributes.size,
targetNodeHeight: targetAttributes.height ?? targetAttributes.size
};
const edgeCache = getEdgeCache(graph);
const prev = edgeCache.get(key);
if (prev && prev.sourcePosition === next.sourcePosition && prev.targetPosition === next.targetPosition && prev.sourceId === next.sourceId && prev.targetId === next.targetId && prev.type === next.type && prev.color === next.color && prev.weight === next.weight && prev.label === next.label && prev.sourceNodeSize === next.sourceNodeSize && prev.targetNodeSize === next.targetNodeSize && prev.sourceNodeWidth === next.sourceNodeWidth && prev.sourceNodeHeight === next.sourceNodeHeight && prev.targetNodeWidth === next.targetNodeWidth && prev.targetNodeHeight === next.targetNodeHeight) {
return prev;
}
edgeCache.set(key, next);
return next;
}
getEdgeCache(graph).delete(key);
return null;
}), (a, b) => a === b);
// src/core/graph-mutations.ts
var import_jotai12 = require("jotai");
var import_graphology3 = __toESM(require("graphology"));
// src/core/graph-mutations-edges.ts
var import_jotai10 = require("jotai");
// src/core/reduced-motion-store.ts
var import_jotai9 = require("jotai");
var prefersReducedMotionAtom = (0, import_jotai9.atom)(typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false);
var watchReducedMotionAtom = (0, import_jotai9.atom)(null, (_get, set) => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e) => {
set(prefersReducedMotionAtom, e.matches);
};
set(prefersReducedMotionAtom, mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
});
// src/core/graph-mutations-edges.ts
var debug5 = createDebug("graph:mutations:edges");
var addEdgeToLocalGraphAtom = (0, import_jotai10.atom)(null, (get, set, newEdge) => {
const graph = get(graphAtom);
if (graph.hasNode(newEdge.source_node_id) && graph.hasNode(newEdge.target_node_id)) {
const uiProps = newEdge.ui_properties || {};
const attributes = {
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
label: newEdge.edge_type ?? void 0,
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
dbData: newEdge
};
if (!graph.hasEdge(newEdge.id)) {
try {
debug5("Adding edge %s to local graph", newEdge.id);
graph.addEdgeWithKey(newEdge.id, newEdge.source_node_id, newEdge.target_node_id, attributes);
set(graphAtom, graph.copy());
set(graphUpdateVersionAtom, (v) => v + 1);
} catch (e) {
debug5("Failed to add edge %s: %o", newEdge.id, e);
}
}
}
});
var removeEdgeFromLocalGraphAtom = (0, import_jotai10.atom)(null, (get, set, edgeId) => {
const graph = get(graphAtom);
if (graph.hasEdge(edgeId)) {
graph.dropEdge(edgeId);
set(graphAtom, graph.copy());
set(graphUpdateVersionAtom, (v) => v + 1);
}
});
var swapEdgeAtomicAtom = (0, import_jotai10.atom)(null, (get, set, {
tempEdgeId,
newEdge
}) => {
const graph = get(graphAtom);
if (graph.hasEdge(tempEdgeId)) {
graph.dropEdge(tempEdgeId);
}
if (graph.hasNode(newEdge.source_node_id) && graph.hasNode(newEdge.target_node_id)) {
const uiProps = newEdge.ui_properties || {};
const attributes = {
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
label: newEdge.edge_type ?? void 0,
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
dbData: newEdge
};
if (!graph.hasEdge(newEdge.id)) {
try {
debug5("Atomically swapping temp edge %s with real edge %s", tempEdgeId, newEdge.id);
graph.addEdgeWithKey(newEdge.id, newEdge.source_node_id, newEdge.target_node_id, attributes);
} catch (e) {
debug5("Failed to add edge %s: %o", newEdge.id, e);
}
}
}
set(graphAtom, graph.copy());
set(graphUpdateVersionAtom, (v) => v + 1);
});
var departingEdgesAtom = (0, import_jotai10.atom)(/* @__PURE__ */ new Map());
var EDGE_ANIMATION_DURATION = 300;
var removeEdgeWithAnimationAtom = (0, import_jotai10.atom)(null, (get, set, edgeKey) => {
const edgeState = get(edgeFamilyAtom(edgeKey));
if (edgeState) {
const departing = new Map(get(departingEdgesAtom));
departing.set(edgeKey, edgeState);
set(departingEdgesAtom, departing);
set(removeEdgeFromLocalGraphAtom, edgeKey);
const duration = get(prefersReducedMotionAtom) ? 0 : EDGE_ANIMATION_DURATION;
setTimeout(() => {
const current = new Map(get(departingEdgesAtom));
current.delete(edgeKey);
set(departingEdgesAtom, current);
}, duration);
}
});
var editingEdgeLabelAtom = (0, import_jotai10.atom)(null);
var updateEdgeLabelAtom = (0, import_jotai10.atom)(null, (get, set, {
edgeKey,
label
}) => {
const graph = get(graphAtom);
if (graph.hasEdge(edgeKey)) {
graph.setEdgeAttribute(edgeKey, "label", label || void 0);
set(graphUpdateVersionAtom, (v) => v + 1);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
}
});
// src/core/graph-mutations-advanced.ts
var import_jotai11 = require("jotai");
var debug6 = createDebug("graph:mutations:advanced");
var dropTargetNodeIdAtom = (0, import_jotai11.atom)(null);
var splitNodeAtom = (0, import_jotai11.atom)(null, (get, set, {
nodeId,
position1,
position2
}) => {
const graph = get(graphAtom);
if (!graph.hasNode(nodeId)) return;
const attrs = graph.getNodeAttributes(nodeId);
const graphId = get(currentGraphIdAtom) || attrs.dbData.graph_id;
set(pushHistoryAtom, "Split node");
graph.setNodeAttribute(nodeId, "x", position1.x);
graph.setNodeAttribute(nodeId, "y", position1.y);
const edges = [];
graph.forEachEdge(nodeId, (_key, eAttrs, source, target) => {
edges.push({
source,
target,
attrs: eAttrs
});
});
const cloneId = `split-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const cloneDbNode = {
...attrs.dbData,
id: cloneId,
graph_id: graphId,
ui_properties: {
...attrs.dbData.ui_properties || {},
x: position2.x,
y: position2.y
},
created_at: (/* @__PURE__ */ new Date()).toISOString(),
updated_at: (/* @__PURE__ */ new Date()).toISOString()
};
set(addNodeToLocalGraphAtom, cloneDbNode);
for (const edge of edges) {
const newSource = edge.source === nodeId ? cloneId : edge.source;
const newTarget = edge.target === nodeId ? cloneId : edge.target;
set(addEdgeToLocalGraphAtom, {
...edge.attrs.dbData,
id: `split-e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
source_node_id: newSource,
target_node_id: newTarget
});
}
set(graphUpdateVersionAtom, (v) => v + 1);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
debug6("Split node %s \u2192 clone %s", nodeId, cloneId);
});
var mergeNodesAtom = (0, import_jotai11.atom)(null, (get, set, {
nodeIds
}) => {
if (nodeIds.length < 2) return;
const graph = get(graphAtom);
const [survivorId, ...doomed] = nodeIds;
if (!graph.hasNode(survivorId)) return;
set(pushHistoryAtom, `Merge ${nodeIds.length} nodes`);
const doomedSet = new Set(doomed);
for (const doomedId of doomed) {
if (!graph.hasNode(doomedId)) continue;
const edges = [];
graph.forEachEdge(doomedId, (_key, eAttrs, source, target) => {
edges.push({
source,
target,
attrs: eAttrs
});
});
for (const edge of edges) {
const newSource = doomedSet.has(edge.source) ? survivorId : edge.source;
const newTarget = doomedSet.has(edge.target) ? survivorId : edge.target;
if (newSource === newTarget) continue;
set(addEdgeToLocalGraphAtom, {
...edge.attrs.dbData,
id: `merge-e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
source_node_id: newSource,
target_node_id: newTarget
});
}
set(optimisticDeleteNodeAtom, {
nodeId: doomedId
});
}
set(graphUpdateVersionAtom, (v) => v + 1);
debug6("Merged nodes %o \u2192 survivor %s", nodeIds, survivorId);
});
// src/core/graph-mutations.ts
var debug7 = createDebug("graph:mutations");
var startNodeDragAtom = (0, import_jotai12.atom)(null, (get, set, {
nodeId
}) => {
const graph = get(graphAtom);
if (!graph.hasNode(nodeId)) return;
const currentAttributes = graph.getNodeAttributes(nodeId);
set(preDragNodeAttributesAtom, JSON.parse(JSON.stringify(currentAttributes)));
const currentHighestZIndex = get(highestZIndexAtom);
const newZIndex = currentHighestZIndex + 1;
graph.setNodeAttribute(nodeId, "zIndex", newZIndex);
set(draggingNodeIdAtom, nodeId);
});
var endNodeDragAtom = (0, import_jotai12.atom)(null, (get, set, _payload) => {
const currentDraggingId = get(draggingNodeIdAtom);
if (currentDraggingId) {
debug7("Node %s drag ended", currentDraggingId);
const graph = get(graphAtom);
if (graph.hasNode(currentDraggingId)) {
const parentId = graph.getNodeAttribute(currentDraggingId, "parentId");
if (parentId) {
set(autoResizeGroupAtom, parentId);
}
}
}
set(draggingNodeIdAtom, null);
set(preDragNodeAttributesAtom, null);
});
var optimisticDeleteNodeAtom = (0, import_jotai12.atom)(null, (get, set, {
nodeId
}) => {
const graph = get(graphAtom);
if (graph.hasNode(nodeId)) {
graph.dropNode(nodeId);
set(cleanupNodePositionAtom, nodeId);
set(graphAtom, graph.copy());
debug7("Optimistically deleted node %s", nodeId);
}
});
var optimisticDeleteEdgeAtom = (0, import_jotai12.atom)(null, (get, set, {
edgeKey
}) => {
const graph = get(graphAtom);
if (graph.hasEdge(edgeKey)) {
graph.dropEdge(edgeKey);
set(graphAtom, graph.copy());
debug7("Optimistically deleted edge %s", edgeKey);
}
});
var addNodeToLocalGraphAtom = (0, import_jotai12.atom)(null, (get, set, newNode) => {
const graph = get(graphAtom);
if (graph.hasNode(newNode.id)) {
debug7("Node %s already exists, skipping", newNode.id);
return;
}
const uiProps = newNode.ui_properties || {};
const attributes = {
x: typeof uiProps.x === "number" ? uiProps.x : Math.random() * 800,
y: typeof uiProps.y === "number" ? uiProps.y : Math.random() * 600,
size: typeof uiProps.size === "number" ? uiProps.size : 15,
width: typeof uiProps.width === "number" ? uiProps.width : 500,
height: typeof uiProps.height === "number" ? uiProps.height : 500,
color: typeof uiProps.color === "string" ? uiProps.color : "#ccc",
label: newNode.label || newNode.node_type || newNode.id,
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : 0,
dbData: newNode
};
debug7("Adding node %s to local graph at (%d, %d)", newNode.id, attributes.x, attributes.y);
graph.addNode(newNode.id, attributes);
set(graphAtom, graph.copy());
set(graphUpdateVersionAtom, (v) => v + 1);
set(nodePositionUpdateCounterAtom, (c) => c + 1);
});
var loadGraphFromDbAtom = (0, import_jotai12.atom)(null, (get, set, fetchedNodes, fetchedEdges) => {
debug7("========== START SYNC ==========");
debug7("Fetched nodes: %d, edges: %d", fetchedNodes.length, fetchedEdges.length);
const currentGraphId = get(currentGraphIdAtom);
if (fetchedNodes.length > 0 && fetchedNodes[0].graph_id !== currentGraphId) {
debug7("Skipping sync - data belongs to different graph");
return;
}
const existingGraph = get(graphAtom);
const isDragging = get(draggingNodeIdAtom) !== null;
if (isDragging) {
debug7("Skipping sync - drag in progress");
return;
}
const existingNodeIds = new Set(existingGraph.nodes());
const fetchedNodeIds = new Set(fetchedNodes.map((n) => n.id));
const hasAnyCommonNodes = Array.from(existingNodeIds).some((id) => fetchedNodeIds.has(id));
let graph;
if (hasAnyCommonNodes && existingNodeIds.size > 0) {
debug7("Merging DB data into existing graph");
graph = existingGraph.copy();
} else {
debug7("Creating fresh graph (graph switch detected)");
graph = new import_graphology3.default(graphOptions);
}
const fetchedEdgeIds = new Set(fetchedEdges.map((e) => e.id));
if (hasAnyCommonNodes && existingNodeIds.size > 0) {
graph.forEachNode((nodeId) => {
if (!fetchedNodeIds.has(nodeId)) {
debug7("Removing deleted node: %s", nodeId);
graph.dropNode(nodeId);
nodePositionAtomFamily.remove(nodeId);
}
});
}
fetchedNodes.forEach((node) => {
const uiProps = node.ui_properties || {};
const newX = typeof uiProps.x === "number" ? uiProps.x : Math.random() * 800;
const newY = typeof uiProps.y === "number" ? uiProps.y : Math.random() * 600;
if (graph.hasNode(node.id)) {
const currentAttrs = graph.getNodeAttributes(node.id);
const attributes = {
x: newX,
y: newY,
size: typeof uiProps.size === "number" ? uiProps.size : currentAttrs.size,
width: typeof uiProps.width === "number" ? uiProps.width : currentAttrs.width ?? 500,
height: typeof uiProps.height === "number" ? uiProps.height : currentAttrs.height ?? 500,
color: typeof uiProps.color === "string" ? uiProps.color : currentAttrs.color,
label: node.label || node.node_type || node.id,
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : currentAttrs.zIndex,
dbData: node
};
graph.replaceNodeAttributes(node.id, attributes);
} else {
const attributes = {
x: newX,
y: newY,
size: typeof uiProps.size === "number" ? uiProps.size : 15,
width: typeof uiProps.width === "number" ? uiProps.width : 500,
height: typeof uiProps.height === "number" ? uiProps.height : 500,
color: typeof uiProps.color === "string" ? uiProps.color : "#ccc",
label: node.label || node.node_type || node.id,
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : 0,
dbData: node
};
graph.addNode(node.id, attributes);
}
});
graph.forEachEdge((edgeId) => {
if (!fetchedEdgeIds.has(edgeId)) {
debug7("Removing deleted edge: %s", edgeId);
graph.dropEdge(edgeId);
}
});
fetchedEdges.forEach((edge) => {
if (graph.hasNode(edge.source_node_id) && graph.hasNode(edge.target_node_id)) {
const uiProps = edge.ui_properties || {};
const attributes = {
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
label: edge.edge_type ?? void 0,
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
dbData: edge
};
if (graph.hasEdge(edge.id)) {
graph.replaceEdgeAttributes(edge.id, attributes);
} else {
try {
graph.addEdgeWithKey(edge.id, edge.source_node_id, edge.target_node_id, attributes);
} catch (e) {
debug7("Failed to add edge %s: %o", edge.id, e);
}
}
}
});
set(graphAtom, graph);
set(graphUpdateVersionAtom, (v) => v + 1);
debug7("========== SYNC COMPLETE ==========");
debug7("Final graph: %d nodes, %d edges", graph.order, graph.size);
});
// src/core/sync-store.ts
var import_jotai13 = require("jotai");
var debug8 = createDebug("sync");
var syncStatusAtom = (0, import_jotai13.atom)("synced");
var pendingMutationsCountAtom = (0, import_jotai13.atom)(0);
var isOnlineAtom = (0, import_jotai13.atom)(typeof navigator !== "undefined" ? navigator.onLine : true);
var lastSyncErrorAtom = (0, import_jotai13.atom)(null);
var lastSyncTimeAtom = (0, import_jotai13.atom)(Date.now());
var mutationQueueAtom = (0, import_jotai13.atom)([]);
var syncStateAtom = (0, import_jotai13.atom)((get) => ({
status: get(syncStatusAtom),
pendingMutations: get(pendingMutationsCountAtom),
lastError: get(lastSyncErrorAtom),
lastSyncTime: get(lastSyncTimeAtom),
isOnline: get(isOnlineAtom),
queuedMutations: get(mutationQueueAtom).length
}));
var startMutationAtom = (0, import_jotai13.atom)(null, (get, set) => {
const currentCount = get(pendingMutationsCountAtom);
const newCount = currentCount + 1;
set(pendingMutationsCountAtom, newCount);
debug8("Mutation started. Pending count: %d -> %d", currentCount, newCount);
if (newCount > 0 && get(syncStatusAtom) !== "syncing") {
set(syncStatusAtom, "syncing");
debug8("Status -> syncing");
}
});
var completeMutationAtom = (0, import_jotai13.atom)(null, (get, set, success = true) => {
const currentCount = get(pendingMutationsCountAtom);
const newCount = Math.max(0, currentCount - 1);
set(pendingMutationsCountAtom, newCount);
debug8("Mutation completed (success: %s). Pending count: %d -> %d", success, currentCount, newCount);
if (success) {
set(lastSyncTimeAtom, Date.now());
if (newCount === 0) {
set(lastSyncErrorAtom, null);
}
}
if (newCount === 0) {
const isOnline = get(isOnlineAtom);
const hasError = get(lastSyncErrorAtom) !== null;
if (hasError) {
set(syncStatusAtom, "error");
debug8("Status -> error");
} else if (!isOnline) {
set(syncStatusAtom, "offline");
debug8("Status -> offline");
} else {
set(syncStatusAtom, "synced");
debug8("Status -> synced");
}
}
});
var trackMutationErrorAtom = (0, import_jotai13.atom)(null, (_get, set, error) => {
set(lastSyncErrorAtom, error);
debug8("Mutation failed: %s", error);
});
var setOnlineStatusAtom = (0, import_jotai13.atom)(null, (get, set, isOnline) => {
set(isOnlineAtom, isOnline);
const pendingCount = get(pendingMutationsCountAtom);
const hasError = get(lastSyncErrorAtom) !== null;
const queueLength = get(mutationQueueAtom).length;
if (pendingCount === 0) {
if (hasError || queueLength > 0) {
set(syncStatusAtom, "error");
} else {
set(syncStatusAtom, isOnline ? "synced" : "offline");
}
}
});
var queueMutationAtom = (0, import_jotai13.atom)(null, (get, set, mutation) => {
const queue = get(mutationQueueAtom);
const newMutation = {
...mutation,
id: crypto.randomUUID(),
timestamp: Date.now(),
retryCount: 0,
maxRetries: mutation.maxRetries ?? 3
};
const newQueue = [...queue, newMutation];
set(mutationQueueAtom, newQueue);
debug8("Queued mutation: %s. Queue size: %d", mutation.type, newQueue.length);
if (get(pendingMutationsCountAtom) === 0) {
set(syncStatusAtom, "error");
}
return newMutation.id;
});
var dequeueMutationAtom = (0, import_jotai13.atom)(null, (get, set, mutationId) => {
const queue = get(mutationQueueAtom);
const newQueue = queue.filter((m) => m.id !== mutationId);
set(mutationQueueAtom, newQueue);
debug8("Dequeued mutation: %s. Queue size: %d", mutationId, newQueue.length);
if (newQueue.length === 0 && get(pendingMutationsCountAtom) === 0 && get(lastSyncErrorAtom) === null) {
set(syncStatusAtom, get(isOnlineAtom) ? "synced" : "offline");
}
});
var incrementRetryCountAtom = (0, import_jotai13.atom)(null, (get, set, mutationId) => {
const queue = get(mutationQueueAtom);
const newQueue = queue.map((m) => m.id === mutationId ? {
...m,
retryCount: m.retryCount + 1
} : m);
set(mutationQueueAtom, newQueue);
});
var getNextQueuedMutationAtom = (0, import_jotai13.atom)((get) => {
const queue = get(mutationQueueAtom);
return queue.find((m) => m.retryCount < m.maxRetries) ?? null;
});
var clearMutationQueueAtom = (0, import_jotai13.atom)(null, (get, set) => {
set(mutationQueueAtom, []);
debug8("Cleared mutation queue");
if (get(pendingMutationsCountAtom) === 0 && get(lastSyncErrorAtom) === null) {
set(syncStatusAtom, get(isOnlineAtom) ? "synced" : "offline");
}
});
// src/core/interaction-store.ts
var import_jotai14 = require("jotai");
var inputModeAtom = (0, import_jotai14.atom)({
type: "normal"
});
var keyboardInteractionModeAtom = (0, import_jotai14.atom)("navigate");
var interactionFeedbackAtom = (0, import_jotai14.atom)(null);
var pendingInputResolverAtom = (0, import_jotai14.atom)(null);
var resetInputModeAtom = (0, import_jotai14.atom)(null, (_get, set) => {
set(inputModeAtom, {
type: "normal"
});
set(interactionFeedbackAtom, null);
set(pendingInputResolverAtom, null);
});
var resetKeyboardInteractionModeAtom = (0, import_jotai14.atom)(null, (_get, set) => {
set(keyboardInteractionModeAtom, "navigate");
});
var setKeyboardInteractionModeAtom = (0, import_jotai14.atom)(null, (_get, set, mode) => {
set(keyboardInteractionModeAtom, mode);
});
var startPickNodeAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
set(inputModeAtom, {
type: "pickNode",
...options
});
});
var startPickNodesAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
set(inputModeAtom, {
type: "pickNodes",
...options
});
});
var startPickPointAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
set(inputModeAtom, {
type: "pickPoint",
...options
});
});
var provideInputAtom = (0, import_jotai14.atom)(null, (get, set, value) => {
set(pendingInputResolverAtom, value);
});
var updateInteractionFeedbackAtom = (0, import_jotai14.atom)(null, (get, set, feedback) => {
const current = get(interactionFeedbackAtom);
set(interactionFeedbackAtom, {
...current,
...feedback
});
});
var isPickingModeAtom = (0, import_jotai14.atom)((get) => {
const mode = get(inputModeAtom);
return mode.type !== "normal";
});
var isPickNodeModeAtom = (0, import_jotai14.atom)((get) => {
const mode = get(inputModeAtom);
return mode.type === "pickNode" || mode.type === "pickNodes";
});
// src/core/locked-node-store.ts
var import_jotai15 = require("jotai");
var lockedNodeIdAtom = (0, import_jotai15.atom)(null);
var lockedNodeDataAtom = (0, import_jotai15.atom)((get) => {
const id = get(lockedNodeIdAtom);
if (!id) return null;
const nodes = get(uiNodesAtom);
return nodes.find((n) => n.id === id) || null;
});
var lockedNodePageIndexAtom = (0, import_jotai15.atom)(0);
var lockedNodePageCountAtom = (0, import_jotai15.atom)(1);
var lockNodeAtom = (0, import_jotai15.atom)(null, (_get, set, payload) => {
set(lockedNodeIdAtom, payload.nodeId);
set(lockedNodePageIndexAtom, 0);
});
var unlockNodeAtom = (0, import_jotai15.atom)(null, (_get, set) => {
set(lockedNodeIdAtom, null);
});
var nextLockedPageAtom = (0, import_jotai15.atom)(null, (get, set) => {
const current = get(lockedNodePageIndexAtom);
const pageCount = get(lockedNodePageCountAtom);
set(lockedNodePageIndexAtom, (current + 1) % pageCount);
});
var prevLockedPageAtom = (0, import_jotai15.atom)(null, (get, set) => {
const current = get(lockedNodePageIndexAtom);
const pageCount = get(lockedNodePageCountAtom);
set(lockedNodePageIndexAtom, (current - 1 + pageCount) % pageCount);
});
var goToLockedPageAtom = (0, import_jotai15.atom)(null, (get, set, index) => {
const pageCount = get(lockedNodePageCountAtom);
if (index >= 0 && index < pageCount) {
set(lockedNodePageIndexAtom, index);
}
});
var hasLockedNodeAtom = (0, import_jotai15.atom)((get) => get(lockedNodeIdAtom) !== null);
// src/core/node-type-registry.tsx
var import_compiler_runtime = require("react/compiler-runtime");
var import_react = __toESM(require("react"));
var import_jsx_runtime = require("react/jsx-runtime");
// src/core/toast-store.ts
var import_jotai16 = require("jotai");
var canvasToastAtom = (0, import_jotai16.atom)(null);
var showToastAtom = (0, import_jotai16.atom)(null, (_get, set, message) => {
const id = `toast-${Date.now()}`;
set(canvasToastAtom, {
id,
message,
timestamp: Date.now()
});
setTimeout(() => {
set(canvasToastAtom, (current) => current?.id === id ? null : current);
}, 2e3);
});
// src/core/snap-store.ts
var import_jotai17 = require("jotai");
var snapEnabledAtom = (0, import_jotai17.atom)(false);
var snapGridSizeAtom = (0, import_jotai17.atom)(20);
var snapTemporaryDisableAtom = (0, import_jotai17.atom)(false);
var isSnappingActiveAtom = (0, import_jotai17.atom)((get) => {
return get(snapEnabledAtom) && !get(snapTemporaryDisableAtom);
});
var toggleSnapAtom = (0, import_jotai17.atom)(null, (get, set) => {
set(snapEnabledAtom, !get(snapEnabledAtom));
});
var setGridSizeAtom = (0, import_jotai17.atom)(null, (_get, set, size) => {
set(snapGridSizeAtom, Math.max(5, Math.min(200, size)));
});
var snapAlignmentEnabledAtom = (0, import_jotai17.atom)(true);
var toggleAlignmentGuidesAtom = (0, import_jotai17.atom)(null, (get, set) => {
set(snapAlignmentEnabledAtom, !get(snapAlignmentEnabledAtom));
});
var alignmentGuidesAtom = (0, import_jotai17.atom)({
verticalGuides: [],
horizontalGuides: []
});
var clearAlignmentGuidesAtom = (0, import_jotai17.atom)(null, (_get, set) => {
set(alignmentGuidesAtom, {
verticalGuides: [],
horizontalGuides: []
});
});
// src/core/event-types.ts
var CanvasEventType = /* @__PURE__ */ (function(CanvasEventType2) {
CanvasEventType2["NodeClick"] = "node:click";
CanvasEventType2["NodeDoubleClick"] = "node:double-click";
CanvasEventType2["NodeTripleClick"] = "node:triple-click";
CanvasEventType2["NodeRightClick"] = "node:right-click";
CanvasEventType2["NodeLongPress"] = "node:long-press";
CanvasEventType2["EdgeClick"] = "edge:click";
CanvasEventType2["EdgeDoubleClick"] = "edge:double-click";
CanvasEventType2["EdgeRightClick"] = "edge:right-click";
CanvasEventType2["BackgroundClick"] = "background:click";
CanvasEventType2["BackgroundDoubleClick"] = "background:double-click";
CanvasEventType2["BackgroundRightClick"] = "background:right-click";
CanvasEventType2["BackgroundLongPress"] = "background:long-press";
return CanvasEventType2;
})({});
var EVENT_TYPE_INFO = {
[CanvasEventType.NodeClick]: {
type: CanvasEventType.NodeClick,
label: "Click Node",
description: "Triggered when clicking on a node",
category: "node"
},
[CanvasEventType.NodeDoubleClick]: {
type: CanvasEventType.NodeDoubleClick,
label: "Double-click Node",
description: "Triggered when double-clicking on a node",
category: "node"
},
[CanvasEventType.NodeTripleClick]: {
type: CanvasEventType.NodeTripleClick,
label: "Triple-click Node",
description: "Triggered when triple-clicking on a node",
category: "node"
},
[CanvasEventType.NodeRightClick]: {
type: CanvasEventType.NodeRightClick,
label: "Right-click Node",
description: "Triggered when right-clicking on a node",
category: "node"
},
[CanvasEventType.NodeLongPress]: {
type: CanvasEventType.NodeLongPress,
label: "Long-press Node",
description: "Triggered when long-pressing on a node (mobile/touch)",
category: "node"
},
[CanvasEventType.EdgeClick]: {
type: CanvasEventType.EdgeClick,
label: "Click Edge",
description: "Triggered when clicking on an edge",
category: "edge"
},
[CanvasEventType.EdgeDoubleClick]: {
type: CanvasEventType.EdgeDoubleClick,
label: "Double-click Edge",
description: "Triggered when double-clicking on an edge",
category: "edge"
},
[CanvasEventType.EdgeRightClick]: {
type: CanvasEventType.EdgeRightClick,
label: "Right-click Edge",
description: "Triggered when right-clicking on an edge",
category: "edge"
},
[CanvasEventType.BackgroundClick]: {
type: CanvasEventType.BackgroundClick,
label: "Click Background",
description: "Triggered when clicking on the canvas background",
category: "background"
},
[CanvasEventType.BackgroundDoubleClick]: {
type: CanvasEventType.BackgroundDoubleClick,
label: "Double-click Background",
description: "Triggered when double-clicking on the canvas background",
category: "background"
},
[CanvasEventType.BackgroundRightClick]: {
type: CanvasEventType.BackgroundRightClick,
label: "Right-click Background",
description: "Triggered when right-clicking on the canvas background",
category: "background"
},
[CanvasEventType.BackgroundLongPress]: {
type: CanvasEventType.BackgroundLongPress,
label: "Long-press Background",
description: "Triggered when long-pressing on the canvas background (mobile/touch)",
category: "background"
}
};
// src/core/action-types.ts
var ActionCategory = /* @__PURE__ */ (function(ActionCategory2) {
ActionCategory2["None"] = "none";
ActionCategory2["Selection"] = "selection";
ActionCategory2["Viewport"] = "viewport";
ActionCategory2["Node"] = "node";
ActionCategory2["Layout"] = "layout";
ActionCategory2["History"] = "history";
ActionCategory2["Custom"] = "custom";
return ActionCategory2;
})({});
var BuiltInActionId = {
// None
None: "none",
// Selection
SelectNode: "select-node",
SelectEdge: "select-edge",
AddToSelection: "add-to-selection",
ClearSelection: "clear-selection",
DeleteSelected: "delete-selected",
// Viewport
FitToView: "fit-to-view",
FitAllToView: "fit-all-to-view",
CenterOnNode: "center-on-node",
ResetViewport: "reset-viewport",
// Node
LockNode: "lock-node",
UnlockNode: "unlock-node",
ToggleLock: "toggle-lock",
OpenContextMenu: "open-context-menu",
SplitNode: "split-node",
GroupNodes: "group-nodes",
MergeNodes: "merge-nodes",
// Layout
ApplyForceLayout: "apply-force-layout",
// History
Undo: "undo",
Redo: "redo",
// Creation
CreateNode: "create-node"
};
// src/core/settings-state-types.ts
var DEFAULT_MAPPINGS = {
[CanvasEventType.NodeClick]: BuiltInActionId.None,
[CanvasEventType.NodeDoubleClick]: BuiltInActionId.FitToView,
[CanvasEventType.NodeTripleClick]: BuiltInActionId.ToggleLock,
[CanvasEventType.NodeRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.NodeLongPress]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.EdgeClick]: BuiltInActionId.SelectEdge,
[CanvasEventType.EdgeDoubleClick]: BuiltInActionId.None,
[CanvasEventType.EdgeRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.BackgroundClick]: BuiltInActionId.ClearSelection,
[CanvasEventType.BackgroundDoubleClick]: BuiltInActionId.FitAllToView,
[CanvasEventType.BackgroundRightClick]: BuiltInActionId.None,
[CanvasEventType.BackgroundLongPress]: BuiltInActionId.CreateNode
};
// src/core/actions-node.ts
function registerSelectionActions() {
registerAction2({
id: BuiltInActionId.SelectNode,
label: "Select Node",
description: "Select this node (replacing current selection)",
category: ActionCategory.Selection,
icon: "pointer",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.selectNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.SelectEdge,
label: "Select Edge",
description: "Select this edge",
category: ActionCategory.Selection,
icon: "git-commit",
isBuiltIn: true,
handler: (context, helpers) => {
if (context.edgeId) {
helpers.selectEdge(context.edgeId);
}
}
});
registerAction2({
id: BuiltInActionId.AddToSelection,
label: "Add to Selection",
description: "Add this node to the current selection",
category: ActionCategory.Selection,
icon: "plus-square",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.addToSelection(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.ClearSelection,
label: "Clear Selection",
description: "Deselect all nodes",
category: ActionCategory.Selection,
icon: "x-square",
isBuiltIn: true,
handler: (_context, helpers) => {
helpers.clearSelection();
}
});
registerAction2({
id: BuiltInActionId.DeleteSelected,
label: "Delete Selected",
description: "Delete all selected nodes",
category: ActionCategory.Selection,
icon: "trash-2",
isBuiltIn: true,
handler: async (_context, helpers) => {
const selectedIds = helpers.getSelectedNodeIds();
for (const nodeId of selectedIds) {
await helpers.deleteNode(nodeId);
}
}
});
}
function registerNodeActions() {
registerAction2({
id: BuiltInActionId.LockNode,
label: "Lock Node",
description: "Prevent this node from being moved",
category: ActionCategory.Node,
icon: "lock",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.lockNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.UnlockNode,
label: "Unlock Node",
description: "Allow this node to be moved",
category: ActionCategory.Node,
icon: "unlock",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.unlockNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.ToggleLock,
label: "Toggle Lock",
description: "Toggle whether this node can be moved",
category: ActionCategory.Node,
icon: "lock",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.toggleLock(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.OpenContextMenu,
label: "Open Context Menu",
description: "Show the context menu for this node",
category: ActionCategory.Node,
icon: "more-vertical",
isBuiltIn: true,
handler: (context, helpers) => {
if (helpers.openContextMenu) {
helpers.openContextMenu(context.screenPosition, context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.CreateNode,
label: "Create Node",
description: "Create a new node at this position",
category: ActionCategory.Node,
icon: "plus",
isBuiltIn: true,
handler: async (context, helpers) => {
if (helpers.createNode) {
await helpers.createNode(context.worldPosition);
}
}
});
registerAction2({
id: BuiltInActionId.SplitNode,
label: "Split Node",
description: "Split a node into two separate nodes",
category: ActionCategory.Node,
icon: "split",
isBuiltIn: true,
handler: async (context, helpers) => {
if (helpers.splitNode && context.nodeId) {
await helpers.splitNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.GroupNodes,
label: "Group Nodes",
description: "Group selected nodes into a parent container",
category: ActionCategory.Node,
icon: "group",
isBuiltIn: true,
handler: async (context, helpers) => {
if (helpers.groupNodes) {
await helpers.groupNodes(context.selectedNodeIds ?? helpers.getSelectedNodeIds());
}
}
});
registerAction2({
id: BuiltInActionId.MergeNodes,
label: "Merge Nodes",
description: "Merge selected nodes into one",
category: ActionCategory.Node,
icon: "merge",
isBuiltIn: true,
handler: async (context, helpers) => {
if (helpers.mergeNodes) {
await helpers.mergeNodes(context.selectedNodeIds ?? helpers.getSelectedNodeIds());
}
}
});
}
// src/core/actions-viewport.ts
function registerViewportActions() {
registerAction2({
id: BuiltInActionId.FitToView,
label: "Fit to View",
description: "Zoom and pan to fit this node in view",
category: ActionCategory.Viewport,
icon: "maximize-2",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.centerOnNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.FitAllToView,
label: "Fit All to View",
description: "Zoom and pan to fit all nodes in view",
category: ActionCategory.Viewport,
icon: "maximize",
isBuiltIn: true,
handler: (_context, helpers) => {
helpers.fitToBounds("graph");
}
});
registerAction2({
id: BuiltInActionId.CenterOnNode,
label: "Center on Node",
description: "Center the viewport on this node",
category: ActionCategory.Viewport,
icon: "crosshair",
requiresNode: true,
isBuiltIn: true,
handler: (context, helpers) => {
if (context.nodeId) {
helpers.centerOnNode(context.nodeId);
}
}
});
registerAction2({
id: BuiltInActionId.ResetViewport,
label: "Reset Viewport",
description: "Reset zoom to 100% and center on origin",
category: ActionCategory.Viewport,
icon: "home",
isBuiltIn: true,
handler: (_context, helpers) => {
helpers.resetViewport();
}
});
}
function registerHistoryActions() {
registerAction2({
id: BuiltInActionId.Undo,
label: "Undo",
description: "Undo the last action",
category: ActionCategory.History,
icon: "undo-2",
isBuiltIn: true,
handler: (_context, helpers) => {
if (helpers.canUndo()) {
helpers.undo();
}
}
});
registerAction2({
id: BuiltInActionId.Redo,
label: "Redo",
description: "Redo the last undone action",
category: ActionCategory.History,
icon: "redo-2",
isBuiltIn: true,
handler: (_context, helpers) => {
if (helpers.canRedo()) {
helpers.redo();
}
}
});
registerAction2({
id: BuiltInActionId.ApplyForceLayout,
label: "Apply Force Layout",
description: "Automatically arrange nodes using force-directed layout",
category: ActionCategory.Layout,
icon: "layout-grid",
isBuiltIn: true,
handler: async (_context, helpers) => {
await helpers.applyForceLayout();
}
});
}
// src/core/built-in-actions.ts
function registerBuiltInActions() {
registerAction2({
id: BuiltInActionId.None,
label: "None",
description: "Do nothing",
category: ActionCategory.None,
icon: "ban",
isBuiltIn: true,
handler: () => {
}
});
registerSelectionActions();
registerNodeActions();
registerViewportActions();
registerHistoryActions();
}
// src/core/action-registry.ts
var actionRegistry = /* @__PURE__ */ new Map();
function registerAction2(action) {
actionRegistry.set(action.id, action);
}
registerBuiltInActions();
// src/core/action-executor.ts
var debug9 = createDebug("actions");
// src/core/settings-store.ts
var import_jotai18 = require("jotai");
var import_utils = require("jotai/utils");
// src/core/settings-presets.ts
var BUILT_IN_PRESETS = [{
id: "default",
name: "Default",
description: "Standard canvas interactions",
isBuiltIn: true,
mappings: DEFAULT_MAPPINGS
}, {
id: "minimal",
name: "Minimal",
description: "Only essential selection and context menu actions",
isBuiltIn: true,
mappings: {
[CanvasEventType.NodeClick]: BuiltInActionId.None,
[CanvasEventType.NodeDoubleClick]: BuiltInActionId.None,
[CanvasEventType.NodeTripleClick]: BuiltInActionId.None,
[CanvasEventType.NodeRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.NodeLongPress]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.EdgeClick]: BuiltInActionId.SelectEdge,
[CanvasEventType.EdgeDoubleClick]: BuiltInActionId.None,
[CanvasEventType.EdgeRightClick]: BuiltInActionId.None,
[CanvasEventType.BackgroundClick]: BuiltInActionId.ClearSelection,
[CanvasEventType.BackgroundDoubleClick]: BuiltInActionId.None,
[CanvasEventType.BackgroundRightClick]: BuiltInActionId.None,
[CanvasEventType.BackgroundLongPress]: BuiltInActionId.None
}
}, {
id: "power-user",
name: "Power User",
description: "Quick actions for experienced users",
isBuiltIn: true,
mappings: {
[CanvasEventType.NodeClick]: BuiltInActionId.None,
[CanvasEventType.NodeDoubleClick]: BuiltInActionId.ToggleLock,
[CanvasEventType.NodeTripleClick]: BuiltInActionId.DeleteSelected,
[CanvasEventType.NodeRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.NodeLongPress]: BuiltInActionId.AddToSelection,
[CanvasEventType.EdgeClick]: BuiltInActionId.SelectEdge,
[CanvasEventType.EdgeDoubleClick]: BuiltInActionId.None,
[CanvasEventType.EdgeRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.BackgroundClick]: BuiltInActionId.ClearSelection,
[CanvasEventType.BackgroundDoubleClick]: BuiltInActionId.CreateNode,
[CanvasEventType.BackgroundRightClick]: BuiltInActionId.OpenContextMenu,
[CanvasEventType.BackgroundLongPress]: BuiltInActionId.ApplyForceLayout
}
}];
// src/core/settings-store.ts
var debug10 = createDebug("settings");
var DEFAULT_STATE = {
mappings: DEFAULT_MAPPINGS,
activePresetId: "default",
customPresets: [],
isPanelOpen: false,
virtualizationEnabled: true
};
var canvasSettingsAtom = (0, import_utils.atomWithStorage)("@blinksgg/canvas/settings", DEFAULT_STATE);
var eventMappingsAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).mappings);
var activePresetIdAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).activePresetId);
var allPresetsAtom = (0, import_jotai18.atom)((get) => {
const state = get(canvasSettingsAtom);
return [...BUILT_IN_PRESETS, ...state.customPresets];
});
var activePresetAtom = (0, import_jotai18.atom)((get) => {
const presetId = get(activePresetIdAtom);
if (!presetId) return null;
const allPresets = get(allPresetsAtom);
return allPresets.find((p) => p.id === presetId) || null;
});
var isPanelOpenAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).isPanelOpen);
var virtualizationEnabledAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).virtualizationEnabled ?? true);
var hasUnsavedChangesAtom = (0, import_jotai18.atom)((get) => {
const state = get(canvasSettingsAtom);
const activePreset = get(activePresetAtom);
if (!activePreset) return true;
const events = Object.values(CanvasEventType);
return events.some((event) => state.mappings[event] !== activePreset.mappings[event]);
});
var setEventMappingAtom = (0, import_jotai18.atom)(null, (get, set, {
event,
actionId
}) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
mappings: {
...current.mappings,
[event]: actionId
},
// Clear active preset since mappings have changed
activePresetId: null
});
});
var applyPresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
const allPresets = get(allPresetsAtom);
const preset = allPresets.find((p) => p.id === presetId);
if (!preset) {
debug10.warn("Preset not found: %s", presetId);
return;
}
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
mappings: {
...preset.mappings
},
activePresetId: presetId
});
});
var saveAsPresetAtom = (0, import_jotai18.atom)(null, (get, set, {
name,
description
}) => {
const current = get(canvasSettingsAtom);
const id = `custom-${Date.now()}`;
const newPreset = {
id,
name,
description,
mappings: {
...current.mappings
},
isBuiltIn: false
};
set(canvasSettingsAtom, {
...current,
customPresets: [...current.customPresets, newPreset],
activePresetId: id
});
return id;
});
var updatePresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
const current = get(canvasSettingsAtom);
const presetIndex = current.customPresets.findIndex((p) => p.id === presetId);
if (presetIndex === -1) {
debug10.warn("Cannot update preset: %s (not found or built-in)", presetId);
return;
}
const updatedPresets = [...current.customPresets];
updatedPresets[presetIndex] = {
...updatedPresets[presetIndex],
mappings: {
...current.mappings
}
};
set(canvasSettingsAtom, {
...current,
customPresets: updatedPresets,
activePresetId: presetId
});
});
var deletePresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
const current = get(canvasSettingsAtom);
const newCustomPresets = current.customPresets.filter((p) => p.id !== presetId);
if (newCustomPresets.length === current.customPresets.length) {
debug10.warn("Cannot delete preset: %s (not found or built-in)", presetId);
return;
}
const newActiveId = current.activePresetId === presetId ? "default" : current.activePresetId;
const newMappings = newActiveId === "default" ? DEFAULT_MAPPINGS : current.mappings;
set(canvasSettingsAtom, {
...current,
customPresets: newCustomPresets,
activePresetId: newActiveId,
mappings: newMappings
});
});
var resetSettingsAtom = (0, import_jotai18.atom)(null, (get, set) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
mappings: DEFAULT_MAPPINGS,
activePresetId: "default"
});
});
var togglePanelAtom = (0, import_jotai18.atom)(null, (get, set) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
isPanelOpen: !current.isPanelOpen
});
});
var setPanelOpenAtom = (0, import_jotai18.atom)(null, (get, set, isOpen) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
isPanelOpen: isOpen
});
});
var setVirtualizationEnabledAtom = (0, import_jotai18.atom)(null, (get, set, enabled) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
virtualizationEnabled: enabled
});
});
var toggleVirtualizationAtom = (0, import_jotai18.atom)(null, (get, set) => {
const current = get(canvasSettingsAtom);
set(canvasSettingsAtom, {
...current,
virtualizationEnabled: !(current.virtualizationEnabled ?? true)
});
});
// src/core/canvas-serializer.ts
var import_graphology4 = __toESM(require("graphology"));
// src/core/clipboard-store.ts
var import_jotai19 = require("jotai");
var debug11 = createDebug("clipboard");
var PASTE_OFFSET = {
x: 50,
y: 50
};
var clipboardAtom = (0, import_jotai19.atom)(null);
var hasClipboardContentAtom = (0, import_jotai19.atom)((get) => get(clipboardAtom) !== null);
var clipboardNodeCountAtom = (0, import_jotai19.atom)((get) => {
const clipboard = get(clipboardAtom);
return clipboard?.nodes.length ?? 0;
});
function calculateBounds2(nodes) {
if (nodes.length === 0) {
return {
minX: 0,
minY: 0,
maxX: 0,
maxY: 0
};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of nodes) {
minX = Math.min(minX, node.attrs.x);
minY = Math.min(minY, node.attrs.y);
maxX = Math.max(maxX, node.attrs.x + node.attrs.width);
maxY = Math.max(maxY, node.attrs.y + node.attrs.height);
}
return {
minX,
minY,
maxX,
maxY
};
}
function generatePasteId(index) {
return `paste-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`;
}
var copyToClipboardAtom = (0, import_jotai19.atom)(null, (get, set, nodeIds) => {
const selectedIds = nodeIds ?? Array.from(get(selectedNodeIdsAtom));
if (selectedIds.length === 0) {
debug11("Nothing to copy - no nodes selected");
return;
}
const graph = get(graphAtom);
const selectedSet = new Set(selectedIds);
const nodes = [];
const edges = [];
for (const nodeId of selectedIds) {
if (!graph.hasNode(nodeId)) {
debug11("Node %s not found in graph, skipping", nodeId);
continue;
}
const attrs = graph.getNodeAttributes(nodeId);
nodes.push({
attrs: {
...attrs
},
dbData: {
...attrs.dbData
}
});
}
graph.forEachEdge((edgeKey, attrs, source, target) => {
if (selectedSet.has(source) && selectedSet.has(target)) {
edges.push({
source,
target,
attrs: {
...attrs
},
dbData: {
...attrs.dbData
}
});
}
});
const bounds = calculateBounds2(nodes);
const clipboardData = {
nodes,
edges,
bounds,
timestamp: Date.now()
};
set(clipboardAtom, clipboardData);
debug11("Copied %d nodes and %d edges to clipboard", nodes.length, edges.length);
});
var cutToClipboardAtom = (0, import_jotai19.atom)(null, (get, set, nodeIds) => {
const selectedIds = nodeIds ?? Array.from(get(selectedNodeIdsAtom));
if (selectedIds.length === 0) return;
set(copyToClipboardAtom, selectedIds);
set(pushHistoryAtom, "Cut nodes");
for (const nodeId of selectedIds) {
set(optimisticDeleteNodeAtom, {
nodeId
});
}
set(clearSelectionAtom);
debug11("Cut %d nodes \u2014 copied to clipboard and deleted from graph", selectedIds.length);
});
var pasteFromClipboardAtom = (0, import_jotai19.atom)(null, (get, set, offset) => {
const clipboard = get(clipboardAtom);
if (!clipboard || clipboard.nodes.length === 0) {
debug11("Nothing to paste - clipboard empty");
return [];
}
const pasteOffset = offset ?? PASTE_OFFSET;
const graph = get(graphAtom);
set(pushHistoryAtom, "Paste nodes");
const idMap = /* @__PURE__ */ new Map();
const newNodeIds = [];
for (let i = 0; i < clipboard.nodes.length; i++) {
const nodeData = clipboard.nodes[i];
const newId = generatePasteId(i);
idMap.set(nodeData.dbData.id, newId);
newNodeIds.push(newId);
const newDbNode = {
...nodeData.dbData,
id: newId,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
ui_properties: {
...nodeData.dbData.ui_properties || {},
x: nodeData.attrs.x + pasteOffset.x,
y: nodeData.attrs.y + pasteOffset.y
}
};
debug11("Pasting node %s -> %s at (%d, %d)", nodeData.dbData.id, newId, nodeData.attrs.x + pasteOffset.x, nodeData.attrs.y + pasteOffset.y);
set(addNodeToLocalGraphAtom, newDbNode);
}
for (const edgeData of clipboard.edges) {
const newSourceId = idMap.get(edgeData.source);
const newTargetId = idMap.get(edgeData.target);
if (!newSourceId || !newTargetId) {
debug11("Edge %s: source or target not found in id map, skipping", edgeData.dbData.id);
continue;
}
const newEdgeId = generatePasteId(clipboard.edges.indexOf(edgeData) + clipboard.nodes.length);
const newDbEdge = {
...edgeData.dbData,
id: newEdgeId,
source_node_id: newSourceId,
target_node_id: newTargetId,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
updated_at: (/* @__PURE__ */ new Date()).toISOString()
};
debug11("Pasting edge %s -> %s (from %s to %s)", edgeData.dbData.id, newEdgeId, newSourceId, newTargetId);
set(addEdgeToLocalGraphAtom, newDbEdge);
}
set(clearSelectionAtom);
set(addNodesToSelectionAtom, newNodeIds);
debug11("Pasted %d nodes and %d edges", newNodeIds.length, clipboard.edges.length);
return newNodeIds;
});
var duplicateSelectionAtom = (0, import_jotai19.atom)(null, (get, set) => {
set(copyToClipboardAtom);
return set(pasteFromClipboardAtom);
});
var clearClipboardAtom = (0, import_jotai19.atom)(null, (_get, set) => {
set(clipboardAtom, null);
debug11("Clipboard cleared");
});
// src/core/virtualization-store.ts
var import_jotai20 = require("jotai");
// src/core/spatial-index.ts
var SpatialGrid = class {
constructor(cellSize = 500) {
/** cell key → set of node IDs in that cell */
__publicField(this, "cells", /* @__PURE__ */ new Map());
/** node ID → entry data (for update/remove) */
__publicField(this, "entries", /* @__PURE__ */ new Map());
this.cellSize = cellSize;
}
/** Number of tracked entries */
get size() {
return this.entries.size;
}
cellKey(cx, cy) {
return `${cx},${cy}`;
}
getCellRange(x, y, w, h) {
const cs = this.cellSize;
return {
minCX: Math.floor(x / cs),
minCY: Math.floor(y / cs),
maxCX: Math.floor((x + w) / cs),
maxCY: Math.floor((y + h) / cs)
};
}
/**
* Insert a node into the index.
* If the node already exists, it is updated.
*/
insert(id, x, y, width, height) {
if (this.entries.has(id)) {
this.update(id, x, y, width, height);
return;
}
const entry = {
id,
x,
y,
width,
height
};
this.entries.set(id, entry);
const {
minCX,
minCY,
maxCX,
maxCY
} = this.getCellRange(x, y, width, height);
for (let cx = minCX; cx <= maxCX; cx++) {
for (let cy = minCY; cy <= maxCY; cy++) {
const key = this.cellKey(cx, cy);
let cell = this.cells.get(key);
if (!cell) {
cell = /* @__PURE__ */ new Set();
this.cells.set(key, cell);
}
cell.add(id);
}
}
}
/**
* Update a node's position/dimensions.
*/
update(id, x, y, width, height) {
const prev = this.entries.get(id);
if (!prev) {
this.insert(id, x, y, width, height);
return;
}
const prevRange = this.getCellRange(prev.x, prev.y, prev.width, prev.height);
const newRange = this.getCellRange(x, y, width, height);
prev.x = x;
prev.y = y;
prev.width = width;
prev.height = height;
if (prevRange.minCX === newRange.minCX && prevRange.minCY === newRange.minCY && prevRange.maxCX === newRange.maxCX && prevRange.maxCY === newRange.maxCY) {
return;
}
for (let cx = prevRange.minCX; cx <= prevRange.maxCX; cx++) {
for (let cy = prevRange.minCY; cy <= prevRange.maxCY; cy++) {
const key = this.cellKey(cx, cy);
const cell = this.cells.get(key);
if (cell) {
cell.delete(id);
if (cell.size === 0) this.cells.delete(key);
}
}
}
for (let cx = newRange.minCX; cx <= newRange.maxCX; cx++) {
for (let cy = newRange.minCY; cy <= newRange.maxCY; cy++) {
const key = this.cellKey(cx, cy);
let cell = this.cells.get(key);
if (!cell) {
cell = /* @__PURE__ */ new Set();
this.cells.set(key, cell);
}
cell.add(id);
}
}
}
/**
* Remove a node from the index.
*/
remove(id) {
const entry = this.entries.get(id);
if (!entry) return;
const {
minCX,
minCY,
maxCX,
maxCY
} = this.getCellRange(entry.x, entry.y, entry.width, entry.height);
for (let cx = minCX; cx <= maxCX; cx++) {
for (let cy = minCY; cy <= maxCY; cy++) {
const key = this.cellKey(cx, cy);
const cell = this.cells.get(key);
if (cell) {
cell.delete(id);
if (cell.size === 0) this.cells.delete(key);
}
}
}
this.entries.delete(id);
}
/**
* Query all node IDs whose bounding box overlaps the given bounds.
* Returns a Set for O(1) membership checks.
*/
query(bounds) {
const result = /* @__PURE__ */ new Set();
const {
minCX,
minCY,
maxCX,
maxCY
} = this.getCellRange(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
for (let cx = minCX; cx <= maxCX; cx++) {
for (let cy = minCY; cy <= maxCY; cy++) {
const cell = this.cells.get(this.cellKey(cx, cy));
if (!cell) continue;
for (const id of cell) {
if (result.has(id)) continue;
const entry = this.entries.get(id);
const entryRight = entry.x + entry.width;
const entryBottom = entry.y + entry.height;
if (entry.x <= bounds.maxX && entryRight >= bounds.minX && entry.y <= bounds.maxY && entryBottom >= bounds.minY) {
result.add(id);
}
}
}
}
return result;
}
/**
* Clear all entries.
*/
clear() {
this.cells.clear();
this.entries.clear();
}
/**
* Check if a node is tracked.
*/
has(id) {
return this.entries.has(id);
}
};
// src/core/virtualization-store.ts
var VIRTUALIZATION_BUFFER = 200;
var spatialIndexAtom = (0, import_jotai20.atom)((get) => {
get(graphUpdateVersionAtom);
get(nodePositionUpdateCounterAtom);
const graph = get(graphAtom);
const grid = new SpatialGrid(500);
graph.forEachNode((nodeId, attrs) => {
const a = attrs;
grid.insert(nodeId, a.x, a.y, a.width || 200, a.height || 100);
});
return grid;
});
var visibleBoundsAtom = (0, import_jotai20.atom)((get) => {
const viewport = get(viewportRectAtom);
const pan = get(panAtom);
const zoom = get(zoomAtom);
if (!viewport || zoom === 0) {
return null;
}
const buffer = VIRTUALIZATION_BUFFER;
return {
minX: (-buffer - pan.x) / zoom,
minY: (-buffer - pan.y) / zoom,
maxX: (viewport.width + buffer - pan.x) / zoom,
maxY: (viewport.height + buffer - pan.y) / zoom
};
});
var visibleNodeKeysAtom = (0, import_jotai20.atom)((get) => {
const end = canvasMark("virtualization-cull");
const enabled = get(virtualizationEnabledAtom);
const allKeys = get(nodeKeysAtom);
if (!enabled) {
end();
return allKeys;
}
const bounds = get(visibleBoundsAtom);
if (!bounds) {
end();
return allKeys;
}
const grid = get(spatialIndexAtom);
const visibleSet = grid.query(bounds);
const result = allKeys.filter((k) => visibleSet.has(k));
end();
return result;
});
var visibleEdgeKeysAtom = (0, import_jotai20.atom)((get) => {
const enabled = get(virtualizationEnabledAtom);
const allEdgeKeys = get(edgeKeysAtom);
const edgeCreation = get(edgeCreationAtom);
const remap = get(collapsedEdgeRemapAtom);
const tempEdgeKey = edgeCreation.isCreating ? "temp-creating-edge" : null;
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
const filteredEdges = allEdgeKeys.filter((edgeKey) => {
const source = graph.source(edgeKey);
const target = graph.target(edgeKey);
const effectiveSource = remap.get(source) ?? source;
const effectiveTarget = remap.get(target) ?? target;
if (effectiveSource === effectiveTarget) return false;
return true;
});
if (!enabled) {
return tempEdgeKey ? [...filteredEdges, tempEdgeKey] : filteredEdges;
}
const visibleNodeKeys = get(visibleNodeKeysAtom);
const visibleNodeSet = new Set(visibleNodeKeys);
const visibleEdges = filteredEdges.filter((edgeKey) => {
const source = graph.source(edgeKey);
const target = graph.target(edgeKey);
const effectiveSource = remap.get(source) ?? source;
const effectiveTarget = remap.get(target) ?? target;
return visibleNodeSet.has(effectiveSource) && visibleNodeSet.has(effectiveTarget);
});
return tempEdgeKey ? [...visibleEdges, tempEdgeKey] : visibleEdges;
});
var virtualizationMetricsAtom = (0, import_jotai20.atom)((get) => {
const enabled = get(virtualizationEnabledAtom);
const totalNodes = get(nodeKeysAtom).length;
const totalEdges = get(edgeKeysAtom).length;
const visibleNodes = get(visibleNodeKeysAtom).length;
const visibleEdges = get(visibleEdgeKeysAtom).length;
const bounds = get(visibleBoundsAtom);
return {
enabled,
totalNodes,
totalEdges,
visibleNodes,
visibleEdges,
culledNodes: totalNodes - visibleNodes,
culledEdges: totalEdges - visibleEdges,
bounds
};
});
// src/core/input-store.ts
var import_jotai21 = require("jotai");
var activePointersAtom = (0, import_jotai21.atom)(/* @__PURE__ */ new Map());
var primaryInputSourceAtom = (0, import_jotai21.atom)("mouse");
var inputCapabilitiesAtom = (0, import_jotai21.atom)(detectInputCapabilities());
var isStylusActiveAtom = (0, import_jotai21.atom)((get) => {
const pointers = get(activePointersAtom);
for (const [, pointer] of pointers) {
if (pointer.source === "pencil") return true;
}
return false;
});
var isMultiTouchAtom = (0, import_jotai21.atom)((get) => {
const pointers = get(activePointersAtom);
let fingerCount = 0;
for (const [, pointer] of pointers) {
if (pointer.source === "finger") fingerCount++;
}
return fingerCount > 1;
});
var fingerCountAtom = (0, import_jotai21.atom)((get) => {
const pointers = get(activePointersAtom);
let count = 0;
for (const [, pointer] of pointers) {
if (pointer.source === "finger") count++;
}
return count;
});
var isTouchDeviceAtom = (0, import_jotai21.atom)((get) => {
const caps = get(inputCapabilitiesAtom);
return caps.hasTouch;
});
var pointerDownAtom = (0, import_jotai21.atom)(null, (get, set, pointer) => {
const pointers = new Map(get(activePointersAtom));
pointers.set(pointer.pointerId, pointer);
set(activePointersAtom, pointers);
set(primaryInputSourceAtom, pointer.source);
if (pointer.source === "pencil") {
const caps = get(inputCapabilitiesAtom);
if (!caps.hasStylus) {
set(inputCapabilitiesAtom, {
...caps,
hasStylus: true
});
}
}
});
var pointerUpAtom = (0, import_jotai21.atom)(null, (get, set, pointerId) => {
const pointers = new Map(get(activePointersAtom));
pointers.delete(pointerId);
set(activePointersAtom, pointers);
});
var clearPointersAtom = (0, import_jotai21.atom)(null, (_get, set) => {
set(activePointersAtom, /* @__PURE__ */ new Map());
});
// src/core/selection-path-store.ts
var import_jotai22 = require("jotai");
var selectionPathAtom = (0, import_jotai22.atom)(null);
var isSelectingAtom = (0, import_jotai22.atom)((get) => get(selectionPathAtom) !== null);
var startSelectionAtom = (0, import_jotai22.atom)(null, (_get, set, {
type,
point
}) => {
set(selectionPathAtom, {
type,
points: [point]
});
});
var updateSelectionAtom = (0, import_jotai22.atom)(null, (get, set, point) => {
const current = get(selectionPathAtom);
if (!current) return;
if (current.type === "rect") {
set(selectionPathAtom, {
...current,
points: [current.points[0], point]
});
} else {
set(selectionPathAtom, {
...current,
points: [...current.points, point]
});
}
});
var cancelSelectionAtom = (0, import_jotai22.atom)(null, (_get, set) => {
set(selectionPathAtom, null);
});
var endSelectionAtom = (0, import_jotai22.atom)(null, (get, set) => {
const path = get(selectionPathAtom);
if (!path || path.points.length < 2) {
set(selectionPathAtom, null);
return;
}
const nodes = get(uiNodesAtom);
const selectedIds = [];
if (path.type === "rect") {
const [p1, p2] = [path.points[0], path.points[path.points.length - 1]];
const minX = Math.min(p1.x, p2.x);
const maxX = Math.max(p1.x, p2.x);
const minY = Math.min(p1.y, p2.y);
const maxY = Math.max(p1.y, p2.y);
for (const node of nodes) {
const nodeRight = node.position.x + (node.width ?? 200);
const nodeBottom = node.position.y + (node.height ?? 100);
if (node.position.x < maxX && nodeRight > minX && node.position.y < maxY && nodeBottom > minY) {
selectedIds.push(node.id);
}
}
} else {
const polygon = path.points;
for (const node of nodes) {
const cx = node.position.x + (node.width ?? 200) / 2;
const cy = node.position.y + (node.height ?? 100) / 2;
if (pointInPolygon(cx, cy, polygon)) {
selectedIds.push(node.id);
}
}
}
set(selectedNodeIdsAtom, new Set(selectedIds));
set(selectionPathAtom, null);
});
var selectionRectAtom = (0, import_jotai22.atom)((get) => {
const path = get(selectionPathAtom);
if (!path || path.type !== "rect" || path.points.length < 2) return null;
const [p1, p2] = [path.points[0], path.points[path.points.length - 1]];
return {
x: Math.min(p1.x, p2.x),
y: Math.min(p1.y, p2.y),
width: Math.abs(p2.x - p1.x),
height: Math.abs(p2.y - p1.y)
};
});
function pointInPolygon(px, py, polygon) {
let inside = false;
const n = polygon.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
// src/core/search-store.ts
var import_jotai23 = require("jotai");
var searchQueryAtom = (0, import_jotai23.atom)("");
var setSearchQueryAtom = (0, import_jotai23.atom)(null, (_get, set, query) => {
set(searchQueryAtom, query);
set(highlightedSearchIndexAtom, 0);
});
var clearSearchAtom = (0, import_jotai23.atom)(null, (_get, set) => {
set(searchQueryAtom, "");
set(highlightedSearchIndexAtom, 0);
});
function fuzzyMatch(query, ...haystacks) {
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return false;
const combined = haystacks.join(" ").toLowerCase();
return tokens.every((token) => combined.includes(token));
}
var searchResultsAtom = (0, import_jotai23.atom)((get) => {
const query = get(searchQueryAtom).trim();
if (!query) return /* @__PURE__ */ new Set();
const nodes = get(uiNodesAtom);
const matches = /* @__PURE__ */ new Set();
for (const node of nodes) {
if (fuzzyMatch(query, node.label || "", node.dbData.node_type || "", node.id)) {
matches.add(node.id);
}
}
return matches;
});
var searchResultsArrayAtom = (0, import_jotai23.atom)((get) => {
return Array.from(get(searchResultsAtom));
});
var searchResultCountAtom = (0, import_jotai23.atom)((get) => {
return get(searchResultsAtom).size;
});
var searchEdgeResultsAtom = (0, import_jotai23.atom)((get) => {
const query = get(searchQueryAtom).trim();
if (!query) return /* @__PURE__ */ new Set();
get(graphUpdateVersionAtom);
const graph = get(graphAtom);
const matches = /* @__PURE__ */ new Set();
graph.forEachEdge((edgeKey, attrs) => {
const label = attrs.label || "";
const edgeType = attrs.dbData?.edge_type || "";
if (fuzzyMatch(query, label, edgeType, edgeKey)) {
matches.add(edgeKey);
}
});
return matches;
});
var searchEdgeResultCountAtom = (0, import_jotai23.atom)((get) => {
return get(searchEdgeResultsAtom).size;
});
var isFilterActiveAtom = (0, import_jotai23.atom)((get) => {
return get(searchQueryAtom).trim().length > 0;
});
var searchTotalResultCountAtom = (0, import_jotai23.atom)((get) => {
return get(searchResultCountAtom) + get(searchEdgeResultCountAtom);
});
var highlightedSearchIndexAtom = (0, import_jotai23.atom)(0);
var nextSearchResultAtom = (0, import_jotai23.atom)(null, (get, set) => {
const results = get(searchResultsArrayAtom);
if (results.length === 0) return;
const currentIndex = get(highlightedSearchIndexAtom);
const nextIndex = (currentIndex + 1) % results.length;
set(highlightedSearchIndexAtom, nextIndex);
const nodeId = results[nextIndex];
set(centerOnNodeAtom, nodeId);
set(selectSingleNodeAtom, nodeId);
});
var prevSearchResultAtom = (0, import_jotai23.atom)(null, (get, set) => {
const results = get(searchResultsArrayAtom);
if (results.length === 0) return;
const currentIndex = get(highlightedSearchIndexAtom);
const prevIndex = (currentIndex - 1 + results.length) % results.length;
set(highlightedSearchIndexAtom, prevIndex);
const nodeId = results[prevIndex];
set(centerOnNodeAtom, nodeId);
set(selectSingleNodeAtom, nodeId);
});
var highlightedSearchNodeIdAtom = (0, import_jotai23.atom)((get) => {
const results = get(searchResultsArrayAtom);
if (results.length === 0) return null;
const index = get(highlightedSearchIndexAtom);
return results[index] ?? null;
});
// src/core/gesture-rules-defaults.ts
function mergeRules(defaults, overrides) {
const overrideMap = new Map(overrides.map((r) => [r.id, r]));
const result = [];
for (const rule of defaults) {
const override = overrideMap.get(rule.id);
if (override) {
result.push(override);
overrideMap.delete(rule.id);
} else {
result.push(rule);
}
}
for (const rule of overrideMap.values()) {
result.push(rule);
}
return result;
}
var DEFAULT_GESTURE_RULES = [
// ── Tap gestures ──────────────────────────────────────────────
{
id: "tap-node",
pattern: {
gesture: "tap",
target: "node"
},
actionId: "select-node"
},
{
id: "tap-edge",
pattern: {
gesture: "tap",
target: "edge"
},
actionId: "select-edge"
},
{
id: "tap-port",
pattern: {
gesture: "tap",
target: "port"
},
actionId: "select-node"
},
{
id: "tap-bg",
pattern: {
gesture: "tap",
target: "background"
},
actionId: "clear-selection"
},
// ── Double-tap ────────────────────────────────────────────────
{
id: "dtap-node",
pattern: {
gesture: "double-tap",
target: "node"
},
actionId: "fit-to-view"
},
{
id: "dtap-bg",
pattern: {
gesture: "double-tap",
target: "background"
},
actionId: "fit-all-to-view"
},
// ── Triple-tap ────────────────────────────────────────────────
{
id: "ttap-node",
pattern: {
gesture: "triple-tap",
target: "node"
},
actionId: "toggle-lock"
},
// ── Left-button drag ──────────────────────────────────────────
{
id: "drag-node",
pattern: {
gesture: "drag",
target: "node"
},
actionId: "move-node"
},
{
id: "drag-port",
pattern: {
gesture: "drag",
target: "port"
},
actionId: "create-edge"
},
{
id: "drag-bg-finger",
pattern: {
gesture: "drag",
target: "background",
source: "finger"
},
actionId: "pan"
},
{
id: "drag-bg-mouse",
pattern: {
gesture: "drag",
target: "background",
source: "mouse"
},
actionId: "pan"
},
{
id: "drag-bg-pencil",
pattern: {
gesture: "drag",
target: "background",
source: "pencil"
},
actionId: "lasso-select"
},
// ── Shift+drag overrides ──────────────────────────────────────
{
id: "shift-drag-bg",
pattern: {
gesture: "drag",
target: "background",
modifiers: {
shift: true
}
},
actionId: "rect-select"
},
// ── Right-click tap (context menu) ────────────────────────────
{
id: "rc-node",
pattern: {
gesture: "tap",
target: "node",
button: 2
},
actionId: "open-context-menu"
},
{
id: "rc-edge",
pattern: {
gesture: "tap",
target: "edge",
button: 2
},
actionId: "open-context-menu"
},
{
id: "rc-bg",
pattern: {
gesture: "tap",
target: "background",
button: 2
},
actionId: "open-context-menu"
},
// ── Long-press ────────────────────────────────────────────────
{
id: "lp-node",
pattern: {
gesture: "long-press",
target: "node"
},
actionId: "open-context-menu"
},
{
id: "lp-bg-finger",
pattern: {
gesture: "long-press",
target: "background",
source: "finger"
},
actionId: "create-node"
},
// ── Right-button drag (defaults to none — consumers override) ─
{
id: "rdrag-node",
pattern: {
gesture: "drag",
target: "node",
button: 2
},
actionId: "none"
},
{
id: "rdrag-bg",
pattern: {
gesture: "drag",
target: "background",
button: 2
},
actionId: "none"
},
// ── Middle-button drag (defaults to none) ─────────────────────
{
id: "mdrag-node",
pattern: {
gesture: "drag",
target: "node",
button: 1
},
actionId: "none"
},
{
id: "mdrag-bg",
pattern: {
gesture: "drag",
target: "background",
button: 1
},
actionId: "none"
},
// ── Zoom ──────────────────────────────────────────────────────
{
id: "pinch-bg",
pattern: {
gesture: "pinch",
target: "background"
},
actionId: "zoom"
},
{
id: "scroll-any",
pattern: {
gesture: "scroll"
},
actionId: "zoom"
},
// ── Split ─────────────────────────────────────────────────────
{
id: "pinch-node",
pattern: {
gesture: "pinch",
target: "node"
},
actionId: "split-node"
}
];
// src/core/gesture-rules.ts
function buildRuleIndex(rules) {
const buckets = /* @__PURE__ */ new Map();
const wildcardRules = [];
for (const rule of rules) {
const key = rule.pattern.gesture;
if (key === void 0) {
wildcardRules.push(rule);
} else {
const bucket = buckets.get(key);
if (bucket) {
bucket.push(rule);
} else {
buckets.set(key, [rule]);
}
}
}
const index = /* @__PURE__ */ new Map();
if (wildcardRules.length > 0) {
for (const [key, bucket] of buckets) {
index.set(key, bucket.concat(wildcardRules));
}
index.set("__wildcard__", wildcardRules);
} else {
for (const [key, bucket] of buckets) {
index.set(key, bucket);
}
}
return index;
}
// src/core/gesture-rule-store.ts
var import_jotai24 = require("jotai");
var import_utils2 = require("jotai/utils");
var DEFAULT_RULE_STATE = {
customRules: [],
palmRejection: true
};
var gestureRuleSettingsAtom = (0, import_utils2.atomWithStorage)("canvas-gesture-rules", DEFAULT_RULE_STATE);
var consumerGestureRulesAtom = (0, import_jotai24.atom)([]);
var gestureRulesAtom = (0, import_jotai24.atom)((get) => {
const settings = get(gestureRuleSettingsAtom);
const consumerRules = get(consumerGestureRulesAtom);
let rules = mergeRules(DEFAULT_GESTURE_RULES, settings.customRules);
if (consumerRules.length > 0) {
rules = mergeRules(rules, consumerRules);
}
return rules;
});
var gestureRuleIndexAtom = (0, import_jotai24.atom)((get) => {
return buildRuleIndex(get(gestureRulesAtom));
});
var palmRejectionEnabledAtom = (0, import_jotai24.atom)((get) => get(gestureRuleSettingsAtom).palmRejection, (get, set, enabled) => {
const current = get(gestureRuleSettingsAtom);
set(gestureRuleSettingsAtom, {
...current,
palmRejection: enabled
});
});
var addGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, rule) => {
const current = get(gestureRuleSettingsAtom);
const existing = current.customRules.findIndex((r) => r.id === rule.id);
const newRules = [...current.customRules];
if (existing >= 0) {
newRules[existing] = rule;
} else {
newRules.push(rule);
}
set(gestureRuleSettingsAtom, {
...current,
customRules: newRules
});
});
var removeGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, ruleId) => {
const current = get(gestureRuleSettingsAtom);
set(gestureRuleSettingsAtom, {
...current,
customRules: current.customRules.filter((r) => r.id !== ruleId)
});
});
var updateGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, {
id,
updates
}) => {
const current = get(gestureRuleSettingsAtom);
const index = current.customRules.findIndex((r) => r.id === id);
if (index < 0) return;
const newRules = [...current.customRules];
newRules[index] = {
...newRules[index],
...updates
};
set(gestureRuleSettingsAtom, {
...current,
customRules: newRules
});
});
var resetGestureRulesAtom = (0, import_jotai24.atom)(null, (get, set) => {
const current = get(gestureRuleSettingsAtom);
set(gestureRuleSettingsAtom, {
...current,
customRules: []
});
});
// src/core/external-keyboard-store.ts
var import_jotai25 = require("jotai");
var hasExternalKeyboardAtom = (0, import_jotai25.atom)(false);
var watchExternalKeyboardAtom = (0, import_jotai25.atom)(null, (get, set) => {
if (typeof window === "undefined") return;
const handler = (e) => {
if (e.key && e.key.length === 1 || ["Tab", "Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
set(hasExternalKeyboardAtom, true);
window.removeEventListener("keydown", handler);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
});
// src/commands/registry.ts
var CommandRegistry = class {
constructor() {
__publicField(this, "commands", /* @__PURE__ */ new Map());
__publicField(this, "aliases", /* @__PURE__ */ new Map());
}
// alias -> command name
/**
* Register a command with the registry.
* @param command The command definition to register
* @throws Error if command name or alias already exists
*/
register(command) {
if (this.commands.has(command.name)) {
throw new Error(`Command "${command.name}" is already registered`);
}
this.commands.set(command.name, command);
if (command.aliases) {
for (const alias of command.aliases) {
if (this.aliases.has(alias)) {
throw new Error(`Alias "${alias}" is already registered for command "${this.aliases.get(alias)}"`);
}
if (this.commands.has(alias)) {
throw new Error(`Alias "${alias}" conflicts with existing command name`);
}
this.aliases.set(alias, command.name);
}
}
}
/**
* Unregister a command by name.
* @param name The command name to remove
*/
unregister(name) {
const command = this.commands.get(name);
if (command) {
if (command.aliases) {
for (const alias of command.aliases) {
this.aliases.delete(alias);
}
}
this.commands.delete(name);
}
}
/**
* Get a command by name or alias.
* @param nameOrAlias Command name or alias
* @returns The command definition or undefined if not found
*/
get(nameOrAlias) {
const direct = this.commands.get(nameOrAlias);
if (direct) return direct;
const commandName = this.aliases.get(nameOrAlias);
if (commandName) {
return this.commands.get(commandName);
}
return void 0;
}
/**
* Check if a command exists by name or alias.
* @param nameOrAlias Command name or alias
*/
has(nameOrAlias) {
return this.commands.has(nameOrAlias) || this.aliases.has(nameOrAlias);
}
/**
* Search for commands matching a query.
* Searches command names, aliases, and descriptions.
* @param query Search query (case-insensitive)
* @returns Array of matching commands, sorted by relevance
*/
search(query) {
if (!query.trim()) {
return this.all();
}
const lowerQuery = query.toLowerCase().trim();
const results = [];
const commands = Array.from(this.commands.values());
for (const command of commands) {
let score = 0;
if (command.name.toLowerCase() === lowerQuery) {
score = 100;
} else if (command.name.toLowerCase().startsWith(lowerQuery)) {
score = 80;
} else if (command.name.toLowerCase().includes(lowerQuery)) {
score = 60;
} else if (command.aliases?.some((a) => a.toLowerCase() === lowerQuery)) {
score = 90;
} else if (command.aliases?.some((a) => a.toLowerCase().startsWith(lowerQuery))) {
score = 70;
} else if (command.aliases?.some((a) => a.toLowerCase().includes(lowerQuery))) {
score = 50;
} else if (command.description.toLowerCase().includes(lowerQuery)) {
score = 30;
}
if (score > 0) {
results.push({
command,
score
});
}
}
return results.sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name)).map((r) => r.command);
}
/**
* Get all registered commands.
* @returns Array of all commands, sorted alphabetically by name
*/
all() {
return Array.from(this.commands.values()).sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Get commands by category.
* @param category The category to filter by
* @returns Array of commands in the category
*/
byCategory(category) {
return this.all().filter((cmd) => cmd.category === category);
}
/**
* Get all available categories.
* @returns Array of unique categories
*/
categories() {
const categories = /* @__PURE__ */ new Set();
const commands = Array.from(this.commands.values());
for (const command of commands) {
categories.add(command.category);
}
return Array.from(categories).sort();
}
/**
* Get the count of registered commands.
*/
get size() {
return this.commands.size;
}
/**
* Clear all registered commands.
* Useful for testing.
*/
clear() {
this.commands.clear();
this.aliases.clear();
}
/**
* Get a serializable list of commands for API responses.
*/
toJSON() {
return this.all().map((cmd) => ({
name: cmd.name,
aliases: cmd.aliases || [],
description: cmd.description,
category: cmd.category,
inputs: cmd.inputs.map((input) => ({
name: input.name,
type: input.type,
prompt: input.prompt,
required: input.required !== false
}))
}));
}
};
var commandRegistry = new CommandRegistry();
// src/core/plugin-registry.ts
var debug12 = createDebug("plugins");
// src/commands/store.ts
var import_jotai27 = require("jotai");
// src/commands/store-atoms.ts
var import_jotai26 = require("jotai");
var import_utils3 = require("jotai/utils");
var inputModeAtom2 = (0, import_jotai26.atom)({
type: "normal"
});
var commandLineVisibleAtom = (0, import_jotai26.atom)(false);
var commandLineStateAtom = (0, import_jotai26.atom)({
phase: "idle"
});
var commandFeedbackAtom = (0, import_jotai26.atom)(null);
var commandHistoryAtom = (0, import_utils3.atomWithStorage)("canvas-command-history", []);
var selectedSuggestionIndexAtom = (0, import_jotai26.atom)(0);
var pendingInputResolverAtom2 = (0, import_jotai26.atom)(null);
var isCommandActiveAtom = (0, import_jotai26.atom)((get) => {
const state = get(commandLineStateAtom);
return state.phase === "collecting" || state.phase === "executing";
});
var currentInputAtom = (0, import_jotai26.atom)((get) => {
const state = get(commandLineStateAtom);
if (state.phase !== "collecting") return null;
return state.command.inputs[state.inputIndex];
});
var commandProgressAtom = (0, import_jotai26.atom)((get) => {
const state = get(commandLineStateAtom);
if (state.phase !== "collecting") return null;
return {
current: state.inputIndex + 1,
total: state.command.inputs.length
};
});
// src/commands/store.ts
var openCommandLineAtom = (0, import_jotai27.atom)(null, (get, set) => {
set(commandLineVisibleAtom, true);
set(commandLineStateAtom, {
phase: "searching",
query: "",
suggestions: commandRegistry.all()
});
set(selectedSuggestionIndexAtom, 0);
});
var closeCommandLineAtom = (0, import_jotai27.atom)(null, (get, set) => {
set(commandLineVisibleAtom, false);
set(commandLineStateAtom, {
phase: "idle"
});
set(inputModeAtom2, {
type: "normal"
});
set(commandFeedbackAtom, null);
set(pendingInputResolverAtom2, null);
});
var updateSearchQueryAtom = (0, import_jotai27.atom)(null, (get, set, query) => {
const suggestions = commandRegistry.search(query);
set(commandLineStateAtom, {
phase: "searching",
query,
suggestions
});
set(selectedSuggestionIndexAtom, 0);
});
var selectCommandAtom = (0, import_jotai27.atom)(null, (get, set, command) => {
const history = get(commandHistoryAtom);
const newHistory = [command.name, ...history.filter((h) => h !== command.name)].slice(0, 50);
set(commandHistoryAtom, newHistory);
if (command.inputs.length === 0) {
set(commandLineStateAtom, {
phase: "executing",
command
});
return;
}
set(commandLineStateAtom, {
phase: "collecting",
command,
inputIndex: 0,
collected: {}
});
const firstInput = command.inputs[0];
set(inputModeAtom2, inputDefToMode(firstInput));
});
var provideInputAtom2 = (0, import_jotai27.atom)(null, (get, set, value) => {
const state = get(commandLineStateAtom);
if (state.phase !== "collecting") return;
const {
command,
inputIndex,
collected
} = state;
const currentInput = command.inputs[inputIndex];
if (currentInput.validate) {
const result = currentInput.validate(value, collected);
if (result !== true) {
set(commandLineStateAtom, {
phase: "error",
message: typeof result === "string" ? result : `Invalid value for ${currentInput.name}`
});
return;
}
}
const newCollected = {
...collected,
[currentInput.name]: value
};
if (inputIndex < command.inputs.length - 1) {
const nextInputIndex = inputIndex + 1;
const nextInput = command.inputs[nextInputIndex];
set(commandLineStateAtom, {
phase: "collecting",
command,
inputIndex: nextInputIndex,
collected: newCollected
});
set(inputModeAtom2, inputDefToMode(nextInput, newCollected));
if (command.feedback) {
const feedback = command.feedback(newCollected, nextInput);
if (feedback) {
const feedbackState = {
hoveredNodeId: feedback.highlightNodeId,
ghostNode: feedback.ghostNode,
crosshair: feedback.crosshair,
// Handle previewEdge conversion - toCursor variant needs cursorWorldPos
previewEdge: feedback.previewEdge && "to" in feedback.previewEdge ? {
from: feedback.previewEdge.from,
to: feedback.previewEdge.to
} : void 0
};
set(commandFeedbackAtom, feedbackState);
} else {
set(commandFeedbackAtom, null);
}
}
} else {
set(commandLineStateAtom, {
phase: "collecting",
command,
inputIndex,
collected: newCollected
});
set(inputModeAtom2, {
type: "normal"
});
}
});
var skipInputAtom = (0, import_jotai27.atom)(null, (get, set) => {
const state = get(commandLineStateAtom);
if (state.phase !== "collecting") return;
const {
command,
inputIndex
} = state;
const currentInput = command.inputs[inputIndex];
if (currentInput.required !== false) {
return;
}
const value = currentInput.default;
set(provideInputAtom2, value);
});
var goBackInputAtom = (0, import_jotai27.atom)(null, (get, set) => {
const state = get(commandLineStateAtom);
if (state.phase !== "collecting") return;
const {
command,
inputIndex,
collected
} = state;
if (inputIndex === 0) {
set(commandLineStateAtom, {
phase: "searching",
query: command.name,
suggestions: [command]
});
set(inputModeAtom2, {
type: "normal"
});
return;
}
const prevInputIndex = inputIndex - 1;
const prevInput = command.inputs[prevInputIndex];
const newCollected = {
...collected
};
delete newCollected[prevInput.name];
set(commandLineStateAtom, {
phase: "collecting",
command,
inputIndex: prevInputIndex,
collected: newCollected
});
set(inputModeAtom2, inputDefToMode(prevInput, newCollected));
});
var setCommandErrorAtom = (0, import_jotai27.atom)(null, (get, set, message) => {
set(commandLineStateAtom, {
phase: "error",
message
});
set(inputModeAtom2, {
type: "normal"
});
});
var clearCommandErrorAtom = (0, import_jotai27.atom)(null, (get, set) => {
set(commandLineStateAtom, {
phase: "idle"
});
});
function inputDefToMode(input, collected) {
switch (input.type) {
case "point":
return {
type: "pickPoint",
prompt: input.prompt,
snapToGrid: input.snapToGrid
};
case "node":
return {
type: "pickNode",
prompt: input.prompt,
filter: input.filter ? (node) => input.filter(node, collected || {}) : void 0
};
case "nodes":
return {
type: "pickNodes",
prompt: input.prompt,
filter: input.filter ? (node) => input.filter(node, collected || {}) : void 0
};
case "select":
return {
type: "select",
prompt: input.prompt,
options: input.options || []
};
case "text":
case "number":
case "color":
case "boolean":
default:
return {
type: "text",
prompt: input.prompt
};
}
}
// src/gestures/modifier-helpers.ts
function isRepeatBlocked(event) {
return isKeyInputEvent(event) && event.repeat;
}
function getSelectedNodeIds(store) {
return Array.from(store.get(selectedNodeIdsAtom));
}
function clearSelectionState(store) {
store.set(clearSelectionAtom);
store.set(clearEdgeSelectionAtom);
}
function resolveFocusableNodeId(store) {
const focusedNodeId = store.get(focusedNodeIdAtom);
if (focusedNodeId) return focusedNodeId;
const selected = getSelectedNodeIds(store);
return selected[0] ?? null;
}
function getCurrentSubject(store) {
const draggingNodeId = store.get(draggingNodeIdAtom);
if (draggingNodeId) return {
kind: "node",
nodeId: draggingNodeId
};
const edgeCreation = store.get(edgeCreationAtom);
if (edgeCreation.isCreating && edgeCreation.sourceNodeId) {
return {
kind: "node",
nodeId: edgeCreation.sourceNodeId
};
}
const focusedNodeId = resolveFocusableNodeId(store);
if (focusedNodeId) return {
kind: "node",
nodeId: focusedNodeId
};
const selectedEdgeId = store.get(selectedEdgeIdAtom);
if (selectedEdgeId) return {
kind: "edge",
edgeId: selectedEdgeId
};
return {
kind: "background"
};
}
function updateKeySubject(event, store) {
if (event.kind === "key" && event.subject === void 0) {
event.subject = getCurrentSubject(store);
}
}
// src/gestures/gesture-classification.ts
function findNearestNode(currentNodeId, store, direction) {
const nodes = store.get(uiNodesAtom);
if (nodes.length === 0) return null;
if (!currentNodeId) {
const sorted = [...nodes].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
return sorted[0]?.id ?? null;
}
const currentNode = nodes.find((node) => node.id === currentNodeId);
if (!currentNode) return nodes[0]?.id ?? null;
const cx = currentNode.position.x + (currentNode.width ?? 200) / 2;
const cy = currentNode.position.y + (currentNode.height ?? 100) / 2;
let bestId = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const candidate of nodes) {
if (candidate.id === currentNode.id) continue;
const nx = candidate.position.x + (candidate.width ?? 200) / 2;
const ny = candidate.position.y + (candidate.height ?? 100) / 2;
const dx = nx - cx;
const dy = ny - cy;
let isValid = false;
switch (direction) {
case "right":
isValid = dx > 0;
break;
case "left":
isValid = dx < 0;
break;
case "down":
isValid = dy > 0;
break;
case "up":
isValid = dy < 0;
break;
}
if (!isValid) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
const isHorizontal = direction === "left" || direction === "right";
const perpendicularDistance = isHorizontal ? Math.abs(dy) : Math.abs(dx);
const score = dist + perpendicularDistance * 0.5;
if (score < bestScore) {
bestScore = score;
bestId = candidate.id;
}
}
return bestId;
}
function cycleFocus(store, direction) {
const nodes = store.get(uiNodesAtom);
if (nodes.length === 0) return;
const focusedNodeId = store.get(focusedNodeIdAtom);
const sorted = [...nodes].sort((a, b) => a.zIndex - b.zIndex);
const currentIdx = focusedNodeId ? sorted.findIndex((node) => node.id === focusedNodeId) : -1;
const nextIdx = direction === -1 ? currentIdx <= 0 ? sorted.length - 1 : currentIdx - 1 : (currentIdx + 1) % sorted.length;
store.set(setFocusedNodeAtom, sorted[nextIdx].id);
}
function navigateFocus(store, direction) {
const nextId = findNearestNode(resolveFocusableNodeId(store), store, direction);
if (nextId) {
store.set(setFocusedNodeAtom, nextId);
}
}
function activateFocusedNode(store, enterManipulate) {
const focusedNodeId = resolveFocusableNodeId(store);
if (!focusedNodeId) return;
store.set(setFocusedNodeAtom, focusedNodeId);
store.set(selectSingleNodeAtom, focusedNodeId);
if (enterManipulate) {
store.set(setKeyboardInteractionModeAtom, "manipulate");
}
}
// src/gestures/input-action-helpers.ts
var EMPTY_EDGE_CREATION = {
isCreating: false,
sourceNodeId: null,
sourceNodePosition: null,
targetPosition: null,
hoveredTargetNodeId: null,
sourceHandle: null,
targetHandle: null,
sourcePort: null,
targetPort: null,
snappedTargetPosition: null
};
function nudgeSelection(store, dx, dy, label) {
const selected = getSelectedNodeIds(store);
if (selected.length === 0) {
const focused = store.get(focusedNodeIdAtom);
if (focused) {
store.set(selectSingleNodeAtom, focused);
}
}
const nodeIds = getSelectedNodeIds(store);
if (nodeIds.length === 0) return;
const graph = store.get(graphAtom).copy();
store.set(pushHistoryAtom, label);
for (const nodeId of nodeIds) {
if (!graph.hasNode(nodeId)) continue;
const x = graph.getNodeAttribute(nodeId, "x");
const y = graph.getNodeAttribute(nodeId, "y");
graph.setNodeAttribute(nodeId, "x", x + dx);
graph.setNodeAttribute(nodeId, "y", y + dy);
}
store.set(graphAtom, graph);
store.set(nodePositionUpdateCounterAtom, (count) => count + 1);
}
function deleteSelection(store) {
const selectedEdgeId = store.get(selectedEdgeIdAtom);
if (selectedEdgeId) {
store.set(optimisticDeleteEdgeAtom, {
edgeKey: selectedEdgeId
});
store.set(clearEdgeSelectionAtom);
}
const selectedNodeIds = getSelectedNodeIds(store);
if (selectedNodeIds.length === 0) return;
store.set(pushHistoryAtom, selectedNodeIds.length > 1 ? `Delete ${selectedNodeIds.length} nodes` : "Delete node");
const focusedNodeId = store.get(focusedNodeIdAtom);
for (const nodeId of selectedNodeIds) {
store.set(optimisticDeleteNodeAtom, {
nodeId
});
}
if (focusedNodeId && selectedNodeIds.includes(focusedNodeId)) {
store.set(setFocusedNodeAtom, null);
}
store.set(clearSelectionAtom);
}
function cutSelection(store) {
const selectedNodeIds = getSelectedNodeIds(store);
if (selectedNodeIds.length === 0) return;
store.set(copyToClipboardAtom, selectedNodeIds);
deleteSelection(store);
}
function cancelActiveInteraction(store) {
let didCancel = false;
if (store.get(draggingNodeIdAtom) !== null) {
store.set(endNodeDragAtom);
didCancel = true;
}
if (store.get(edgeCreationAtom).isCreating) {
store.set(edgeCreationAtom, EMPTY_EDGE_CREATION);
didCancel = true;
}
if (store.get(inputModeAtom).type === "pickPoint" || store.get(inputModeAtom).type === "pickNode" || store.get(inputModeAtom).type === "pickNodes") {
store.set(resetInputModeAtom);
didCancel = true;
}
const selectionPath = store.get(selectionPathAtom);
if (selectionPath !== null) {
store.set(cancelSelectionAtom);
didCancel = true;
}
return didCancel;
}
function escapeInput(store) {
if (cancelActiveInteraction(store)) return;
if (store.get(keyboardInteractionModeAtom) === "manipulate") {
store.set(resetKeyboardInteractionModeAtom);
return;
}
if (store.get(isFilterActiveAtom)) {
store.set(clearSearchAtom);
return;
}
if (store.get(commandLineVisibleAtom)) {
store.set(closeCommandLineAtom);
return;
}
if (store.get(selectedNodeIdsAtom).size > 0 || store.get(selectedEdgeIdAtom) !== null) {
clearSelectionState(store);
}
}
function resolvePickNode(event, store) {
updateKeySubject(event, store);
if (event.subject?.kind !== "node") return;
if (store.get(inputModeAtom).type === "pickNodes") {
store.set(toggleNodeInSelectionAtom, event.subject.nodeId);
store.set(setFocusedNodeAtom, event.subject.nodeId);
return;
}
store.set(provideInputAtom, event.subject.nodeId);
store.set(resetInputModeAtom);
}
function finishPickNodes(store) {
store.set(provideInputAtom, getSelectedNodeIds(store));
store.set(resetInputModeAtom);
}
function resolvePickPoint(event, store) {
if (event.kind !== "pointer") return;
store.set(provideInputAtom, event.worldPosition);
store.set(resetInputModeAtom);
}
function selectAll(store) {
clearSelectionState(store);
store.set(addNodesToSelectionAtom, store.get(nodeKeysAtom));
}
// src/gestures/useCanvasGestures.ts
var import_react4 = require("react");
var import_jotai29 = require("jotai");
var import_react5 = require("@use-gesture/react");
// src/gestures/useGuardContext.ts
var import_react2 = require("react");
var import_jotai28 = require("jotai");
function useGuardContext(heldKeys = NO_HELD_KEYS) {
const isStylusActive = (0, import_jotai28.useAtomValue)(isStylusActiveAtom);
const fingerCount = (0, import_jotai28.useAtomValue)(fingerCountAtom);
const draggingNodeId = (0, import_jotai28.useAtomValue)(draggingNodeIdAtom);
const focusedNodeId = (0, import_jotai28.useAtomValue)(focusedNodeIdAtom);
const selectedNodeIds = (0, import_jotai28.useAtomValue)(selectedNodeIdsAtom);
const inputMode = (0, import_jotai28.useAtomValue)(inputModeAtom);
const keyboardInteractionMode = (0, import_jotai28.useAtomValue)(keyboardInteractionModeAtom);
const selectionPath = (0, import_jotai28.useAtomValue)(selectionPathAtom);
const edgeCreation = (0, import_jotai28.useAtomValue)(edgeCreationAtom);
const isSearchActive = (0, import_jotai28.useAtomValue)(isFilterActiveAtom);
const commandLineVisible = (0, import_jotai28.useAtomValue)(commandLineVisibleAtom);
const guardRef = (0, import_react2.useRef)({
isStylusActive: false,
fingerCount: 0,
isDragging: false,
isResizing: false,
isSplitting: false,
inputMode: {
type: "normal"
},
keyboardInteractionMode: "navigate",
selectedNodeIds: /* @__PURE__ */ new Set(),
focusedNodeId: null,
isSearchActive: false,
commandLineVisible: false,
heldKeys: NO_HELD_KEYS,
custom: {}
});
guardRef.current = {
isStylusActive,
fingerCount,
isDragging: draggingNodeId !== null,
isResizing: false,
isSplitting: false,
inputMode,
keyboardInteractionMode,
selectedNodeIds,
focusedNodeId,
isSearchActive,
commandLineVisible,
heldKeys,
custom: {
isSelecting: selectionPath !== null,
isCreatingEdge: edgeCreation.isCreating
}
};
return guardRef;
}
// src/gestures/useInertia.ts
var import_react3 = require("react");
var ZOOM_SNAP_THRESHOLD2 = 0.03;
var INERTIA_MIN_SPEED = 2;
var VELOCITY_SAMPLE_COUNT2 = 5;
function snapZoom(z) {
return Math.abs(z - 1) < ZOOM_SNAP_THRESHOLD2 ? 1 : z;
}
function useInertia() {
const panInertiaRef = (0, import_react3.useRef)(null);
const zoomInertiaRef = (0, import_react3.useRef)(null);
const panSamplerRef = (0, import_react3.useRef)(new VelocitySampler(VELOCITY_SAMPLE_COUNT2));
const zoomSamplerRef = (0, import_react3.useRef)(new VelocitySampler(VELOCITY_SAMPLE_COUNT2));
const pinchPrevOrigin = (0, import_react3.useRef)(null);
const lastPinchZoom = (0, import_react3.useRef)(null);
const cancelPan = () => {
if (panInertiaRef.current) {
cancelAnimationFrame(panInertiaRef.current.anim);
panInertiaRef.current = null;
}
};
const cancelZoom = () => {
if (zoomInertiaRef.current) {
cancelAnimationFrame(zoomInertiaRef.current.anim);
zoomInertiaRef.current = null;
}
};
const cancelAll = () => {
cancelPan();
cancelZoom();
};
const startPanInertia = (velocity, setPan) => {
cancelPan();
const engine = new PanInertia(velocity);
const animate = () => {
const tick = engine.tick();
if (!tick) {
panInertiaRef.current = null;
return;
}
setPan((prev) => ({
x: prev.x + tick.x,
y: prev.y + tick.y
}));
panInertiaRef.current.anim = requestAnimationFrame(animate);
};
panInertiaRef.current = {
anim: requestAnimationFrame(animate),
engine
};
};
const startZoomInertia = (velocity_0, origin, currentZoom, minZoom, maxZoom, setZoom, setPan_0) => {
cancelZoom();
const engine_0 = new ZoomInertia(velocity_0, origin);
const animate_0 = () => {
const tick_0 = engine_0.tick(currentZoom);
if (!tick_0) {
zoomInertiaRef.current = null;
return;
}
setZoom((prevZoom) => {
const newZoom = Math.max(minZoom, Math.min(maxZoom, prevZoom + tick_0.delta));
setPan_0((prevPan) => {
const worldX = (tick_0.origin.x - prevPan.x) / prevZoom;
const worldY = (tick_0.origin.y - prevPan.y) / prevZoom;
return {
x: tick_0.origin.x - worldX * newZoom,
y: tick_0.origin.y - worldY * newZoom
};
});
return snapZoom(newZoom);
});
zoomInertiaRef.current.anim = requestAnimationFrame(animate_0);
};
zoomInertiaRef.current = {
anim: requestAnimationFrame(animate_0),
engine: engine_0
};
};
(0, import_react3.useEffect)(() => cancelAll, []);
return {
startPanInertia,
startZoomInertia,
cancelAll,
cancelPan,
cancelZoom,
panSampler: panSamplerRef.current,
zoomSampler: zoomSamplerRef.current,
pinchPrevOrigin,
lastPinchZoom
};
}
// src/gestures/useWheelZoom.ts
function createWheelHandler(config) {
return ({
event,
pinching,
delta: [, dy],
memo
}) => {
if (memo === true || pinching || !config.enableZoom || !config.ref.current) return;
const target = event.target;
const noDrag = target.closest('[data-no-drag="true"]');
if (noDrag) {
const draggableNode = target.closest('[data-draggable-node="true"]');
if (draggableNode) {
const nodeId = draggableNode.getAttribute("data-node-id");
if (nodeId && config.selectedNodeIds.has(nodeId)) {
let el = target;
while (el && noDrag.contains(el)) {
if (el.scrollHeight > el.clientHeight) {
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const atTop = el.scrollTop <= 1;
if (!atBottom && dy > 0 || !atTop && dy < 0 || atBottom && dy < 0 || atTop && dy > 0) {
return;
}
}
el = el.parentElement;
}
}
}
}
event.preventDefault();
const rect = config.ref.current.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - config.pan.x) / config.zoom;
const worldY = (mouseY - config.pan.y) / config.zoom;
const zoomDelta = -dy * config.zoomSensitivity * config.zoom;
const newZoom = snapZoom(Math.max(config.minZoom, Math.min(config.maxZoom, config.zoom + zoomDelta)));
config.setZoom(newZoom);
config.setPan({
x: mouseX - worldX * newZoom,
y: mouseY - worldY * newZoom
});
};
}
// src/gestures/usePinchZoom.ts
function createPinchHandlers(config) {
const onPinchStart = ({
origin: [ox, oy]
}) => {
if (!config.enableZoom || !config.ref.current) return true;
config.inertia.cancelAll();
config.inertia.pinchPrevOrigin.current = {
x: ox,
y: oy
};
config.inertia.zoomSampler.reset();
config.inertia.lastPinchZoom.current = {
zoom: config.zoom,
t: performance.now()
};
return false;
};
const onPinch = ({
offset: [d],
origin: [ox, oy],
event,
memo
}) => {
if (memo === true || !config.enableZoom || !config.ref.current) return;
event.preventDefault();
const rect = config.ref.current.getBoundingClientRect();
const pinchX = ox - rect.left;
const pinchY = oy - rect.top;
let pdx = 0;
let pdy = 0;
if (config.inertia.pinchPrevOrigin.current) {
pdx = ox - config.inertia.pinchPrevOrigin.current.x;
pdy = oy - config.inertia.pinchPrevOrigin.current.y;
}
config.inertia.pinchPrevOrigin.current = {
x: ox,
y: oy
};
const worldX = (pinchX - config.pan.x) / config.zoom;
const worldY = (pinchY - config.pan.y) / config.zoom;
const newZoom = snapZoom(Math.max(config.minZoom, Math.min(config.maxZoom, d)));
const now = performance.now();
if (config.inertia.lastPinchZoom.current) {
const dt = (now - config.inertia.lastPinchZoom.current.t) / 1e3;
if (dt > 0) {
const v = (newZoom - config.inertia.lastPinchZoom.current.zoom) / dt;
config.inertia.zoomSampler.sample(v, 0, now);
}
}
config.inertia.lastPinchZoom.current = {
zoom: newZoom,
t: now
};
config.setZoom(newZoom);
config.setPan({
x: pinchX - worldX * newZoom + pdx,
y: pinchY - worldY * newZoom + pdy
});
};
const onPinchEnd = () => {
const avg = config.inertia.zoomSampler.average();
const perFrameV = avg.x / 60;
if (!config.reducedMotion && Math.abs(perFrameV) > 1e-3 && config.ref.current) {
const rect = config.ref.current.getBoundingClientRect();
const origin = config.inertia.pinchPrevOrigin.current ?? {
x: rect.width / 2,
y: rect.height / 2
};
config.inertia.startZoomInertia(perFrameV, {
x: origin.x - rect.left,
y: origin.y - rect.top
}, config.zoom, config.minZoom, config.maxZoom, config.setZoom, config.setPan);
}
config.inertia.pinchPrevOrigin.current = null;
config.inertia.zoomSampler.reset();
config.inertia.lastPinchZoom.current = null;
};
return {
onPinchStart,
onPinch,
onPinchEnd
};
}
// src/gestures/useCanvasGestures.ts
function useCanvasGestures({
ref,
minZoom = 0.1,
maxZoom = 5,
zoomSensitivity = 15e-4,
enablePan = true,
enableZoom = true,
mappingIndex: externalIndex,
contexts: extraContexts,
palmRejection = true,
onAction,
heldKeys = NO_HELD_KEYS
}) {
const [panVals, setPan] = (0, import_jotai29.useAtom)(panAtom);
const [zoomVal, setZoom] = (0, import_jotai29.useAtom)(zoomAtom);
const setViewportRect = (0, import_jotai29.useSetAtom)(viewportRectAtom);
const selectedNodeIds = (0, import_jotai29.useAtomValue)(selectedNodeIdsAtom);
const clearSelection = (0, import_jotai29.useSetAtom)(clearSelectionAtom);
const screenToWorld = (0, import_jotai29.useAtomValue)(screenToWorldAtom);
const setPointerDown = (0, import_jotai29.useSetAtom)(pointerDownAtom);
const setPointerUp = (0, import_jotai29.useSetAtom)(pointerUpAtom);
const clearPointers = (0, import_jotai29.useSetAtom)(clearPointersAtom);
const primaryInputSource = (0, import_jotai29.useAtomValue)(primaryInputSourceAtom);
const reducedMotion = (0, import_jotai29.useAtomValue)(prefersReducedMotionAtom);
const startSelection = (0, import_jotai29.useSetAtom)(startSelectionAtom);
const updateSelection = (0, import_jotai29.useSetAtom)(updateSelectionAtom);
const endSelection = (0, import_jotai29.useSetAtom)(endSelectionAtom);
const guardRef = useGuardContext(heldKeys);
const inertia = useInertia();
const panStartRef = (0, import_react4.useRef)({
x: 0,
y: 0
});
const dragOriginatedOnBg = (0, import_react4.useRef)(false);
const dragSourceRef = (0, import_react4.useRef)("mouse");
const buttonRef = (0, import_react4.useRef)(0);
const dragIntentRef = (0, import_react4.useRef)("none");
const timedRunner = (0, import_react4.useRef)(new TimedStateRunner());
const internalIndex = (0, import_react4.useMemo)(() => {
const ctxList = [];
if (palmRejection) ctxList.push(PALM_REJECTION_CONTEXT);
if (extraContexts) ctxList.push(...extraContexts);
ctxList.push(DEFAULT_CONTEXT);
return buildMappingIndex(ctxList);
}, [palmRejection, extraContexts]);
const mappingIndex = externalIndex ?? internalIndex;
const isBackgroundTarget = (target) => {
if (!ref.current || !target) return false;
const child = ref.current.firstChild;
return target === ref.current || target === child;
};
const makeGestureEvent = (type, phase, subject, screenX, screenY, extra) => {
const worldPos = screenToWorld(screenX, screenY);
return {
kind: "pointer",
type,
phase,
subject,
source: dragSourceRef.current,
button: buttonRef.current,
modifiers: NO_MODIFIERS,
heldKeys,
screenPosition: {
x: screenX,
y: screenY
},
worldPosition: worldPos,
...extra
};
};
const resolveAndDispatch = (event) => {
const resolution = resolve(event, mappingIndex, guardRef.current);
if (resolution) {
dispatch(event, resolution);
onAction?.(event, resolution);
}
return resolution;
};
(0, import_react4.useEffect)(() => {
const runner = timedRunner.current;
runner.onEmit = (_gestureType) => {
};
return () => {
runner.destroy();
};
}, []);
(0, import_react4.useEffect)(() => {
const el = ref.current;
if (!el) {
setViewportRect(null);
return;
}
setViewportRect(el.getBoundingClientRect());
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setViewportRect(entry.contentRect);
}
});
observer.observe(el);
return () => {
observer.unobserve(el);
observer.disconnect();
};
}, [setViewportRect]);
(0, import_react4.useEffect)(() => {
return () => {
inertia.cancelAll();
timedRunner.current.destroy();
};
}, []);
(0, import_react4.useEffect)(() => {
const handleVisChange = () => {
if (document.hidden) {
inertia.cancelAll();
clearPointers();
timedRunner.current.feed("cancel");
}
};
document.addEventListener("visibilitychange", handleVisChange);
return () => document.removeEventListener("visibilitychange", handleVisChange);
}, [clearPointers]);
(0, import_react5.useGesture)({
onPointerDown: (state) => {
if (!ref.current) {
dragOriginatedOnBg.current = false;
return;
}
const pe = state.event;
const classified = classifyPointer(pe);
setPointerDown(classified);
dragSourceRef.current = classified.source;
buttonRef.current = pe.button ?? 0;
inertia.cancelAll();
if (isBackgroundTarget(state.event.target)) {
dragOriginatedOnBg.current = true;
timedRunner.current.feed("down");
const startX = pe.clientX;
const startY = pe.clientY;
timedRunner.current.onEmit = (gestureType) => {
if (gestureType === "long-press") {
const worldPos_0 = screenToWorld(startX, startY);
const event_0 = makeGestureEvent("long-press", "instant", {
kind: "background"
}, startX, startY, {
modifiers: extractModifiers(pe),
worldPosition: worldPos_0
});
resolveAndDispatch(event_0);
}
};
} else {
dragOriginatedOnBg.current = false;
}
},
onPointerUp: (state_0) => {
const pe_0 = state_0.event;
setPointerUp(pe_0.pointerId);
},
onDragStart: ({
event: event_1
}) => {
if (!dragOriginatedOnBg.current) return;
const pe_1 = event_1;
const modifiers = pe_1 ? extractModifiers(pe_1) : NO_MODIFIERS;
const gestureEvent = makeGestureEvent("drag", "start", {
kind: "background"
}, pe_1?.clientX ?? 0, pe_1?.clientY ?? 0, {
modifiers
});
const resolution_0 = resolve(gestureEvent, mappingIndex, guardRef.current);
dragIntentRef.current = resolution_0?.actionId ?? "none";
if (dragIntentRef.current === "pan" && enablePan) {
panStartRef.current = {
...panVals
};
inertia.panSampler.reset();
} else if (dragIntentRef.current === "lasso-select" || dragIntentRef.current === "rect-select") {
const worldPos_1 = screenToWorld(pe_1?.clientX ?? 0, pe_1?.clientY ?? 0);
startSelection({
type: dragIntentRef.current === "lasso-select" ? "lasso" : "rect",
point: worldPos_1
});
}
if (resolution_0) {
dispatch(gestureEvent, resolution_0);
onAction?.(gestureEvent, resolution_0);
}
},
onDrag: ({
movement: [mx, my],
tap,
active,
pinching,
event: event_2,
velocity: [vx, vy],
direction: [dx, dy]
}) => {
if (tap && dragOriginatedOnBg.current) {
const emitted = timedRunner.current.feed("up");
const pe_2 = event_2;
const modifiers_0 = extractModifiers(pe_2);
const tapType = emitted ?? "tap";
const gestureEvent_0 = makeGestureEvent(tapType, "instant", {
kind: "background"
}, pe_2.clientX, pe_2.clientY, {
modifiers: modifiers_0
});
const resolution_1 = resolve(gestureEvent_0, mappingIndex, guardRef.current);
if (resolution_1) {
dispatch(gestureEvent_0, resolution_1);
onAction?.(gestureEvent_0, resolution_1);
} else {
clearSelection();
}
return;
}
if (!tap && active && !pinching && dragOriginatedOnBg.current) {
timedRunner.current.feed("move-beyond-threshold");
const intent = dragIntentRef.current;
if (intent === "pan" && enablePan) {
setPan({
x: panStartRef.current.x + mx,
y: panStartRef.current.y + my
});
const now = performance.now();
inertia.panSampler.sample(vx * dx, vy * dy, now);
} else if (intent === "lasso-select" || intent === "rect-select") {
const pe_3 = event_2;
const worldPos_2 = screenToWorld(pe_3.clientX, pe_3.clientY);
updateSelection(worldPos_2);
}
}
},
onDragEnd: () => {
timedRunner.current.feed("up");
const intent_0 = dragIntentRef.current;
if (intent_0 === "lasso-select" || intent_0 === "rect-select") {
endSelection();
}
if (!reducedMotion && dragOriginatedOnBg.current && dragSourceRef.current === "finger" && intent_0 === "pan") {
const avg = inertia.panSampler.average();
const speed = Math.sqrt(avg.x ** 2 + avg.y ** 2);
if (speed > INERTIA_MIN_SPEED) {
inertia.startPanInertia(avg, setPan);
}
}
dragOriginatedOnBg.current = false;
dragIntentRef.current = "none";
inertia.panSampler.reset();
},
onWheel: createWheelHandler({
ref,
minZoom,
maxZoom,
zoomSensitivity,
enableZoom,
zoom: zoomVal,
pan: panVals,
selectedNodeIds,
setZoom,
setPan
}),
...createPinchHandlers({
ref,
minZoom,
maxZoom,
enableZoom,
reducedMotion,
zoom: zoomVal,
pan: panVals,
setZoom,
setPan,
inertia
})
}, {
target: ref,
eventOptions: {
passive: false,
capture: true
},
drag: {
filterTaps: true,
tapsThreshold: primaryInputSource === "finger" ? 10 : 5,
pointer: {
touch: true,
keys: false,
capture: false,
buttons: -1
}
},
wheel: {},
pinch: {
scaleBounds: () => ({
min: minZoom,
max: maxZoom
}),
from: () => [zoomVal, 0]
}
});
}
// src/gestures/useNodeGestures.ts
var import_react6 = require("react");
var import_jotai30 = require("jotai");
function useNodeGestures({
nodeId,
mappingIndex,
onAction,
heldKeys = NO_HELD_KEYS
}) {
const screenToWorld = (0, import_jotai30.useAtomValue)(screenToWorldAtom);
const isStylusActive = (0, import_jotai30.useAtomValue)(isStylusActiveAtom);
const fingerCount = (0, import_jotai30.useAtomValue)(fingerCountAtom);
const inputMode = (0, import_jotai30.useAtomValue)(inputModeAtom);
const keyboardInteractionMode = (0, import_jotai30.useAtomValue)(keyboardInteractionModeAtom);
const selectedNodeIds = (0, import_jotai30.useAtomValue)(selectedNodeIdsAtom);
const focusedNodeId = (0, import_jotai30.useAtomValue)(focusedNodeIdAtom);
const draggingNodeId = (0, import_jotai30.useAtomValue)(draggingNodeIdAtom);
const edgeCreation = (0, import_jotai30.useAtomValue)(edgeCreationAtom);
const selectionPath = (0, import_jotai30.useAtomValue)(selectionPathAtom);
const isSearchActive = (0, import_jotai30.useAtomValue)(isFilterActiveAtom);
const commandLineVisible = (0, import_jotai30.useAtomValue)(commandLineVisibleAtom);
const runner = (0, import_react6.useRef)(new TimedStateRunner());
const sourceRef = (0, import_react6.useRef)("mouse");
const buttonRef = (0, import_react6.useRef)(0);
const posRef = (0, import_react6.useRef)({
x: 0,
y: 0
});
const modifiersRef = (0, import_react6.useRef)(NO_MODIFIERS);
const rightDragSeen = (0, import_react6.useRef)(false);
const guardRef = (0, import_react6.useRef)({
isStylusActive: false,
fingerCount: 0,
isDragging: false,
isResizing: false,
isSplitting: false,
inputMode: {
type: "normal"
},
keyboardInteractionMode: "navigate",
selectedNodeIds: /* @__PURE__ */ new Set(),
focusedNodeId: null,
isSearchActive: false,
commandLineVisible: false,
heldKeys: NO_HELD_KEYS,
custom: {}
});
guardRef.current = {
isStylusActive,
fingerCount,
isDragging: draggingNodeId !== null,
isResizing: false,
isSplitting: false,
inputMode,
keyboardInteractionMode,
selectedNodeIds,
focusedNodeId,
isSearchActive,
commandLineVisible,
heldKeys,
custom: {}
};
guardRef.current.custom = {
isSelecting: selectionPath !== null,
isCreatingEdge: edgeCreation.isCreating
};
(0, import_react6.useEffect)(() => {
return () => runner.current.destroy();
}, []);
const resolveAndDispatch = (event) => {
const resolution = resolve(event, mappingIndex, guardRef.current);
if (resolution) {
dispatch(event, resolution);
onAction?.(event, resolution);
}
return resolution;
};
const makeEvent = (type, phase, screenX, screenY) => {
const worldPos = screenToWorld(screenX, screenY);
return {
kind: "pointer",
type,
phase,
subject: {
kind: "node",
nodeId
},
source: sourceRef.current,
button: buttonRef.current,
modifiers: modifiersRef.current,
heldKeys,
screenPosition: {
x: screenX,
y: screenY
},
worldPosition: worldPos
};
};
(0, import_react6.useEffect)(() => {
runner.current.onEmit = (gestureType) => {
if (gestureType === "long-press") {
const event_0 = makeEvent("long-press", "instant", posRef.current.x, posRef.current.y);
resolveAndDispatch(event_0);
}
};
});
const onPointerDown = (e) => {
const classified = classifyPointer(e.nativeEvent);
sourceRef.current = classified.source;
buttonRef.current = e.button ?? 0;
posRef.current = {
x: e.clientX,
y: e.clientY
};
modifiersRef.current = extractModifiers(e);
rightDragSeen.current = false;
runner.current.feed("down");
};
const onPointerUp = (e_0) => {
const emitted = runner.current.feed("up");
if (emitted) {
const event_1 = makeEvent(emitted, "instant", e_0.clientX, e_0.clientY);
resolveAndDispatch(event_1);
}
};
const onPointerCancel = (_e) => {
runner.current.feed("cancel");
};
const onContextMenu = (e_1) => {
e_1.preventDefault();
e_1.stopPropagation();
if (rightDragSeen.current) {
rightDragSeen.current = false;
return;
}
const event_2 = makeEvent("tap", "instant", e_1.clientX, e_1.clientY);
event_2.button = 2;
event_2.modifiers = extractModifiers(e_1);
resolveAndDispatch(event_2);
};
return {
onPointerDown,
onPointerUp,
onPointerCancel,
onContextMenu
};
}
// src/gestures/useGestureSystem.ts
var import_compiler_runtime2 = require("react/compiler-runtime");
var import_react7 = require("react");
function useInputSystem(t0) {
const $ = (0, import_compiler_runtime2.c)(19);
const config = t0 === void 0 ? {} : t0;
const {
contexts: staticContexts,
palmRejection: t1
} = config;
const initialPR = t1 === void 0 ? true : t1;
let t2;
if ($[0] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel")) {
t2 = [];
$[0] = t2;
} else {
t2 = $[0];
}
const [dynamicContexts, setDynamicContexts] = (0, import_react7.useState)(t2);
const [palmRejection, setPalmRejection] = (0, import_react7.useState)(initialPR);
const [heldKeys, setHeldKeys] = (0, import_react7.useState)(NO_HELD_KEYS);
const all = [];
if (palmRejection) {
all.push(PALM_REJECTION_CONTEXT);
}
all.push(ACTIVE_INTERACTION_CONTEXT, SEARCH_CONTEXT, KEYBOARD_MANIPULATE_CONTEXT, KEYBOARD_NAVIGATE_CONTEXT);
if (staticContexts) {
all.push(...staticContexts);
}
all.push(...dynamicContexts);
all.push(DEFAULT_CONTEXT);
const mappingIndex = buildMappingIndex(all);
let t3;
if ($[1] !== setDynamicContexts) {
t3 = (ctx) => {
setDynamicContexts((prev) => {
const filtered = prev.filter((c) => c.id !== ctx.id);
return [...filtered, ctx];
});
};
$[1] = setDynamicContexts;
$[2] = t3;
} else {
t3 = $[2];
}
const pushContext = t3;
let t4;
if ($[3] !== setDynamicContexts) {
t4 = (id) => {
setDynamicContexts((prev_0) => prev_0.filter((c_0) => c_0.id !== id));
};
$[3] = setDynamicContexts;
$[4] = t4;
} else {
t4 = $[4];
}
const removeContext = t4;
let t5;
if ($[5] !== setDynamicContexts) {
t5 = (id_0, enabled) => {
setDynamicContexts((prev_1) => prev_1.map((c_1) => c_1.id === id_0 ? {
...c_1,
enabled
} : c_1));
};
$[5] = setDynamicContexts;
$[6] = t5;
} else {
t5 = $[6];
}
const setContextEnabled = t5;
let t6;
if ($[7] !== setHeldKeys) {
t6 = () => {
setHeldKeys(NO_HELD_KEYS);
};
$[7] = setHeldKeys;
$[8] = t6;
} else {
t6 = $[8];
}
const clearHeldKeys = t6;
let t7;
if ($[9] !== clearHeldKeys || $[10] !== heldKeys || $[11] !== mappingIndex || $[12] !== palmRejection || $[13] !== pushContext || $[14] !== removeContext || $[15] !== setContextEnabled || $[16] !== setHeldKeys || $[17] !== setPalmRejection) {
t7 = {
mappingIndex,
pushContext,
removeContext,
setContextEnabled,
palmRejection,
setPalmRejection,
heldKeys,
setHeldKeys,
clearHeldKeys
};
$[9] = clearHeldKeys;
$[10] = heldKeys;
$[11] = mappingIndex;
$[12] = palmRejection;
$[13] = pushContext;
$[14] = removeContext;
$[15] = setContextEnabled;
$[16] = setHeldKeys;
$[17] = setPalmRejection;
$[18] = t7;
} else {
t7 = $[18];
}
return t7;
}
var useGestureSystem = useInputSystem;
// src/gestures/useInputModeGestureContext.ts
var import_compiler_runtime3 = require("react/compiler-runtime");
var import_react8 = require("react");
var import_jotai31 = require("jotai");
function useInputModeGestureContext(inputSystem) {
const $ = (0, import_compiler_runtime3.c)(4);
const inputMode = (0, import_jotai31.useAtomValue)(inputModeAtom);
let t0;
let t1;
if ($[0] !== inputMode.type || $[1] !== inputSystem) {
t0 = () => {
const modeType = inputMode.type;
const ctx = INPUT_MODE_CONTEXTS[modeType] ?? null;
if (ctx) {
inputSystem.pushContext(ctx);
} else {
for (const key of Object.keys(INPUT_MODE_CONTEXTS)) {
const existing = INPUT_MODE_CONTEXTS[key];
if (existing) {
inputSystem.removeContext(existing.id);
}
}
}
return () => {
if (ctx) {
inputSystem.removeContext(ctx.id);
}
};
};
t1 = [inputMode.type, inputSystem];
$[0] = inputMode.type;
$[1] = inputSystem;
$[2] = t0;
$[3] = t1;
} else {
t0 = $[2];
t1 = $[3];
}
(0, import_react8.useEffect)(t0, t1);
}
// src/gestures/useRegisterInputActions.ts
var import_compiler_runtime4 = require("react/compiler-runtime");
var import_react9 = require("react");
var import_jotai32 = require("jotai");
function register(actionId, handler) {
registerAction(actionId, handler);
return () => unregisterAction(actionId);
}
function useRegisterInputActions() {
const $ = (0, import_compiler_runtime4.c)(3);
const store = (0, import_jotai32.useStore)();
let t0;
let t1;
if ($[0] !== store) {
t0 = () => {
const unregister = [register("select-node", (event) => {
updateKeySubject(event, store);
if (event.subject?.kind !== "node") {
return;
}
store.set(setFocusedNodeAtom, event.subject.nodeId);
if (event.kind === "key") {
store.set(selectSingleNodeAtom, event.subject.nodeId);
}
}), register("select-edge", (event_0) => {
updateKeySubject(event_0, store);
if (event_0.subject?.kind !== "edge") {
return;
}
store.set(selectEdgeAtom, event_0.subject.edgeId);
}), register("toggle-selection", (event_1) => {
updateKeySubject(event_1, store);
if (event_1.subject?.kind !== "node") {
return;
}
store.set(setFocusedNodeAtom, event_1.subject.nodeId);
if (event_1.kind === "key") {
store.set(toggleNodeInSelectionAtom, event_1.subject.nodeId);
}
}), register("clear-selection", () => {
clearSelectionState(store);
}), register("select-all", (event_2) => {
if (isRepeatBlocked(event_2)) {
return;
}
selectAll(store);
}), register("fit-to-view", (event_3) => {
updateKeySubject(event_3, store);
if (event_3.subject?.kind === "node") {
store.set(centerOnNodeAtom, event_3.subject.nodeId);
}
}), register("fit-all-to-view", () => {
store.set(fitToBoundsAtom, {
mode: "graph"
});
}), register("toggle-lock", (event_4) => {
updateKeySubject(event_4, store);
if (event_4.subject?.kind !== "node") {
return;
}
if (store.get(lockedNodeIdAtom) === event_4.subject.nodeId) {
store.set(unlockNodeAtom);
} else {
store.set(lockNodeAtom, {
nodeId: event_4.subject.nodeId
});
}
}), register("open-command-line", (event_5) => {
if (isRepeatBlocked(event_5)) {
return;
}
store.set(openCommandLineAtom);
}), register("open-search", (event_6) => {
if (isRepeatBlocked(event_6)) {
return;
}
store.set(openCommandLineAtom);
}), register("search-next-result", () => {
store.set(nextSearchResultAtom);
}), register("search-prev-result", () => {
store.set(prevSearchResultAtom);
}), register("copy-selection", (event_7) => {
if (isRepeatBlocked(event_7)) {
return;
}
const selectedNodeIds = getSelectedNodeIds(store);
if (selectedNodeIds.length > 0) {
store.set(copyToClipboardAtom, selectedNodeIds);
}
}), register("cut-selection", (event_8) => {
if (isRepeatBlocked(event_8)) {
return;
}
cutSelection(store);
}), register("paste-selection", (event_9) => {
if (isRepeatBlocked(event_9)) {
return;
}
store.set(pasteFromClipboardAtom);
}), register("duplicate-selection", (event_10) => {
if (isRepeatBlocked(event_10)) {
return;
}
store.set(duplicateSelectionAtom);
}), register("merge-selection", (event_11) => {
if (isRepeatBlocked(event_11)) {
return;
}
const nodeIds = getSelectedNodeIds(store);
if (nodeIds.length >= 2) {
store.set(mergeNodesAtom, {
nodeIds
});
}
}), register("delete-selection", (event_12) => {
if (isRepeatBlocked(event_12)) {
return;
}
deleteSelection(store);
}), register("undo", (event_13) => {
if (isRepeatBlocked(event_13)) {
return;
}
store.set(undoAtom);
}), register("redo", (event_14) => {
if (isRepeatBlocked(event_14)) {
return;
}
store.set(redoAtom);
}), register("cancel-active-input", () => {
cancelActiveInteraction(store);
}), register("escape-input", () => {
escapeInput(store);
}), register("navigate-focus-up", () => {
navigateFocus(store, "up");
}), register("navigate-focus-down", () => {
navigateFocus(store, "down");
}), register("navigate-focus-left", () => {
navigateFocus(store, "left");
}), register("navigate-focus-right", () => {
navigateFocus(store, "right");
}), register("cycle-focus-forward", (event_15) => {
if (isRepeatBlocked(event_15)) {
return;
}
cycleFocus(store, 1);
}), register("cycle-focus-backward", (event_16) => {
if (isRepeatBlocked(event_16)) {
return;
}
cycleFocus(store, -1);
}), register("activate-focused-node", (event_17) => {
if (isRepeatBlocked(event_17)) {
return;
}
activateFocusedNode(store, false);
}), register("enter-keyboard-manipulate-mode", (event_18) => {
if (isRepeatBlocked(event_18)) {
return;
}
activateFocusedNode(store, true);
}), register("exit-keyboard-manipulate-mode", () => {
store.set(resetKeyboardInteractionModeAtom);
}), register("nudge-selection-up", () => {
nudgeSelection(store, 0, -10, "Nudge selection");
}), register("nudge-selection-down", () => {
nudgeSelection(store, 0, 10, "Nudge selection");
}), register("nudge-selection-left", () => {
nudgeSelection(store, -10, 0, "Nudge selection");
}), register("nudge-selection-right", () => {
nudgeSelection(store, 10, 0, "Nudge selection");
}), register("nudge-selection-up-large", () => {
nudgeSelection(store, 0, -50, "Nudge selection");
}), register("nudge-selection-down-large", () => {
nudgeSelection(store, 0, 50, "Nudge selection");
}), register("nudge-selection-left-large", () => {
nudgeSelection(store, -50, 0, "Nudge selection");
}), register("nudge-selection-right-large", () => {
nudgeSelection(store, 50, 0, "Nudge selection");
}), register("resolve-pick-node", (event_19) => {
resolvePickNode(event_19, store);
}), register("finish-pick-nodes", () => {
finishPickNodes(store);
}), register("resolve-pick-point", (event_20) => {
resolvePickPoint(event_20, store);
}), register("cancel-pick", () => {
store.set(resetInputModeAtom);
})];
return () => {
for (const cleanup of unregister) {
cleanup();
}
};
};
t1 = [store];
$[0] = store;
$[1] = t0;
$[2] = t1;
} else {
t0 = $[1];
t1 = $[2];
}
(0, import_react9.useEffect)(t0, t1);
}
// src/gestures/GestureProvider.tsx
var import_react10 = __toESM(require("react"));
var import_jotai33 = require("jotai");
// src/gestures/gesture-provider-utils.ts
function isEditableTarget(target) {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const editable = target.closest('input, textarea, [contenteditable="true"], [data-no-canvas-keyboard="true"]');
return editable !== null;
}
function setHeldKeyValue(current, key, isHeld) {
const previous = current[key] ?? false;
if (previous === isHeld) {
return current;
}
const next = {
...current
};
if (isHeld) {
next[key] = true;
} else {
delete next[key];
}
return next;
}
function applyHeldKeyDelta(event, current) {
const isHeld = event.type === "keydown";
const nextByKey = setHeldKeyValue(current.byKey, event.key, isHeld);
const nextByCode = setHeldKeyValue(current.byCode, event.code, isHeld);
if (nextByKey === current.byKey && nextByCode === current.byCode) {
return current;
}
return {
byKey: nextByKey,
byCode: nextByCode
};
}
function getCurrentSubject2(store) {
const draggingNodeId = store.get(draggingNodeIdAtom);
if (draggingNodeId) {
return {
kind: "node",
nodeId: draggingNodeId
};
}
const edgeCreation = store.get(edgeCreationAtom);
if (edgeCreation.isCreating && edgeCreation.sourceNodeId) {
return {
kind: "node",
nodeId: edgeCreation.sourceNodeId
};
}
const focusedNodeId = store.get(focusedNodeIdAtom);
if (focusedNodeId) {
return {
kind: "node",
nodeId: focusedNodeId
};
}
return {
kind: "background"
};
}
function getSubjectPosition(store, root, subject) {
const worldToScreen = store.get(worldToScreenAtom);
if (subject.kind === "node") {
const node = store.get(uiNodesAtom).find((entry) => entry.id === subject.nodeId);
if (!node) {
return {};
}
const worldPosition = {
x: node.position.x + (node.width ?? 200) / 2,
y: node.position.y + (node.height ?? 100) / 2
};
return {
worldPosition,
screenPosition: worldToScreen(worldPosition.x, worldPosition.y)
};
}
if (subject.kind === "background" && root) {
const rect = root.getBoundingClientRect();
const screenPosition = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
return {
screenPosition,
worldPosition: store.get(screenToWorldAtom)(screenPosition.x, screenPosition.y)
};
}
return {};
}
function buildGuardContext(store, system) {
const heldKeys = "heldKeys" in system ? system.heldKeys : system;
const edgeCreation = store.get(edgeCreationAtom);
return {
isStylusActive: store.get(isStylusActiveAtom),
fingerCount: store.get(fingerCountAtom),
isDragging: store.get(draggingNodeIdAtom) !== null,
isResizing: false,
isSplitting: false,
inputMode: store.get(inputModeAtom),
keyboardInteractionMode: store.get(keyboardInteractionModeAtom),
selectedNodeIds: store.get(selectedNodeIdsAtom),
focusedNodeId: store.get(focusedNodeIdAtom),
isSearchActive: store.get(isFilterActiveAtom),
commandLineVisible: store.get(commandLineVisibleAtom),
heldKeys,
custom: {
isSelecting: store.get(selectionPathAtom) !== null,
isCreatingEdge: edgeCreation.isCreating
}
};
}
// src/gestures/GestureProvider.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
var nextOwnerId = 1;
var activeOwnerId = null;
var InputContext = /* @__PURE__ */ (0, import_react10.createContext)(null);
function InputProvider({
children,
gestureConfig,
onAction
}) {
const store = (0, import_jotai33.useStore)();
const system = useInputSystem({
contexts: gestureConfig?.contexts,
palmRejection: gestureConfig?.palmRejection ?? true
});
useInputModeGestureContext(system);
useRegisterInputActions();
const [ownerId] = (0, import_react10.useState)(() => nextOwnerId++);
const [canvasRoot, setCanvasRoot] = (0, import_react10.useState)(null);
const rootRef = (0, import_react10.useRef)(null);
const systemRef = (0, import_react10.useRef)(system);
systemRef.current = system;
const registerCanvasRoot = (node) => {
rootRef.current = node;
setCanvasRoot(node);
};
(0, import_react10.useEffect)(() => {
const root = canvasRoot;
if (!root) {
return;
}
const activate = (event) => {
activeOwnerId = ownerId;
if (!isEditableTarget(event.target)) {
root.focus({
preventScroll: true
});
}
};
root.addEventListener("pointerdown", activate, true);
root.addEventListener("focusin", activate, true);
return () => {
root.removeEventListener("pointerdown", activate, true);
root.removeEventListener("focusin", activate, true);
if (activeOwnerId === ownerId) {
activeOwnerId = null;
}
};
}, [canvasRoot, ownerId]);
(0, import_react10.useEffect)(() => {
const handleKeyboard = (nativeEvent) => {
if (activeOwnerId !== ownerId) {
return;
}
const root_0 = rootRef.current;
if (!root_0 || !root_0.isConnected) {
return;
}
const currentSystem = systemRef.current;
const editableTarget = isEditableTarget(nativeEvent.target);
const shouldTrackHeldKey = nativeEvent.type === "keyup" || !nativeEvent.isComposing && !editableTarget;
const nextHeldKeys = shouldTrackHeldKey ? applyHeldKeyDelta(nativeEvent, currentSystem.heldKeys) : currentSystem.heldKeys;
if (nextHeldKeys !== currentSystem.heldKeys) {
currentSystem.setHeldKeys(nextHeldKeys);
}
if (nativeEvent.isComposing || editableTarget) {
return;
}
const subject = getCurrentSubject2(store);
const {
screenPosition,
worldPosition
} = getSubjectPosition(store, root_0, subject);
const inputEvent = {
kind: "key",
phase: nativeEvent.type === "keydown" ? "down" : "up",
key: nativeEvent.key,
code: nativeEvent.code,
repeat: nativeEvent.repeat,
modifiers: {
shift: nativeEvent.shiftKey,
ctrl: nativeEvent.ctrlKey,
alt: nativeEvent.altKey,
meta: nativeEvent.metaKey
},
heldKeys: nextHeldKeys,
subject,
screenPosition,
worldPosition,
originalEvent: nativeEvent
};
const resolution = resolve(inputEvent, currentSystem.mappingIndex, buildGuardContext(store, {
heldKeys: nextHeldKeys
}));
if (!resolution) {
return;
}
nativeEvent.preventDefault();
dispatch(inputEvent, resolution);
onAction?.(inputEvent, resolution);
};
const clearHeldKeys = () => {
systemRef.current.clearHeldKeys();
};
window.addEventListener("keydown", handleKeyboard, true);
window.addEventListener("keyup", handleKeyboard, true);
window.addEventListener("blur", clearHeldKeys);
document.addEventListener("visibilitychange", clearHeldKeys);
return () => {
window.removeEventListener("keydown", handleKeyboard, true);
window.removeEventListener("keyup", handleKeyboard, true);
window.removeEventListener("blur", clearHeldKeys);
document.removeEventListener("visibilitychange", clearHeldKeys);
};
}, [onAction, ownerId, store]);
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(InputContext.Provider, {
value: {
system,
onAction,
registerCanvasRoot
},
children
});
}
function useInputContext() {
const ctx = (0, import_react10.useContext)(InputContext);
if (!ctx) {
throw new Error("useInputContext must be used within an InputProvider");
}
return ctx;
}
var GestureProvider = InputProvider;
var useGestureContext = useInputContext;
//# sourceMappingURL=index.js.map