canvas/dist/core/index.mjs

5249 lines
155 KiB
JavaScript
Raw Normal View History

2026-03-11 18:42:08 -07:00
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/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() {
}
function canvasWrap(name, fn) {
const end = canvasMark(name);
try {
return fn();
} finally {
end();
}
}
// 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 ZOOM_TRANSITION_THRESHOLD = 3.5;
var ZOOM_EXIT_THRESHOLD = 2;
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);
});
function getNodeDescendants(graph, groupId) {
const descendants = [];
const stack = [groupId];
while (stack.length > 0) {
const current = stack.pop();
graph.forEachNode((nodeId, attrs) => {
if (attrs.parentId === current) {
descendants.push(nodeId);
stack.push(nodeId);
}
});
}
return descendants;
}
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);
});
function isNodeCollapsed(nodeId, getParentId, collapsed) {
let current = nodeId;
while (true) {
const parentId = getParentId(current);
if (!parentId) return false;
if (collapsed.has(parentId)) return true;
current = parentId;
}
}
// 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";
var nodeTypeRegistry = /* @__PURE__ */ new Map();
function registerNodeType(nodeType, component) {
nodeTypeRegistry.set(nodeType, component);
}
function registerNodeTypes(types) {
for (const [nodeType, component] of Object.entries(types)) {
nodeTypeRegistry.set(nodeType, component);
}
}
function unregisterNodeType(nodeType) {
return nodeTypeRegistry.delete(nodeType);
}
function getNodeTypeComponent(nodeType) {
if (!nodeType) return void 0;
return nodeTypeRegistry.get(nodeType);
}
function hasNodeTypeComponent(nodeType) {
if (!nodeType) return false;
return nodeTypeRegistry.has(nodeType);
}
function getRegisteredNodeTypes() {
return Array.from(nodeTypeRegistry.keys());
}
function clearNodeTypeRegistry() {
nodeTypeRegistry.clear();
}
var FallbackNodeTypeComponent = (t0) => {
const $ = _c(11);
const {
nodeData
} = t0;
let t1;
if ($[0] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel")) {
t1 = {
padding: "12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "#666",
fontSize: "12px"
};
$[0] = t1;
} else {
t1 = $[0];
}
const t2 = nodeData.dbData.node_type || "none";
let t3;
if ($[1] !== t2) {
t3 = /* @__PURE__ */ _jsxs("div", {
children: ["Unknown type: ", t2]
});
$[1] = t2;
$[2] = t3;
} else {
t3 = $[2];
}
let t4;
if ($[3] === /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel")) {
t4 = {
marginTop: "4px",
opacity: 0.7
};
$[3] = t4;
} else {
t4 = $[3];
}
let t5;
if ($[4] !== nodeData.id) {
t5 = nodeData.id.substring(0, 8);
$[4] = nodeData.id;
$[5] = t5;
} else {
t5 = $[5];
}
let t6;
if ($[6] !== t5) {
t6 = /* @__PURE__ */ _jsx("div", {
style: t4,
children: t5
});
$[6] = t5;
$[7] = t6;
} else {
t6 = $[7];
}
let t7;
if ($[8] !== t3 || $[9] !== t6) {
t7 = /* @__PURE__ */ _jsxs("div", {
style: t1,
children: [t3, t6]
});
$[8] = t3;
$[9] = t6;
$[10] = t7;
} else {
t7 = $[10];
}
return t7;
};
// 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);
});
function snapToGrid(pos, gridSize) {
return {
x: Math.round(pos.x / gridSize) * gridSize,
y: Math.round(pos.y / gridSize) * gridSize
};
}
function conditionalSnap(pos, gridSize, isActive) {
return isActive ? snapToGrid(pos, gridSize) : pos;
}
function getSnapGuides(pos, gridSize, tolerance = 5) {
const snappedX = Math.round(pos.x / gridSize) * gridSize;
const snappedY = Math.round(pos.y / gridSize) * gridSize;
return {
x: Math.abs(pos.x - snappedX) < tolerance ? snappedX : null,
y: Math.abs(pos.y - snappedY) < tolerance ? snappedY : null
};
}
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: []
});
});
function findAlignmentGuides(dragged, others, tolerance = 5) {
const verticals = /* @__PURE__ */ new Set();
const horizontals = /* @__PURE__ */ new Set();
const dragCX = dragged.x + dragged.width / 2;
const dragCY = dragged.y + dragged.height / 2;
const dragRight = dragged.x + dragged.width;
const dragBottom = dragged.y + dragged.height;
for (const other of others) {
const otherCX = other.x + other.width / 2;
const otherCY = other.y + other.height / 2;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
if (Math.abs(dragCX - otherCX) < tolerance) verticals.add(otherCX);
if (Math.abs(dragged.x - other.x) < tolerance) verticals.add(other.x);
if (Math.abs(dragRight - otherRight) < tolerance) verticals.add(otherRight);
if (Math.abs(dragged.x - otherRight) < tolerance) verticals.add(otherRight);
if (Math.abs(dragRight - other.x) < tolerance) verticals.add(other.x);
if (Math.abs(dragCX - other.x) < tolerance) verticals.add(other.x);
if (Math.abs(dragCX - otherRight) < tolerance) verticals.add(otherRight);
if (Math.abs(dragCY - otherCY) < tolerance) horizontals.add(otherCY);
if (Math.abs(dragged.y - other.y) < tolerance) horizontals.add(other.y);
if (Math.abs(dragBottom - otherBottom) < tolerance) horizontals.add(otherBottom);
if (Math.abs(dragged.y - otherBottom) < tolerance) horizontals.add(otherBottom);
if (Math.abs(dragBottom - other.y) < tolerance) horizontals.add(other.y);
if (Math.abs(dragCY - other.y) < tolerance) horizontals.add(other.y);
if (Math.abs(dragCY - otherBottom) < tolerance) horizontals.add(otherBottom);
}
return {
verticalGuides: Array.from(verticals),
horizontalGuides: Array.from(horizontals)
};
}
// 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() {
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
id: BuiltInActionId.ClearSelection,
label: "Clear Selection",
description: "Deselect all nodes",
category: ActionCategory.Selection,
icon: "x-square",
isBuiltIn: true,
handler: (_context, helpers) => {
helpers.clearSelection();
}
});
registerAction({
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() {
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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);
}
}
});
registerAction({
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());
}
}
});
registerAction({
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() {
registerAction({
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);
}
}
});
registerAction({
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");
}
});
registerAction({
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);
}
}
});
registerAction({
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() {
registerAction({
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();
}
}
});
registerAction({
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();
}
}
});
registerAction({
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() {
registerAction({
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 registerAction(action) {
actionRegistry.set(action.id, action);
}
function getAction(id) {
return actionRegistry.get(id);
}
function hasAction(id) {
return actionRegistry.has(id);
}
function getAllActions() {
return Array.from(actionRegistry.values());
}
function getActionsByCategory(category) {
return getAllActions().filter((action) => action.category === category);
}
function unregisterAction(id) {
return actionRegistry.delete(id);
}
function clearActions() {
actionRegistry.clear();
}
registerBuiltInActions();
function getActionsByCategories() {
const categoryLabels = {
[ActionCategory.None]: "None",
[ActionCategory.Selection]: "Selection",
[ActionCategory.Viewport]: "Viewport",
[ActionCategory.Node]: "Node",
[ActionCategory.Layout]: "Layout",
[ActionCategory.History]: "History",
[ActionCategory.Custom]: "Custom"
};
const categoryOrder = [ActionCategory.None, ActionCategory.Selection, ActionCategory.Viewport, ActionCategory.Node, ActionCategory.Layout, ActionCategory.History, ActionCategory.Custom];
return categoryOrder.map((category) => ({
category,
label: categoryLabels[category],
actions: getActionsByCategory(category)
})).filter((group) => group.actions.length > 0);
}
// src/core/action-executor.ts
var debug9 = createDebug("actions");
async function executeAction(actionId, context, helpers) {
if (actionId === BuiltInActionId.None) {
return {
success: true,
actionId
};
}
const action = getAction(actionId);
if (!action) {
debug9.warn("Action not found: %s", actionId);
return {
success: false,
actionId,
error: new Error(`Action not found: ${actionId}`)
};
}
if (action.requiresNode && !context.nodeId) {
debug9.warn("Action %s requires a node context", actionId);
return {
success: false,
actionId,
error: new Error(`Action ${actionId} requires a node context`)
};
}
try {
const result = action.handler(context, helpers);
if (result instanceof Promise) {
await result;
}
return {
success: true,
actionId
};
} catch (error) {
debug9.error("Error executing action %s: %O", actionId, error);
return {
success: false,
actionId,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
function createActionContext(eventType, screenEvent, worldPosition, options) {
return {
eventType,
nodeId: options?.nodeId,
nodeData: options?.nodeData,
edgeId: options?.edgeId,
edgeData: options?.edgeData,
worldPosition,
screenPosition: {
x: screenEvent.clientX,
y: screenEvent.clientY
},
modifiers: {
shift: false,
ctrl: false,
alt: false,
meta: false
}
};
}
function createActionContextFromReactEvent(eventType, event, worldPosition, options) {
return {
eventType,
nodeId: options?.nodeId,
nodeData: options?.nodeData,
edgeId: options?.edgeId,
edgeData: options?.edgeData,
worldPosition,
screenPosition: {
x: event.clientX,
y: event.clientY
},
modifiers: {
shift: event.shiftKey,
ctrl: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey
}
};
}
function createActionContextFromTouchEvent(eventType, touch, worldPosition, options) {
return {
eventType,
nodeId: options?.nodeId,
nodeData: options?.nodeData,
edgeId: options?.edgeId,
edgeData: options?.edgeData,
worldPosition,
screenPosition: {
x: touch.clientX,
y: touch.clientY
},
modifiers: {
shift: false,
ctrl: false,
alt: false,
meta: false
}
};
}
function buildActionHelpers(store, options = {}) {
return {
selectNode: (nodeId) => store.set(selectSingleNodeAtom, nodeId),
addToSelection: (nodeId) => store.set(addNodesToSelectionAtom, [nodeId]),
clearSelection: () => store.set(clearSelectionAtom),
getSelectedNodeIds: () => Array.from(store.get(selectedNodeIdsAtom)),
fitToBounds: (mode, padding) => {
const fitMode = mode === "graph" ? FitToBoundsMode.Graph : FitToBoundsMode.Selection;
store.set(fitToBoundsAtom, {
mode: fitMode,
padding
});
},
centerOnNode: (nodeId) => store.set(centerOnNodeAtom, nodeId),
resetViewport: () => store.set(resetViewportAtom),
lockNode: (nodeId) => store.set(lockNodeAtom, {
nodeId
}),
unlockNode: (_nodeId) => store.set(unlockNodeAtom),
toggleLock: (nodeId) => {
const currentLockedId = store.get(lockedNodeIdAtom);
if (currentLockedId === nodeId) {
store.set(unlockNodeAtom);
} else {
store.set(lockNodeAtom, {
nodeId
});
}
},
deleteNode: async (nodeId) => {
if (options.onDeleteNode) {
await options.onDeleteNode(nodeId);
} else {
debug9.warn("deleteNode called but onDeleteNode callback not provided");
}
},
isNodeLocked: (nodeId) => store.get(lockedNodeIdAtom) === nodeId,
applyForceLayout: async () => {
if (options.onApplyForceLayout) {
await options.onApplyForceLayout();
} else {
debug9.warn("applyForceLayout called but onApplyForceLayout callback not provided");
}
},
undo: () => store.set(undoAtom),
redo: () => store.set(redoAtom),
canUndo: () => store.get(canUndoAtom),
canRedo: () => store.get(canRedoAtom),
selectEdge: (edgeId) => store.set(selectEdgeAtom, edgeId),
clearEdgeSelection: () => store.set(clearEdgeSelectionAtom),
openContextMenu: options.onOpenContextMenu,
createNode: options.onCreateNode
};
}
// 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
}
}];
function getActionForEvent(mappings, event) {
return mappings[event] || BuiltInActionId.None;
}
// 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";
var SNAPSHOT_VERSION = 1;
function exportGraph(store, metadata) {
const graph = store.get(graphAtom);
const zoom = store.get(zoomAtom);
const pan = store.get(panAtom);
const collapsed = store.get(collapsedGroupsAtom);
const nodes = [];
const groups = [];
const seenGroupParents = /* @__PURE__ */ new Set();
graph.forEachNode((nodeId, attrs) => {
const a = attrs;
nodes.push({
id: nodeId,
position: {
x: a.x,
y: a.y
},
dimensions: {
width: a.width,
height: a.height
},
size: a.size,
color: a.color,
zIndex: a.zIndex,
label: a.label,
parentId: a.parentId,
dbData: a.dbData
});
if (a.parentId) {
const key = `${nodeId}:${a.parentId}`;
if (!seenGroupParents.has(key)) {
seenGroupParents.add(key);
groups.push({
nodeId,
parentId: a.parentId,
isCollapsed: collapsed.has(a.parentId)
});
}
}
});
const edges = [];
graph.forEachEdge((key, attrs, source, target) => {
const a = attrs;
edges.push({
key,
sourceId: source,
targetId: target,
attributes: {
weight: a.weight,
type: a.type,
color: a.color,
label: a.label
},
dbData: a.dbData
});
});
return {
version: SNAPSHOT_VERSION,
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
nodes,
edges,
groups,
viewport: {
zoom,
pan: {
...pan
}
},
metadata
};
}
function importGraph(store, snapshot, options = {}) {
const {
clearExisting = true,
offsetPosition,
remapIds = false
} = options;
const idMap = /* @__PURE__ */ new Map();
if (remapIds) {
for (const node of snapshot.nodes) {
idMap.set(node.id, crypto.randomUUID());
}
for (const edge of snapshot.edges) {
idMap.set(edge.key, crypto.randomUUID());
}
}
const remap = (id) => idMap.get(id) ?? id;
let graph;
if (clearExisting) {
graph = new Graph4(graphOptions);
} else {
graph = store.get(graphAtom);
}
const ox = offsetPosition?.x ?? 0;
const oy = offsetPosition?.y ?? 0;
for (const node of snapshot.nodes) {
const nodeId = remap(node.id);
const parentId = node.parentId ? remap(node.parentId) : void 0;
const dbData = remapIds ? {
...node.dbData,
id: nodeId
} : node.dbData;
const attrs = {
x: node.position.x + ox,
y: node.position.y + oy,
width: node.dimensions.width,
height: node.dimensions.height,
size: node.size,
color: node.color,
zIndex: node.zIndex,
label: node.label,
parentId,
dbData
};
graph.addNode(nodeId, attrs);
}
for (const edge of snapshot.edges) {
const edgeKey = remap(edge.key);
const sourceId = remap(edge.sourceId);
const targetId = remap(edge.targetId);
if (!graph.hasNode(sourceId) || !graph.hasNode(targetId)) continue;
const dbData = remapIds ? {
...edge.dbData,
id: edgeKey,
source_node_id: sourceId,
target_node_id: targetId
} : edge.dbData;
const attrs = {
weight: edge.attributes.weight,
type: edge.attributes.type,
color: edge.attributes.color,
label: edge.attributes.label,
dbData
};
graph.addEdgeWithKey(edgeKey, sourceId, targetId, attrs);
}
store.set(graphAtom, graph);
store.set(graphUpdateVersionAtom, (v) => v + 1);
store.set(nodePositionUpdateCounterAtom, (c) => c + 1);
const collapsedSet = /* @__PURE__ */ new Set();
for (const group of snapshot.groups) {
if (group.isCollapsed) {
collapsedSet.add(remap(group.parentId));
}
}
store.set(collapsedGroupsAtom, collapsedSet);
store.set(zoomAtom, snapshot.viewport.zoom);
store.set(panAtom, {
...snapshot.viewport.pan
});
}
function validateSnapshot(data) {
const errors = [];
if (!data || typeof data !== "object") {
return {
valid: false,
errors: ["Snapshot must be a non-null object"]
};
}
const obj = data;
if (obj.version !== SNAPSHOT_VERSION) {
errors.push(`Expected version ${SNAPSHOT_VERSION}, got ${String(obj.version)}`);
}
if (typeof obj.exportedAt !== "string") {
errors.push('Missing or invalid "exportedAt" (expected ISO string)');
}
if (!Array.isArray(obj.nodes)) {
errors.push('Missing or invalid "nodes" (expected array)');
} else {
for (let i = 0; i < obj.nodes.length; i++) {
const node = obj.nodes[i];
if (!node || typeof node !== "object") {
errors.push(`nodes[${i}]: expected object`);
continue;
}
if (typeof node.id !== "string") errors.push(`nodes[${i}]: missing "id"`);
if (!node.position || typeof node.position !== "object") errors.push(`nodes[${i}]: missing "position"`);
if (!node.dimensions || typeof node.dimensions !== "object") errors.push(`nodes[${i}]: missing "dimensions"`);
if (!node.dbData || typeof node.dbData !== "object") errors.push(`nodes[${i}]: missing "dbData"`);
}
}
if (!Array.isArray(obj.edges)) {
errors.push('Missing or invalid "edges" (expected array)');
} else {
for (let i = 0; i < obj.edges.length; i++) {
const edge = obj.edges[i];
if (!edge || typeof edge !== "object") {
errors.push(`edges[${i}]: expected object`);
continue;
}
if (typeof edge.key !== "string") errors.push(`edges[${i}]: missing "key"`);
if (typeof edge.sourceId !== "string") errors.push(`edges[${i}]: missing "sourceId"`);
if (typeof edge.targetId !== "string") errors.push(`edges[${i}]: missing "targetId"`);
if (!edge.dbData || typeof edge.dbData !== "object") errors.push(`edges[${i}]: missing "dbData"`);
}
}
if (!Array.isArray(obj.groups)) {
errors.push('Missing or invalid "groups" (expected array)');
}
if (!obj.viewport || typeof obj.viewport !== "object") {
errors.push('Missing or invalid "viewport" (expected object)');
} else {
const vp = obj.viewport;
if (typeof vp.zoom !== "number") errors.push('viewport: missing "zoom"');
if (!vp.pan || typeof vp.pan !== "object") errors.push('viewport: missing "pan"');
}
return {
valid: errors.length === 0,
errors
};
}
// 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/canvas-api.ts
function createCanvasAPI(store, options = {}) {
const helpers = buildActionHelpers(store, options);
const api = {
// Selection
selectNode: (id) => store.set(selectSingleNodeAtom, id),
addToSelection: (ids) => store.set(addNodesToSelectionAtom, ids),
clearSelection: () => store.set(clearSelectionAtom),
getSelectedNodeIds: () => Array.from(store.get(selectedNodeIdsAtom)),
selectEdge: (edgeId) => store.set(selectEdgeAtom, edgeId),
clearEdgeSelection: () => store.set(clearEdgeSelectionAtom),
getSelectedEdgeId: () => store.get(selectedEdgeIdAtom),
// Viewport
getZoom: () => store.get(zoomAtom),
setZoom: (zoom) => store.set(zoomAtom, zoom),
getPan: () => store.get(panAtom),
setPan: (pan) => store.set(panAtom, pan),
resetViewport: () => store.set(resetViewportAtom),
fitToBounds: (mode, padding) => {
const fitMode = mode === "graph" ? FitToBoundsMode.Graph : FitToBoundsMode.Selection;
store.set(fitToBoundsAtom, {
mode: fitMode,
padding
});
},
centerOnNode: (nodeId) => store.set(centerOnNodeAtom, nodeId),
// Graph
addNode: (node) => store.set(addNodeToLocalGraphAtom, node),
removeNode: (nodeId) => store.set(optimisticDeleteNodeAtom, {
nodeId
}),
addEdge: (edge) => store.set(addEdgeToLocalGraphAtom, edge),
removeEdge: (edgeKey) => store.set(optimisticDeleteEdgeAtom, {
edgeKey
}),
getNodeKeys: () => store.get(nodeKeysAtom),
getEdgeKeys: () => store.get(edgeKeysAtom),
getNodeAttributes: (id) => {
const graph = store.get(graphAtom);
return graph.hasNode(id) ? graph.getNodeAttributes(id) : void 0;
},
// History
undo: () => store.set(undoAtom),
redo: () => store.set(redoAtom),
canUndo: () => store.get(canUndoAtom),
canRedo: () => store.get(canRedoAtom),
recordSnapshot: (label) => store.set(pushHistoryAtom, label),
clearHistory: () => store.set(clearHistoryAtom),
// Clipboard
copy: () => store.set(copyToClipboardAtom),
cut: () => store.set(cutToClipboardAtom),
paste: () => store.set(pasteFromClipboardAtom),
duplicate: () => store.set(duplicateSelectionAtom),
hasClipboardContent: () => store.get(clipboardAtom) !== null,
// Snap
isSnapEnabled: () => store.get(snapEnabledAtom),
toggleSnap: () => store.set(toggleSnapAtom),
getSnapGridSize: () => store.get(snapGridSizeAtom),
// Virtualization
isVirtualizationEnabled: () => store.get(virtualizationEnabledAtom),
getVisibleNodeKeys: () => store.get(visibleNodeKeysAtom),
getVisibleEdgeKeys: () => store.get(visibleEdgeKeysAtom),
// Actions
executeAction: (actionId, context) => executeAction(actionId, context, helpers),
executeEventAction: (event, context) => {
const mappings = store.get(eventMappingsAtom);
const actionId = getActionForEvent(mappings, event);
return executeAction(actionId, context, helpers);
},
// Serialization
exportSnapshot: (metadata) => exportGraph(store, metadata),
importSnapshot: (snapshot, options2) => importGraph(store, snapshot, options2),
validateSnapshot: (data) => validateSnapshot(data)
};
return api;
}
// src/core/port-types.ts
function calculatePortPosition(nodeX, nodeY, nodeWidth, nodeHeight, port) {
switch (port.side) {
case "left":
return {
x: nodeX,
y: nodeY + nodeHeight * port.position
};
case "right":
return {
x: nodeX + nodeWidth,
y: nodeY + nodeHeight * port.position
};
case "top":
return {
x: nodeX + nodeWidth * port.position,
y: nodeY
};
case "bottom":
return {
x: nodeX + nodeWidth * port.position,
y: nodeY + nodeHeight
};
}
}
var DEFAULT_PORT = {
id: "default",
type: "bidirectional",
side: "right",
position: 0.5
};
function getNodePorts(ports) {
if (ports && ports.length > 0) {
return ports;
}
return [DEFAULT_PORT];
}
function canPortAcceptConnection(port, currentConnections, isSource) {
if (isSource && port.type === "input") {
return false;
}
if (!isSource && port.type === "output") {
return false;
}
if (port.maxConnections !== void 0 && currentConnections >= port.maxConnections) {
return false;
}
return true;
}
function arePortsCompatible(sourcePort, targetPort) {
if (sourcePort.type === "input") {
return false;
}
if (targetPort.type === "output") {
return false;
}
return true;
}
// 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
};
}
function getGestureThresholds(source) {
switch (source) {
case "finger":
return {
dragThreshold: 10,
tapThreshold: 10,
longPressDuration: 600,
longPressMoveLimit: 10
};
case "pencil":
return {
dragThreshold: 2,
tapThreshold: 3,
longPressDuration: 500,
longPressMoveLimit: 5
};
case "mouse":
return {
dragThreshold: 3,
tapThreshold: 5,
longPressDuration: 0,
// Mouse uses right-click instead
longPressMoveLimit: 0
};
}
}
var HIT_TARGET_SIZES = {
/** Minimum touch target (Apple HIG: 44pt) */
finger: 44,
/** Stylus target (precise, can use smaller targets) */
pencil: 24,
/** Mouse target (hover-discoverable, smallest) */
mouse: 16
};
function getHitTargetSize(source) {
return HIT_TARGET_SIZES[source];
}
// 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
var MODIFIER_KEYS = ["shift", "ctrl", "alt", "meta"];
var SOURCE_LABELS = {
mouse: "Mouse",
pencil: "Pencil",
finger: "Touch"
};
var GESTURE_LABELS = {
tap: "Tap",
"double-tap": "Double-tap",
"triple-tap": "Triple-tap",
drag: "Drag",
"long-press": "Long-press",
"right-click": "Right-click",
pinch: "Pinch",
scroll: "Scroll"
};
var TARGET_LABELS = {
node: "node",
edge: "edge",
port: "port",
"resize-handle": "resize handle",
background: "background"
};
var BUTTON_LABELS = {
0: "Left",
1: "Middle",
2: "Right"
};
function formatRuleLabel(pattern) {
const parts = [];
if (pattern.modifiers) {
const mods = MODIFIER_KEYS.filter((k) => pattern.modifiers[k]).map((k) => k.charAt(0).toUpperCase() + k.slice(1));
if (mods.length) parts.push(mods.join("+"));
}
if (pattern.button !== void 0 && pattern.button !== 0) {
parts.push(BUTTON_LABELS[pattern.button]);
}
if (pattern.source) {
parts.push(SOURCE_LABELS[pattern.source]);
}
if (pattern.gesture) {
parts.push(GESTURE_LABELS[pattern.gesture] ?? pattern.gesture);
}
if (pattern.target) {
parts.push("on " + (TARGET_LABELS[pattern.target] ?? pattern.target));
}
if (parts.length === 0) return "Any gesture";
if (pattern.modifiers) {
const modCount = MODIFIER_KEYS.filter((k) => pattern.modifiers[k]).length;
if (modCount > 0 && parts.length > modCount) {
const modPart = parts.slice(0, 1).join("");
const rest = parts.slice(1).join(" ").toLowerCase();
return `${modPart} + ${rest}`;
}
}
return parts.join(" ");
}
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
var MODIFIER_KEYS2 = ["shift", "ctrl", "alt", "meta"];
function matchSpecificity(pattern, desc) {
let score = 0;
if (pattern.gesture !== void 0) {
if (pattern.gesture !== desc.gesture) return -1;
score += 32;
}
if (pattern.target !== void 0) {
if (pattern.target !== desc.target) return -1;
score += 16;
}
if (pattern.source !== void 0) {
if (pattern.source !== desc.source) return -1;
score += 4;
}
if (pattern.button !== void 0) {
if (pattern.button !== (desc.button ?? 0)) return -1;
score += 2;
}
if (pattern.modifiers !== void 0) {
const dm = desc.modifiers ?? {};
for (const key of MODIFIER_KEYS2) {
const required = pattern.modifiers[key];
if (required === void 0) continue;
const actual = dm[key] ?? false;
if (required !== actual) return -1;
score += 8;
}
}
return score;
}
var PALM_REJECTION_RULE = {
id: "__palm-rejection__",
pattern: {},
actionId: "none",
label: "Palm rejection"
};
function resolveGesture(desc, rules, options) {
const palmRejection = options?.palmRejection !== false;
if (palmRejection && desc.isStylusActive && desc.source === "finger") {
if (desc.gesture === "tap" || desc.gesture === "long-press" || desc.gesture === "double-tap" || desc.gesture === "triple-tap") {
return {
actionId: "none",
rule: PALM_REJECTION_RULE,
score: Infinity
};
}
if (desc.gesture === "drag" && desc.target !== "background") {
return resolveGesture({
...desc,
target: "background",
isStylusActive: false
}, rules, {
palmRejection: false
});
}
}
let best = null;
for (const rule of rules) {
const specificity = matchSpecificity(rule.pattern, desc);
if (specificity < 0) continue;
const effectiveScore = specificity * 1e3 + (rule.priority ?? 0);
if (!best || effectiveScore > best.score) {
best = {
actionId: rule.actionId,
rule,
score: effectiveScore
};
}
}
return best;
}
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;
}
function resolveGestureIndexed(desc, index, options) {
const rules = index.get(desc.gesture) ?? index.get("__wildcard__") ?? [];
return resolveGesture(desc, rules, options);
}
// 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/core/plugin-types.ts
var PluginError = class extends Error {
constructor(message, pluginId, code) {
super(`[Plugin "${pluginId}"] ${message}`);
this.pluginId = pluginId;
this.code = code;
this.name = "PluginError";
}
};
// 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({})
});
// src/gestures/dispatcher.ts
var handlers = /* @__PURE__ */ new Map();
function registerAction2(actionId, handler) {
handlers.set(actionId, handler);
}
function unregisterAction2(actionId) {
handlers.delete(actionId);
}
// 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/utils/edge-path-registry.ts
var customCalculators = /* @__PURE__ */ new Map();
function registerEdgePathCalculator(name, calculator) {
customCalculators.set(name, calculator);
}
function unregisterEdgePathCalculator(name) {
return customCalculators.delete(name);
}
// src/core/plugin-registry.ts
var debug12 = createDebug("plugins");
var plugins = /* @__PURE__ */ new Map();
function registerPlugin(plugin) {
debug12("Registering plugin: %s", plugin.id);
if (plugins.has(plugin.id)) {
throw new PluginError("Plugin is already registered", plugin.id, "ALREADY_REGISTERED");
}
if (plugin.dependencies) {
for (const depId of plugin.dependencies) {
if (!plugins.has(depId)) {
throw new PluginError(`Missing dependency: "${depId}"`, plugin.id, "MISSING_DEPENDENCY");
}
}
}
detectConflicts(plugin);
const cleanups = [];
try {
if (plugin.nodeTypes) {
const nodeTypeNames = Object.keys(plugin.nodeTypes);
registerNodeTypes(plugin.nodeTypes);
cleanups.push(() => {
for (const name of nodeTypeNames) {
unregisterNodeType(name);
}
});
}
if (plugin.edgePathCalculators) {
for (const [name, calc] of Object.entries(plugin.edgePathCalculators)) {
registerEdgePathCalculator(name, calc);
cleanups.push(() => unregisterEdgePathCalculator(name));
}
}
if (plugin.actionHandlers) {
for (const [actionId, handler] of Object.entries(plugin.actionHandlers)) {
registerAction2(actionId, handler);
cleanups.push(() => unregisterAction2(actionId));
}
}
if (plugin.commands) {
for (const cmd of plugin.commands) {
commandRegistry.register(cmd);
cleanups.push(() => commandRegistry.unregister(cmd.name));
}
}
if (plugin.actions) {
for (const action of plugin.actions) {
registerAction(action);
cleanups.push(() => unregisterAction(action.id));
}
}
let lifecycleCleanup = null;
if (plugin.onRegister) {
const ctx = makePluginContext(plugin.id);
try {
const result = plugin.onRegister(ctx);
if (typeof result === "function") {
lifecycleCleanup = result;
}
} catch (err) {
for (const cleanup of cleanups.reverse()) {
try {
cleanup();
} catch {
}
}
throw new PluginError(`onRegister failed: ${err instanceof Error ? err.message : String(err)}`, plugin.id, "LIFECYCLE_ERROR");
}
}
plugins.set(plugin.id, {
plugin,
cleanup: () => {
for (const cleanup of cleanups.reverse()) {
try {
cleanup();
} catch {
}
}
if (lifecycleCleanup) {
try {
lifecycleCleanup();
} catch {
}
}
},
registeredAt: Date.now()
});
debug12("Plugin registered: %s (%d node types, %d commands, %d actions)", plugin.id, Object.keys(plugin.nodeTypes ?? {}).length, plugin.commands?.length ?? 0, plugin.actions?.length ?? 0);
} catch (err) {
if (err instanceof PluginError) throw err;
for (const cleanup of cleanups.reverse()) {
try {
cleanup();
} catch {
}
}
throw err;
}
}
function unregisterPlugin(pluginId) {
const registration = plugins.get(pluginId);
if (!registration) {
throw new PluginError("Plugin is not registered", pluginId, "NOT_FOUND");
}
for (const [otherId, other] of plugins) {
if (other.plugin.dependencies?.includes(pluginId)) {
throw new PluginError(`Cannot unregister: plugin "${otherId}" depends on it`, pluginId, "CONFLICT");
}
}
if (registration.cleanup) {
registration.cleanup();
}
plugins.delete(pluginId);
debug12("Plugin unregistered: %s", pluginId);
}
function getPlugin(id) {
return plugins.get(id)?.plugin;
}
function hasPlugin(id) {
return plugins.has(id);
}
function getAllPlugins() {
return Array.from(plugins.values()).map((r) => r.plugin);
}
function getPluginIds() {
return Array.from(plugins.keys());
}
function getPluginGestureContexts() {
const contexts = [];
for (const registration of plugins.values()) {
if (registration.plugin.gestureContexts) {
contexts.push(...registration.plugin.gestureContexts);
}
}
return contexts;
}
function clearPlugins() {
const ids = Array.from(plugins.keys()).reverse();
for (const id of ids) {
const reg = plugins.get(id);
if (reg?.cleanup) {
try {
reg.cleanup();
} catch {
}
}
plugins.delete(id);
}
debug12("All plugins cleared");
}
function detectConflicts(plugin) {
if (plugin.commands) {
for (const cmd of plugin.commands) {
if (commandRegistry.has(cmd.name)) {
throw new PluginError(`Command "${cmd.name}" is already registered`, plugin.id, "CONFLICT");
}
}
}
if (plugin.edgePathCalculators) {
for (const name of Object.keys(plugin.edgePathCalculators)) {
for (const [otherId, other] of plugins) {
if (other.plugin.edgePathCalculators?.[name]) {
throw new PluginError(`Edge path calculator "${name}" already registered by plugin "${otherId}"`, plugin.id, "CONFLICT");
}
}
}
}
if (plugin.nodeTypes) {
for (const nodeType of Object.keys(plugin.nodeTypes)) {
for (const [otherId, other] of plugins) {
if (other.plugin.nodeTypes?.[nodeType]) {
throw new PluginError(`Node type "${nodeType}" already registered by plugin "${otherId}"`, plugin.id, "CONFLICT");
}
}
}
}
if (plugin.actionHandlers) {
for (const actionId of Object.keys(plugin.actionHandlers)) {
for (const [otherId, other] of plugins) {
if (other.plugin.actionHandlers?.[actionId]) {
throw new PluginError(`Action handler "${actionId}" already registered by plugin "${otherId}"`, plugin.id, "CONFLICT");
}
}
}
}
}
function makePluginContext(pluginId) {
return {
pluginId,
getPlugin,
hasPlugin
};
}
export {
ActionCategory,
BUILT_IN_PRESETS,
BuiltInActionId,
CanvasEventType,
DEFAULT_GESTURE_RULES,
DEFAULT_MAPPINGS,
DEFAULT_PORT,
EDGE_ANIMATION_DURATION,
EVENT_TYPE_INFO,
FallbackNodeTypeComponent,
HIT_TARGET_SIZES,
PASTE_OFFSET,
PluginError,
SNAPSHOT_VERSION,
SpatialGrid,
VIRTUALIZATION_BUFFER,
ZOOM_EXIT_THRESHOLD,
ZOOM_TRANSITION_THRESHOLD,
activePointersAtom,
activePresetAtom,
activePresetIdAtom,
addGestureRuleAtom,
addNodeToLocalGraphAtom,
addNodesToSelectionAtom,
alignmentGuidesAtom,
allPresetsAtom,
animateFitToBoundsAtom,
animateZoomToNodeAtom,
applyDelta,
applyPresetAtom,
arePortsCompatible,
autoResizeGroupAtom,
buildActionHelpers,
buildRuleIndex,
calculatePortPosition,
canPortAcceptConnection,
canRedoAtom,
canUndoAtom,
cancelSelectionAtom,
canvasMark,
canvasSettingsAtom,
canvasToastAtom,
canvasWrap,
centerOnNodeAtom,
classifyPointer,
cleanupAllNodePositionsAtom,
cleanupNodePositionAtom,
clearActions,
clearAlignmentGuidesAtom,
clearClipboardAtom,
clearEdgeSelectionAtom,
clearGraphOnSwitchAtom,
clearHistoryAtom,
clearMutationQueueAtom,
clearNodeTypeRegistry,
clearPlugins,
clearPointersAtom,
clearSearchAtom,
clearSelectionAtom,
clipboardAtom,
clipboardNodeCountAtom,
collapseGroupAtom,
collapsedEdgeRemapAtom,
collapsedGroupsAtom,
completeMutationAtom,
conditionalSnap,
consumerGestureRulesAtom,
copyToClipboardAtom,
createActionContext,
createActionContextFromReactEvent,
createActionContextFromTouchEvent,
createCanvasAPI,
createSnapshot,
currentGraphIdAtom,
cutToClipboardAtom,
deletePresetAtom,
departingEdgesAtom,
dequeueMutationAtom,
detectInputCapabilities,
draggingNodeIdAtom,
dropTargetNodeIdAtom,
duplicateSelectionAtom,
edgeCreationAtom,
edgeFamilyAtom,
edgeKeysAtom,
edgeKeysWithTempEdgeAtom,
editingEdgeLabelAtom,
endNodeDragAtom,
endSelectionAtom,
eventMappingsAtom,
executeAction,
expandGroupAtom,
exportGraph,
findAlignmentGuides,
fingerCountAtom,
fitToBoundsAtom,
focusedNodeIdAtom,
formatRuleLabel,
fuzzyMatch,
gestureRuleIndexAtom,
gestureRuleSettingsAtom,
gestureRulesAtom,
getAction,
getActionForEvent,
getActionsByCategories,
getActionsByCategory,
getAllActions,
getAllPlugins,
getGestureThresholds,
getHitTargetSize,
getNextQueuedMutationAtom,
getNodeDescendants,
getNodePorts,
getNodeTypeComponent,
getPlugin,
getPluginGestureContexts,
getPluginIds,
getRegisteredNodeTypes,
getSnapGuides,
goToLockedPageAtom,
graphAtom,
graphOptions,
graphUpdateVersionAtom,
groupChildCountAtom,
groupSelectedNodesAtom,
handleNodePointerDownSelectionAtom,
hasAction,
hasClipboardContentAtom,
hasExternalKeyboardAtom,
hasFocusedNodeAtom,
hasLockedNodeAtom,
hasNodeTypeComponent,
hasPlugin,
hasSelectionAtom,
hasUnsavedChangesAtom,
highestZIndexAtom,
highlightedSearchIndexAtom,
highlightedSearchNodeIdAtom,
historyLabelsAtom,
historyStateAtom,
importGraph,
incrementRetryCountAtom,
inputCapabilitiesAtom,
inputModeAtom,
interactionFeedbackAtom,
invertDelta,
isFilterActiveAtom,
isGroupNodeAtom,
isMultiTouchAtom,
isNodeCollapsed,
isOnlineAtom,
isPanelOpenAtom,
isPickNodeModeAtom,
isPickingModeAtom,
isSelectingAtom,
isSnappingActiveAtom,
isStylusActiveAtom,
isTouchDeviceAtom,
isZoomTransitioningAtom,
keyboardInteractionModeAtom,
lastSyncErrorAtom,
lastSyncTimeAtom,
loadGraphFromDbAtom,
lockNodeAtom,
lockedNodeDataAtom,
lockedNodeIdAtom,
lockedNodePageCountAtom,
lockedNodePageIndexAtom,
matchSpecificity,
mergeNodesAtom,
mergeRules,
moveNodesToGroupAtom,
mutationQueueAtom,
nestNodesOnDropAtom,
nextLockedPageAtom,
nextSearchResultAtom,
nodeChildrenAtom,
nodeFamilyAtom,
nodeKeysAtom,
nodeParentAtom,
nodePositionAtomFamily,
nodePositionUpdateCounterAtom,
optimisticDeleteEdgeAtom,
optimisticDeleteNodeAtom,
palmRejectionEnabledAtom,
panAtom,
pasteFromClipboardAtom,
pendingInputResolverAtom,
pendingMutationsCountAtom,
perfEnabledAtom,
pointInPolygon,
pointerDownAtom,
pointerUpAtom,
preDragNodeAttributesAtom,
prefersReducedMotionAtom,
prevLockedPageAtom,
prevSearchResultAtom,
primaryInputSourceAtom,
provideInputAtom,
pushDeltaAtom,
pushHistoryAtom,
queueMutationAtom,
redoAtom,
redoCountAtom,
registerAction,
registerNodeType,
registerNodeTypes,
registerPlugin,
removeEdgeWithAnimationAtom,
removeFromGroupAtom,
removeGestureRuleAtom,
removeNodesFromSelectionAtom,
resetGestureRulesAtom,
resetInputModeAtom,
resetKeyboardInteractionModeAtom,
resetSettingsAtom,
resetViewportAtom,
resolveGesture,
resolveGestureIndexed,
saveAsPresetAtom,
screenToWorldAtom,
searchEdgeResultCountAtom,
searchEdgeResultsAtom,
searchQueryAtom,
searchResultCountAtom,
searchResultsArrayAtom,
searchResultsAtom,
searchTotalResultCountAtom,
selectEdgeAtom,
selectSingleNodeAtom,
selectedEdgeIdAtom,
selectedNodeIdsAtom,
selectedNodesCountAtom,
selectionPathAtom,
selectionRectAtom,
setEventMappingAtom,
setFocusedNodeAtom,
setGridSizeAtom,
setKeyboardInteractionModeAtom,
setNodeParentAtom,
setOnlineStatusAtom,
setPanelOpenAtom,
setPerfEnabled,
setSearchQueryAtom,
setVirtualizationEnabledAtom,
setZoomAtom,
showToastAtom,
snapAlignmentEnabledAtom,
snapEnabledAtom,
snapGridSizeAtom,
snapTemporaryDisableAtom,
snapToGrid,
spatialIndexAtom,
splitNodeAtom,
startMutationAtom,
startNodeDragAtom,
startPickNodeAtom,
startPickNodesAtom,
startPickPointAtom,
startSelectionAtom,
swapEdgeAtomicAtom,
syncStateAtom,
syncStatusAtom,
toggleAlignmentGuidesAtom,
toggleGroupCollapseAtom,
toggleNodeInSelectionAtom,
togglePanelAtom,
toggleSnapAtom,
toggleVirtualizationAtom,
trackMutationErrorAtom,
uiNodesAtom,
undoAtom,
undoCountAtom,
ungroupNodesAtom,
unlockNodeAtom,
unregisterAction,
unregisterNodeType,
unregisterPlugin,
updateEdgeLabelAtom,
updateGestureRuleAtom,
updateInteractionFeedbackAtom,
updateNodePositionAtom,
updatePresetAtom,
updateSelectionAtom,
validateSnapshot,
viewportRectAtom,
virtualizationEnabledAtom,
virtualizationMetricsAtom,
visibleBoundsAtom,
visibleEdgeKeysAtom,
visibleNodeKeysAtom,
watchExternalKeyboardAtom,
watchReducedMotionAtom,
worldToScreenAtom,
zoomAnimationTargetAtom,
zoomAtom,
zoomFocusNodeIdAtom,
zoomTransitionProgressAtom
};
//# sourceMappingURL=index.mjs.map