7366 lines
206 KiB
JavaScript
7366 lines
206 KiB
JavaScript
|
|
var __defProp = Object.defineProperty;
|
||
|
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||
|
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||
|
|
|
||
|
|
// 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
|
||
|
|
import { atom } from "jotai";
|
||
|
|
import Graph from "graphology";
|
||
|
|
var graphOptions = {
|
||
|
|
type: "directed",
|
||
|
|
multi: true,
|
||
|
|
allowSelfLoops: true
|
||
|
|
};
|
||
|
|
var currentGraphIdAtom = atom(null);
|
||
|
|
var graphAtom = atom(new Graph(graphOptions));
|
||
|
|
var graphUpdateVersionAtom = atom(0);
|
||
|
|
var edgeCreationAtom = atom({
|
||
|
|
isCreating: false,
|
||
|
|
sourceNodeId: null,
|
||
|
|
sourceNodePosition: null,
|
||
|
|
targetPosition: null,
|
||
|
|
hoveredTargetNodeId: null,
|
||
|
|
sourceHandle: null,
|
||
|
|
targetHandle: null,
|
||
|
|
sourcePort: null,
|
||
|
|
targetPort: null,
|
||
|
|
snappedTargetPosition: null
|
||
|
|
});
|
||
|
|
var draggingNodeIdAtom = atom(null);
|
||
|
|
var preDragNodeAttributesAtom = atom(null);
|
||
|
|
|
||
|
|
// src/core/graph-position.ts
|
||
|
|
import { atom as atom3 } from "jotai";
|
||
|
|
import { atomFamily } from "jotai-family";
|
||
|
|
import Graph2 from "graphology";
|
||
|
|
|
||
|
|
// src/utils/debug.ts
|
||
|
|
import debugFactory from "debug";
|
||
|
|
var NAMESPACE = "canvas";
|
||
|
|
function createDebug(module) {
|
||
|
|
const base = debugFactory(`${NAMESPACE}:${module}`);
|
||
|
|
const warn = debugFactory(`${NAMESPACE}:${module}:warn`);
|
||
|
|
const error = debugFactory(`${NAMESPACE}:${module}: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
|
||
|
|
import { atom as atom2 } from "jotai";
|
||
|
|
var perfEnabledAtom = atom2(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 = atom3(0);
|
||
|
|
var nodePositionAtomFamily = atomFamily((nodeId) => atom3((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 = atom3(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 = atom3(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 = atom3(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 = atom3(null, (get, set) => {
|
||
|
|
debug2("Clearing graph for switch");
|
||
|
|
set(cleanupAllNodePositionsAtom);
|
||
|
|
clearAllPendingMutations();
|
||
|
|
const emptyGraph = new Graph2(graphOptions);
|
||
|
|
set(graphAtom, emptyGraph);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/graph-derived.ts
|
||
|
|
import { atom as atom8 } from "jotai";
|
||
|
|
import { atomFamily as atomFamily2 } from "jotai-family";
|
||
|
|
|
||
|
|
// src/core/viewport-store.ts
|
||
|
|
import { atom as atom5 } from "jotai";
|
||
|
|
|
||
|
|
// src/core/selection-store.ts
|
||
|
|
import { atom as atom4 } from "jotai";
|
||
|
|
var debug3 = createDebug("selection");
|
||
|
|
var selectedNodeIdsAtom = atom4(/* @__PURE__ */ new Set());
|
||
|
|
var selectedEdgeIdAtom = atom4(null);
|
||
|
|
var handleNodePointerDownSelectionAtom = atom4(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 = atom4(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 = atom4(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 = atom4(null, (_get, set) => {
|
||
|
|
debug3("clearSelection");
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
|
||
|
|
});
|
||
|
|
var addNodesToSelectionAtom = atom4(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 = atom4(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 = atom4(null, (get, set, edgeId) => {
|
||
|
|
set(selectedEdgeIdAtom, edgeId);
|
||
|
|
if (edgeId !== null) {
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var clearEdgeSelectionAtom = atom4(null, (_get, set) => {
|
||
|
|
set(selectedEdgeIdAtom, null);
|
||
|
|
});
|
||
|
|
var focusedNodeIdAtom = atom4(null);
|
||
|
|
var setFocusedNodeAtom = atom4(null, (_get, set, nodeId) => {
|
||
|
|
set(focusedNodeIdAtom, nodeId);
|
||
|
|
});
|
||
|
|
var hasFocusedNodeAtom = atom4((get) => get(focusedNodeIdAtom) !== null);
|
||
|
|
var selectedNodesCountAtom = atom4((get) => get(selectedNodeIdsAtom).size);
|
||
|
|
var hasSelectionAtom = atom4((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 = atom5(1);
|
||
|
|
var panAtom = atom5({
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
var viewportRectAtom = atom5(null);
|
||
|
|
var screenToWorldAtom = atom5((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 = atom5((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 = atom5(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 = atom5(null, (_get, set) => {
|
||
|
|
set(zoomAtom, 1);
|
||
|
|
set(panAtom, {
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var fitToBoundsAtom = atom5(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 = atom5(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 = atom5(null);
|
||
|
|
var zoomTransitionProgressAtom = atom5(0);
|
||
|
|
var isZoomTransitioningAtom = atom5((get) => {
|
||
|
|
const progress = get(zoomTransitionProgressAtom);
|
||
|
|
return progress > 0 && progress < 1;
|
||
|
|
});
|
||
|
|
var zoomAnimationTargetAtom = atom5(null);
|
||
|
|
var animateZoomToNodeAtom = atom5(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 = atom5(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
|
||
|
|
import { atom as atom7 } from "jotai";
|
||
|
|
|
||
|
|
// src/core/history-store.ts
|
||
|
|
import { atom as atom6 } from "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 = atom6({
|
||
|
|
past: [],
|
||
|
|
future: [],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
var canUndoAtom = atom6((get) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
return history.past.length > 0 && !history.isApplying;
|
||
|
|
});
|
||
|
|
var canRedoAtom = atom6((get) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
return history.future.length > 0 && !history.isApplying;
|
||
|
|
});
|
||
|
|
var undoCountAtom = atom6((get) => get(historyStateAtom).past.length);
|
||
|
|
var redoCountAtom = atom6((get) => get(historyStateAtom).future.length);
|
||
|
|
var pushDeltaAtom = atom6(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 = atom6(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 = atom6(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 = atom6(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 = atom6(null, (_get, set) => {
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: [],
|
||
|
|
future: [],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("History cleared");
|
||
|
|
});
|
||
|
|
var historyLabelsAtom = atom6((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 = atom7(/* @__PURE__ */ new Set());
|
||
|
|
var toggleGroupCollapseAtom = atom7(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 = atom7(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 = atom7(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 = atom7((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 = atom7((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return (nodeId) => {
|
||
|
|
if (!graph.hasNode(nodeId)) return void 0;
|
||
|
|
return graph.getNodeAttribute(nodeId, "parentId");
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var isGroupNodeAtom = atom7((get) => {
|
||
|
|
const getChildren = get(nodeChildrenAtom);
|
||
|
|
return (nodeId) => getChildren(nodeId).length > 0;
|
||
|
|
});
|
||
|
|
var groupChildCountAtom = atom7((get) => {
|
||
|
|
const getChildren = get(nodeChildrenAtom);
|
||
|
|
return (groupId) => getChildren(groupId).length;
|
||
|
|
});
|
||
|
|
var setNodeParentAtom = atom7(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 = atom7(null, (get, set, {
|
||
|
|
nodeIds,
|
||
|
|
groupId
|
||
|
|
}) => {
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
set(setNodeParentAtom, {
|
||
|
|
nodeId,
|
||
|
|
parentId: groupId
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var removeFromGroupAtom = atom7(null, (get, set, nodeId) => {
|
||
|
|
set(setNodeParentAtom, {
|
||
|
|
nodeId,
|
||
|
|
parentId: void 0
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var groupSelectedNodesAtom = atom7(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 = atom7(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 = atom7(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 = atom7((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 = atom7(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 = atom8((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 = atom8((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 = atom8((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return graph.nodes();
|
||
|
|
});
|
||
|
|
var nodeFamilyAtom = atomFamily2((nodeId) => atom8((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 = atom8((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return graph.edges();
|
||
|
|
});
|
||
|
|
var edgeKeysWithTempEdgeAtom = atom8((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 = atomFamily2((key) => atom8((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
|
||
|
|
import { atom as atom12 } from "jotai";
|
||
|
|
import Graph3 from "graphology";
|
||
|
|
|
||
|
|
// src/core/graph-mutations-edges.ts
|
||
|
|
import { atom as atom10 } from "jotai";
|
||
|
|
|
||
|
|
// src/core/reduced-motion-store.ts
|
||
|
|
import { atom as atom9 } from "jotai";
|
||
|
|
var prefersReducedMotionAtom = atom9(typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false);
|
||
|
|
var watchReducedMotionAtom = atom9(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 = atom10(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 = atom10(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 = atom10(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 = atom10(/* @__PURE__ */ new Map());
|
||
|
|
var EDGE_ANIMATION_DURATION = 300;
|
||
|
|
var removeEdgeWithAnimationAtom = atom10(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 = atom10(null);
|
||
|
|
var updateEdgeLabelAtom = atom10(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
|
||
|
|
import { atom as atom11 } from "jotai";
|
||
|
|
var debug6 = createDebug("graph:mutations:advanced");
|
||
|
|
var dropTargetNodeIdAtom = atom11(null);
|
||
|
|
var splitNodeAtom = atom11(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 = atom11(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 = atom12(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 = atom12(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 = atom12(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 = atom12(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 = atom12(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 = atom12(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 Graph3(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
|
||
|
|
import { atom as atom13 } from "jotai";
|
||
|
|
var debug8 = createDebug("sync");
|
||
|
|
var syncStatusAtom = atom13("synced");
|
||
|
|
var pendingMutationsCountAtom = atom13(0);
|
||
|
|
var isOnlineAtom = atom13(typeof navigator !== "undefined" ? navigator.onLine : true);
|
||
|
|
var lastSyncErrorAtom = atom13(null);
|
||
|
|
var lastSyncTimeAtom = atom13(Date.now());
|
||
|
|
var mutationQueueAtom = atom13([]);
|
||
|
|
var syncStateAtom = atom13((get) => ({
|
||
|
|
status: get(syncStatusAtom),
|
||
|
|
pendingMutations: get(pendingMutationsCountAtom),
|
||
|
|
lastError: get(lastSyncErrorAtom),
|
||
|
|
lastSyncTime: get(lastSyncTimeAtom),
|
||
|
|
isOnline: get(isOnlineAtom),
|
||
|
|
queuedMutations: get(mutationQueueAtom).length
|
||
|
|
}));
|
||
|
|
var startMutationAtom = atom13(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 = atom13(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 = atom13(null, (_get, set, error) => {
|
||
|
|
set(lastSyncErrorAtom, error);
|
||
|
|
debug8("Mutation failed: %s", error);
|
||
|
|
});
|
||
|
|
var setOnlineStatusAtom = atom13(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 = atom13(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 = atom13(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 = atom13(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 = atom13((get) => {
|
||
|
|
const queue = get(mutationQueueAtom);
|
||
|
|
return queue.find((m) => m.retryCount < m.maxRetries) ?? null;
|
||
|
|
});
|
||
|
|
var clearMutationQueueAtom = atom13(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
|
||
|
|
import { atom as atom14 } from "jotai";
|
||
|
|
var inputModeAtom = atom14({
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
var keyboardInteractionModeAtom = atom14("navigate");
|
||
|
|
var interactionFeedbackAtom = atom14(null);
|
||
|
|
var pendingInputResolverAtom = atom14(null);
|
||
|
|
var resetInputModeAtom = atom14(null, (_get, set) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
set(interactionFeedbackAtom, null);
|
||
|
|
set(pendingInputResolverAtom, null);
|
||
|
|
});
|
||
|
|
var resetKeyboardInteractionModeAtom = atom14(null, (_get, set) => {
|
||
|
|
set(keyboardInteractionModeAtom, "navigate");
|
||
|
|
});
|
||
|
|
var setKeyboardInteractionModeAtom = atom14(null, (_get, set, mode) => {
|
||
|
|
set(keyboardInteractionModeAtom, mode);
|
||
|
|
});
|
||
|
|
var startPickNodeAtom = atom14(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickNode",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var startPickNodesAtom = atom14(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickNodes",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var startPickPointAtom = atom14(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickPoint",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var provideInputAtom = atom14(null, (get, set, value) => {
|
||
|
|
set(pendingInputResolverAtom, value);
|
||
|
|
});
|
||
|
|
var updateInteractionFeedbackAtom = atom14(null, (get, set, feedback) => {
|
||
|
|
const current = get(interactionFeedbackAtom);
|
||
|
|
set(interactionFeedbackAtom, {
|
||
|
|
...current,
|
||
|
|
...feedback
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var isPickingModeAtom = atom14((get) => {
|
||
|
|
const mode = get(inputModeAtom);
|
||
|
|
return mode.type !== "normal";
|
||
|
|
});
|
||
|
|
var isPickNodeModeAtom = atom14((get) => {
|
||
|
|
const mode = get(inputModeAtom);
|
||
|
|
return mode.type === "pickNode" || mode.type === "pickNodes";
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/locked-node-store.ts
|
||
|
|
import { atom as atom15 } from "jotai";
|
||
|
|
var lockedNodeIdAtom = atom15(null);
|
||
|
|
var lockedNodeDataAtom = atom15((get) => {
|
||
|
|
const id = get(lockedNodeIdAtom);
|
||
|
|
if (!id) return null;
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
return nodes.find((n) => n.id === id) || null;
|
||
|
|
});
|
||
|
|
var lockedNodePageIndexAtom = atom15(0);
|
||
|
|
var lockedNodePageCountAtom = atom15(1);
|
||
|
|
var lockNodeAtom = atom15(null, (_get, set, payload) => {
|
||
|
|
set(lockedNodeIdAtom, payload.nodeId);
|
||
|
|
set(lockedNodePageIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var unlockNodeAtom = atom15(null, (_get, set) => {
|
||
|
|
set(lockedNodeIdAtom, null);
|
||
|
|
});
|
||
|
|
var nextLockedPageAtom = atom15(null, (get, set) => {
|
||
|
|
const current = get(lockedNodePageIndexAtom);
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
set(lockedNodePageIndexAtom, (current + 1) % pageCount);
|
||
|
|
});
|
||
|
|
var prevLockedPageAtom = atom15(null, (get, set) => {
|
||
|
|
const current = get(lockedNodePageIndexAtom);
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
set(lockedNodePageIndexAtom, (current - 1 + pageCount) % pageCount);
|
||
|
|
});
|
||
|
|
var goToLockedPageAtom = atom15(null, (get, set, index) => {
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
if (index >= 0 && index < pageCount) {
|
||
|
|
set(lockedNodePageIndexAtom, index);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var hasLockedNodeAtom = atom15((get) => get(lockedNodeIdAtom) !== null);
|
||
|
|
|
||
|
|
// src/core/node-type-registry.tsx
|
||
|
|
import { c as _c } from "react/compiler-runtime";
|
||
|
|
import React from "react";
|
||
|
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
||
|
|
|
||
|
|
// src/core/toast-store.ts
|
||
|
|
import { atom as atom16 } from "jotai";
|
||
|
|
var canvasToastAtom = atom16(null);
|
||
|
|
var showToastAtom = atom16(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
|
||
|
|
import { atom as atom17 } from "jotai";
|
||
|
|
var snapEnabledAtom = atom17(false);
|
||
|
|
var snapGridSizeAtom = atom17(20);
|
||
|
|
var snapTemporaryDisableAtom = atom17(false);
|
||
|
|
var isSnappingActiveAtom = atom17((get) => {
|
||
|
|
return get(snapEnabledAtom) && !get(snapTemporaryDisableAtom);
|
||
|
|
});
|
||
|
|
var toggleSnapAtom = atom17(null, (get, set) => {
|
||
|
|
set(snapEnabledAtom, !get(snapEnabledAtom));
|
||
|
|
});
|
||
|
|
var setGridSizeAtom = atom17(null, (_get, set, size) => {
|
||
|
|
set(snapGridSizeAtom, Math.max(5, Math.min(200, size)));
|
||
|
|
});
|
||
|
|
var snapAlignmentEnabledAtom = atom17(true);
|
||
|
|
var toggleAlignmentGuidesAtom = atom17(null, (get, set) => {
|
||
|
|
set(snapAlignmentEnabledAtom, !get(snapAlignmentEnabledAtom));
|
||
|
|
});
|
||
|
|
var alignmentGuidesAtom = atom17({
|
||
|
|
verticalGuides: [],
|
||
|
|
horizontalGuides: []
|
||
|
|
});
|
||
|
|
var clearAlignmentGuidesAtom = atom17(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
|
||
|
|
import { atom as atom18 } from "jotai";
|
||
|
|
import { atomWithStorage } from "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 = atomWithStorage("@blinksgg/canvas/settings", DEFAULT_STATE);
|
||
|
|
var eventMappingsAtom = atom18((get) => get(canvasSettingsAtom).mappings);
|
||
|
|
var activePresetIdAtom = atom18((get) => get(canvasSettingsAtom).activePresetId);
|
||
|
|
var allPresetsAtom = atom18((get) => {
|
||
|
|
const state = get(canvasSettingsAtom);
|
||
|
|
return [...BUILT_IN_PRESETS, ...state.customPresets];
|
||
|
|
});
|
||
|
|
var activePresetAtom = atom18((get) => {
|
||
|
|
const presetId = get(activePresetIdAtom);
|
||
|
|
if (!presetId) return null;
|
||
|
|
const allPresets = get(allPresetsAtom);
|
||
|
|
return allPresets.find((p) => p.id === presetId) || null;
|
||
|
|
});
|
||
|
|
var isPanelOpenAtom = atom18((get) => get(canvasSettingsAtom).isPanelOpen);
|
||
|
|
var virtualizationEnabledAtom = atom18((get) => get(canvasSettingsAtom).virtualizationEnabled ?? true);
|
||
|
|
var hasUnsavedChangesAtom = atom18((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 = atom18(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 = atom18(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 = atom18(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 = atom18(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 = atom18(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 = atom18(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
mappings: DEFAULT_MAPPINGS,
|
||
|
|
activePresetId: "default"
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var togglePanelAtom = atom18(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
isPanelOpen: !current.isPanelOpen
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var setPanelOpenAtom = atom18(null, (get, set, isOpen) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
isPanelOpen: isOpen
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var setVirtualizationEnabledAtom = atom18(null, (get, set, enabled) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
virtualizationEnabled: enabled
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var toggleVirtualizationAtom = atom18(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
virtualizationEnabled: !(current.virtualizationEnabled ?? true)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/canvas-serializer.ts
|
||
|
|
import Graph4 from "graphology";
|
||
|
|
|
||
|
|
// src/core/clipboard-store.ts
|
||
|
|
import { atom as atom19 } from "jotai";
|
||
|
|
var debug11 = createDebug("clipboard");
|
||
|
|
var PASTE_OFFSET = {
|
||
|
|
x: 50,
|
||
|
|
y: 50
|
||
|
|
};
|
||
|
|
var clipboardAtom = atom19(null);
|
||
|
|
var hasClipboardContentAtom = atom19((get) => get(clipboardAtom) !== null);
|
||
|
|
var clipboardNodeCountAtom = atom19((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 = atom19(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 = atom19(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 = atom19(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 = atom19(null, (get, set) => {
|
||
|
|
set(copyToClipboardAtom);
|
||
|
|
return set(pasteFromClipboardAtom);
|
||
|
|
});
|
||
|
|
var clearClipboardAtom = atom19(null, (_get, set) => {
|
||
|
|
set(clipboardAtom, null);
|
||
|
|
debug11("Clipboard cleared");
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/virtualization-store.ts
|
||
|
|
import { atom as atom20 } from "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 = atom20((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 = atom20((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 = atom20((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 = atom20((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 = atom20((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
|
||
|
|
import { atom as atom21 } from "jotai";
|
||
|
|
var activePointersAtom = atom21(/* @__PURE__ */ new Map());
|
||
|
|
var primaryInputSourceAtom = atom21("mouse");
|
||
|
|
var inputCapabilitiesAtom = atom21(detectInputCapabilities());
|
||
|
|
var isStylusActiveAtom = atom21((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "pencil") return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
});
|
||
|
|
var isMultiTouchAtom = atom21((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
let fingerCount = 0;
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "finger") fingerCount++;
|
||
|
|
}
|
||
|
|
return fingerCount > 1;
|
||
|
|
});
|
||
|
|
var fingerCountAtom = atom21((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
let count = 0;
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "finger") count++;
|
||
|
|
}
|
||
|
|
return count;
|
||
|
|
});
|
||
|
|
var isTouchDeviceAtom = atom21((get) => {
|
||
|
|
const caps = get(inputCapabilitiesAtom);
|
||
|
|
return caps.hasTouch;
|
||
|
|
});
|
||
|
|
var pointerDownAtom = atom21(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 = atom21(null, (get, set, pointerId) => {
|
||
|
|
const pointers = new Map(get(activePointersAtom));
|
||
|
|
pointers.delete(pointerId);
|
||
|
|
set(activePointersAtom, pointers);
|
||
|
|
});
|
||
|
|
var clearPointersAtom = atom21(null, (_get, set) => {
|
||
|
|
set(activePointersAtom, /* @__PURE__ */ new Map());
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/selection-path-store.ts
|
||
|
|
import { atom as atom22 } from "jotai";
|
||
|
|
var selectionPathAtom = atom22(null);
|
||
|
|
var isSelectingAtom = atom22((get) => get(selectionPathAtom) !== null);
|
||
|
|
var startSelectionAtom = atom22(null, (_get, set, {
|
||
|
|
type,
|
||
|
|
point
|
||
|
|
}) => {
|
||
|
|
set(selectionPathAtom, {
|
||
|
|
type,
|
||
|
|
points: [point]
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var updateSelectionAtom = atom22(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 = atom22(null, (_get, set) => {
|
||
|
|
set(selectionPathAtom, null);
|
||
|
|
});
|
||
|
|
var endSelectionAtom = atom22(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 = atom22((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
|
||
|
|
import { atom as atom23 } from "jotai";
|
||
|
|
var searchQueryAtom = atom23("");
|
||
|
|
var setSearchQueryAtom = atom23(null, (_get, set, query) => {
|
||
|
|
set(searchQueryAtom, query);
|
||
|
|
set(highlightedSearchIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var clearSearchAtom = atom23(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 = atom23((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 = atom23((get) => {
|
||
|
|
return Array.from(get(searchResultsAtom));
|
||
|
|
});
|
||
|
|
var searchResultCountAtom = atom23((get) => {
|
||
|
|
return get(searchResultsAtom).size;
|
||
|
|
});
|
||
|
|
var searchEdgeResultsAtom = atom23((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 = atom23((get) => {
|
||
|
|
return get(searchEdgeResultsAtom).size;
|
||
|
|
});
|
||
|
|
var isFilterActiveAtom = atom23((get) => {
|
||
|
|
return get(searchQueryAtom).trim().length > 0;
|
||
|
|
});
|
||
|
|
var searchTotalResultCountAtom = atom23((get) => {
|
||
|
|
return get(searchResultCountAtom) + get(searchEdgeResultCountAtom);
|
||
|
|
});
|
||
|
|
var highlightedSearchIndexAtom = atom23(0);
|
||
|
|
var nextSearchResultAtom = atom23(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 = atom23(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 = atom23((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
|
||
|
|
import { atom as atom24 } from "jotai";
|
||
|
|
import { atomWithStorage as atomWithStorage2 } from "jotai/utils";
|
||
|
|
var DEFAULT_RULE_STATE = {
|
||
|
|
customRules: [],
|
||
|
|
palmRejection: true
|
||
|
|
};
|
||
|
|
var gestureRuleSettingsAtom = atomWithStorage2("canvas-gesture-rules", DEFAULT_RULE_STATE);
|
||
|
|
var consumerGestureRulesAtom = atom24([]);
|
||
|
|
var gestureRulesAtom = atom24((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 = atom24((get) => {
|
||
|
|
return buildRuleIndex(get(gestureRulesAtom));
|
||
|
|
});
|
||
|
|
var palmRejectionEnabledAtom = atom24((get) => get(gestureRuleSettingsAtom).palmRejection, (get, set, enabled) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
palmRejection: enabled
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var addGestureRuleAtom = atom24(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 = atom24(null, (get, set, ruleId) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: current.customRules.filter((r) => r.id !== ruleId)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var updateGestureRuleAtom = atom24(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 = atom24(null, (get, set) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: []
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/external-keyboard-store.ts
|
||
|
|
import { atom as atom25 } from "jotai";
|
||
|
|
var hasExternalKeyboardAtom = atom25(false);
|
||
|
|
var watchExternalKeyboardAtom = atom25(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
|
||
|
|
import { atom as atom27 } from "jotai";
|
||
|
|
|
||
|
|
// src/commands/store-atoms.ts
|
||
|
|
import { atom as atom26 } from "jotai";
|
||
|
|
import { atomWithStorage as atomWithStorage3 } from "jotai/utils";
|
||
|
|
var inputModeAtom2 = atom26({
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
var commandLineVisibleAtom = atom26(false);
|
||
|
|
var commandLineStateAtom = atom26({
|
||
|
|
phase: "idle"
|
||
|
|
});
|
||
|
|
var commandFeedbackAtom = atom26(null);
|
||
|
|
var commandHistoryAtom = atomWithStorage3("canvas-command-history", []);
|
||
|
|
var selectedSuggestionIndexAtom = atom26(0);
|
||
|
|
var pendingInputResolverAtom2 = atom26(null);
|
||
|
|
var isCommandActiveAtom = atom26((get) => {
|
||
|
|
const state = get(commandLineStateAtom);
|
||
|
|
return state.phase === "collecting" || state.phase === "executing";
|
||
|
|
});
|
||
|
|
var currentInputAtom = atom26((get) => {
|
||
|
|
const state = get(commandLineStateAtom);
|
||
|
|
if (state.phase !== "collecting") return null;
|
||
|
|
return state.command.inputs[state.inputIndex];
|
||
|
|
});
|
||
|
|
var commandProgressAtom = atom26((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 = atom27(null, (get, set) => {
|
||
|
|
set(commandLineVisibleAtom, true);
|
||
|
|
set(commandLineStateAtom, {
|
||
|
|
phase: "searching",
|
||
|
|
query: "",
|
||
|
|
suggestions: commandRegistry.all()
|
||
|
|
});
|
||
|
|
set(selectedSuggestionIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var closeCommandLineAtom = atom27(null, (get, set) => {
|
||
|
|
set(commandLineVisibleAtom, false);
|
||
|
|
set(commandLineStateAtom, {
|
||
|
|
phase: "idle"
|
||
|
|
});
|
||
|
|
set(inputModeAtom2, {
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
set(commandFeedbackAtom, null);
|
||
|
|
set(pendingInputResolverAtom2, null);
|
||
|
|
});
|
||
|
|
var updateSearchQueryAtom = atom27(null, (get, set, query) => {
|
||
|
|
const suggestions = commandRegistry.search(query);
|
||
|
|
set(commandLineStateAtom, {
|
||
|
|
phase: "searching",
|
||
|
|
query,
|
||
|
|
suggestions
|
||
|
|
});
|
||
|
|
set(selectedSuggestionIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var selectCommandAtom = atom27(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 = atom27(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 = atom27(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 = atom27(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 = atom27(null, (get, set, message) => {
|
||
|
|
set(commandLineStateAtom, {
|
||
|
|
phase: "error",
|
||
|
|
message
|
||
|
|
});
|
||
|
|
set(inputModeAtom2, {
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var clearCommandErrorAtom = atom27(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
|
||
|
|
import { useRef as useRef3, useEffect as useEffect2, useMemo } from "react";
|
||
|
|
import { useAtom, useSetAtom, useAtomValue as useAtomValue2 } from "jotai";
|
||
|
|
import { useGesture } from "@use-gesture/react";
|
||
|
|
|
||
|
|
// src/gestures/useGuardContext.ts
|
||
|
|
import { useRef } from "react";
|
||
|
|
import { useAtomValue } from "jotai";
|
||
|
|
function useGuardContext(heldKeys = NO_HELD_KEYS) {
|
||
|
|
const isStylusActive = useAtomValue(isStylusActiveAtom);
|
||
|
|
const fingerCount = useAtomValue(fingerCountAtom);
|
||
|
|
const draggingNodeId = useAtomValue(draggingNodeIdAtom);
|
||
|
|
const focusedNodeId = useAtomValue(focusedNodeIdAtom);
|
||
|
|
const selectedNodeIds = useAtomValue(selectedNodeIdsAtom);
|
||
|
|
const inputMode = useAtomValue(inputModeAtom);
|
||
|
|
const keyboardInteractionMode = useAtomValue(keyboardInteractionModeAtom);
|
||
|
|
const selectionPath = useAtomValue(selectionPathAtom);
|
||
|
|
const edgeCreation = useAtomValue(edgeCreationAtom);
|
||
|
|
const isSearchActive = useAtomValue(isFilterActiveAtom);
|
||
|
|
const commandLineVisible = useAtomValue(commandLineVisibleAtom);
|
||
|
|
const guardRef = 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
|
||
|
|
import { useRef as useRef2, useEffect } from "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 = useRef2(null);
|
||
|
|
const zoomInertiaRef = useRef2(null);
|
||
|
|
const panSamplerRef = useRef2(new VelocitySampler(VELOCITY_SAMPLE_COUNT2));
|
||
|
|
const zoomSamplerRef = useRef2(new VelocitySampler(VELOCITY_SAMPLE_COUNT2));
|
||
|
|
const pinchPrevOrigin = useRef2(null);
|
||
|
|
const lastPinchZoom = useRef2(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
|
||
|
|
};
|
||
|
|
};
|
||
|
|
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] = useAtom(panAtom);
|
||
|
|
const [zoomVal, setZoom] = useAtom(zoomAtom);
|
||
|
|
const setViewportRect = useSetAtom(viewportRectAtom);
|
||
|
|
const selectedNodeIds = useAtomValue2(selectedNodeIdsAtom);
|
||
|
|
const clearSelection = useSetAtom(clearSelectionAtom);
|
||
|
|
const screenToWorld = useAtomValue2(screenToWorldAtom);
|
||
|
|
const setPointerDown = useSetAtom(pointerDownAtom);
|
||
|
|
const setPointerUp = useSetAtom(pointerUpAtom);
|
||
|
|
const clearPointers = useSetAtom(clearPointersAtom);
|
||
|
|
const primaryInputSource = useAtomValue2(primaryInputSourceAtom);
|
||
|
|
const reducedMotion = useAtomValue2(prefersReducedMotionAtom);
|
||
|
|
const startSelection = useSetAtom(startSelectionAtom);
|
||
|
|
const updateSelection = useSetAtom(updateSelectionAtom);
|
||
|
|
const endSelection = useSetAtom(endSelectionAtom);
|
||
|
|
const guardRef = useGuardContext(heldKeys);
|
||
|
|
const inertia = useInertia();
|
||
|
|
const panStartRef = useRef3({
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
const dragOriginatedOnBg = useRef3(false);
|
||
|
|
const dragSourceRef = useRef3("mouse");
|
||
|
|
const buttonRef = useRef3(0);
|
||
|
|
const dragIntentRef = useRef3("none");
|
||
|
|
const timedRunner = useRef3(new TimedStateRunner());
|
||
|
|
const internalIndex = 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;
|
||
|
|
};
|
||
|
|
useEffect2(() => {
|
||
|
|
const runner = timedRunner.current;
|
||
|
|
runner.onEmit = (_gestureType) => {
|
||
|
|
};
|
||
|
|
return () => {
|
||
|
|
runner.destroy();
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
useEffect2(() => {
|
||
|
|
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]);
|
||
|
|
useEffect2(() => {
|
||
|
|
return () => {
|
||
|
|
inertia.cancelAll();
|
||
|
|
timedRunner.current.destroy();
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
useEffect2(() => {
|
||
|
|
const handleVisChange = () => {
|
||
|
|
if (document.hidden) {
|
||
|
|
inertia.cancelAll();
|
||
|
|
clearPointers();
|
||
|
|
timedRunner.current.feed("cancel");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
document.addEventListener("visibilitychange", handleVisChange);
|
||
|
|
return () => document.removeEventListener("visibilitychange", handleVisChange);
|
||
|
|
}, [clearPointers]);
|
||
|
|
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
|
||
|
|
import { useRef as useRef4, useEffect as useEffect3 } from "react";
|
||
|
|
import { useAtomValue as useAtomValue3 } from "jotai";
|
||
|
|
function useNodeGestures({
|
||
|
|
nodeId,
|
||
|
|
mappingIndex,
|
||
|
|
onAction,
|
||
|
|
heldKeys = NO_HELD_KEYS
|
||
|
|
}) {
|
||
|
|
const screenToWorld = useAtomValue3(screenToWorldAtom);
|
||
|
|
const isStylusActive = useAtomValue3(isStylusActiveAtom);
|
||
|
|
const fingerCount = useAtomValue3(fingerCountAtom);
|
||
|
|
const inputMode = useAtomValue3(inputModeAtom);
|
||
|
|
const keyboardInteractionMode = useAtomValue3(keyboardInteractionModeAtom);
|
||
|
|
const selectedNodeIds = useAtomValue3(selectedNodeIdsAtom);
|
||
|
|
const focusedNodeId = useAtomValue3(focusedNodeIdAtom);
|
||
|
|
const draggingNodeId = useAtomValue3(draggingNodeIdAtom);
|
||
|
|
const edgeCreation = useAtomValue3(edgeCreationAtom);
|
||
|
|
const selectionPath = useAtomValue3(selectionPathAtom);
|
||
|
|
const isSearchActive = useAtomValue3(isFilterActiveAtom);
|
||
|
|
const commandLineVisible = useAtomValue3(commandLineVisibleAtom);
|
||
|
|
const runner = useRef4(new TimedStateRunner());
|
||
|
|
const sourceRef = useRef4("mouse");
|
||
|
|
const buttonRef = useRef4(0);
|
||
|
|
const posRef = useRef4({
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
const modifiersRef = useRef4(NO_MODIFIERS);
|
||
|
|
const rightDragSeen = useRef4(false);
|
||
|
|
const guardRef = useRef4({
|
||
|
|
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
|
||
|
|
};
|
||
|
|
useEffect3(() => {
|
||
|
|
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
|
||
|
|
};
|
||
|
|
};
|
||
|
|
useEffect3(() => {
|
||
|
|
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
|
||
|
|
import { c as _c2 } from "react/compiler-runtime";
|
||
|
|
import { useState } from "react";
|
||
|
|
function useInputSystem(t0) {
|
||
|
|
const $ = _c2(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] = useState(t2);
|
||
|
|
const [palmRejection, setPalmRejection] = useState(initialPR);
|
||
|
|
const [heldKeys, setHeldKeys] = 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
|
||
|
|
import { c as _c3 } from "react/compiler-runtime";
|
||
|
|
import { useEffect as useEffect4 } from "react";
|
||
|
|
import { useAtomValue as useAtomValue4 } from "jotai";
|
||
|
|
function useInputModeGestureContext(inputSystem) {
|
||
|
|
const $ = _c3(4);
|
||
|
|
const inputMode = useAtomValue4(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];
|
||
|
|
}
|
||
|
|
useEffect4(t0, t1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/gestures/useRegisterInputActions.ts
|
||
|
|
import { c as _c4 } from "react/compiler-runtime";
|
||
|
|
import { useEffect as useEffect5 } from "react";
|
||
|
|
import { useStore } from "jotai";
|
||
|
|
function register(actionId, handler) {
|
||
|
|
registerAction(actionId, handler);
|
||
|
|
return () => unregisterAction(actionId);
|
||
|
|
}
|
||
|
|
function useRegisterInputActions() {
|
||
|
|
const $ = _c4(3);
|
||
|
|
const store = 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];
|
||
|
|
}
|
||
|
|
useEffect5(t0, t1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/gestures/GestureProvider.tsx
|
||
|
|
import React2, { createContext, useContext, useEffect as useEffect6, useRef as useRef5, useState as useState2 } from "react";
|
||
|
|
import { useStore as useStore2 } from "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
|
||
|
|
import { jsx as _jsx2 } from "react/jsx-runtime";
|
||
|
|
var nextOwnerId = 1;
|
||
|
|
var activeOwnerId = null;
|
||
|
|
var InputContext = /* @__PURE__ */ createContext(null);
|
||
|
|
function InputProvider({
|
||
|
|
children,
|
||
|
|
gestureConfig,
|
||
|
|
onAction
|
||
|
|
}) {
|
||
|
|
const store = useStore2();
|
||
|
|
const system = useInputSystem({
|
||
|
|
contexts: gestureConfig?.contexts,
|
||
|
|
palmRejection: gestureConfig?.palmRejection ?? true
|
||
|
|
});
|
||
|
|
useInputModeGestureContext(system);
|
||
|
|
useRegisterInputActions();
|
||
|
|
const [ownerId] = useState2(() => nextOwnerId++);
|
||
|
|
const [canvasRoot, setCanvasRoot] = useState2(null);
|
||
|
|
const rootRef = useRef5(null);
|
||
|
|
const systemRef = useRef5(system);
|
||
|
|
systemRef.current = system;
|
||
|
|
const registerCanvasRoot = (node) => {
|
||
|
|
rootRef.current = node;
|
||
|
|
setCanvasRoot(node);
|
||
|
|
};
|
||
|
|
useEffect6(() => {
|
||
|
|
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]);
|
||
|
|
useEffect6(() => {
|
||
|
|
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__ */ _jsx2(InputContext.Provider, {
|
||
|
|
value: {
|
||
|
|
system,
|
||
|
|
onAction,
|
||
|
|
registerCanvasRoot
|
||
|
|
},
|
||
|
|
children
|
||
|
|
});
|
||
|
|
}
|
||
|
|
function useInputContext() {
|
||
|
|
const ctx = useContext(InputContext);
|
||
|
|
if (!ctx) {
|
||
|
|
throw new Error("useInputContext must be used within an InputProvider");
|
||
|
|
}
|
||
|
|
return ctx;
|
||
|
|
}
|
||
|
|
var GestureProvider = InputProvider;
|
||
|
|
var useGestureContext = useInputContext;
|
||
|
|
export {
|
||
|
|
ACTIVE_INTERACTION_CONTEXT,
|
||
|
|
DEFAULT_CONTEXT,
|
||
|
|
GestureProvider,
|
||
|
|
IDLE,
|
||
|
|
INPUT_MODE_CONTEXTS,
|
||
|
|
InputProvider,
|
||
|
|
KEYBOARD_MANIPULATE_CONTEXT,
|
||
|
|
KEYBOARD_NAVIGATE_CONTEXT,
|
||
|
|
LONG_PRESS_TIMER,
|
||
|
|
NO_HELD_KEYS,
|
||
|
|
NO_MODIFIERS,
|
||
|
|
PALM_REJECTION_CONTEXT,
|
||
|
|
PICK_NODES_CONTEXT,
|
||
|
|
PICK_NODE_CONTEXT,
|
||
|
|
PICK_POINT_CONTEXT,
|
||
|
|
PanInertia,
|
||
|
|
SEARCH_CONTEXT,
|
||
|
|
SETTLE_TIMER,
|
||
|
|
TimedStateRunner,
|
||
|
|
VelocitySampler,
|
||
|
|
ZoomInertia,
|
||
|
|
activateFocusedNode,
|
||
|
|
buildMappingIndex,
|
||
|
|
cancelActiveInteraction,
|
||
|
|
clearHandlers,
|
||
|
|
createPinchHandlers,
|
||
|
|
createWheelHandler,
|
||
|
|
cutSelection,
|
||
|
|
cycleFocus,
|
||
|
|
deleteSelection,
|
||
|
|
dispatch,
|
||
|
|
escapeInput,
|
||
|
|
extractModifiers,
|
||
|
|
findNearestNode,
|
||
|
|
getCurrentSubject,
|
||
|
|
getHandler,
|
||
|
|
indexContext,
|
||
|
|
isKeyInputEvent,
|
||
|
|
isPointerGestureEvent,
|
||
|
|
navigateFocus,
|
||
|
|
normalizePointer,
|
||
|
|
nudgeSelection,
|
||
|
|
registerAction,
|
||
|
|
resolve,
|
||
|
|
snapZoom,
|
||
|
|
specificity,
|
||
|
|
transition,
|
||
|
|
unregisterAction,
|
||
|
|
useCanvasGestures,
|
||
|
|
useGestureContext,
|
||
|
|
useGestureSystem,
|
||
|
|
useGuardContext,
|
||
|
|
useInertia,
|
||
|
|
useInputContext,
|
||
|
|
useInputModeGestureContext,
|
||
|
|
useInputSystem,
|
||
|
|
useNodeGestures,
|
||
|
|
useRegisterInputActions
|
||
|
|
};
|
||
|
|
//# sourceMappingURL=index.mjs.map
|