5280 lines
168 KiB
JavaScript
5280 lines
168 KiB
JavaScript
|
|
"use strict";
|
||
|
|
var __create = Object.create;
|
||
|
|
var __defProp = Object.defineProperty;
|
||
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||
|
|
var __getProtoOf = Object.getPrototypeOf;
|
||
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||
|
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||
|
|
var __export = (target, all) => {
|
||
|
|
for (var name in all)
|
||
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||
|
|
};
|
||
|
|
var __copyProps = (to, from, except, desc) => {
|
||
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
||
|
|
for (let key of __getOwnPropNames(from))
|
||
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||
|
|
}
|
||
|
|
return to;
|
||
|
|
};
|
||
|
|
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||
|
|
// If the importer is in node compatibility mode or this is not an ESM
|
||
|
|
// file that has been converted to a CommonJS file using a Babel-
|
||
|
|
// compatible transform (i.e. "__esModule" has not been set), then set
|
||
|
|
// "default" to the CommonJS "module.exports" for node compatibility.
|
||
|
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||
|
|
mod
|
||
|
|
));
|
||
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||
|
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||
|
|
|
||
|
|
// src/core/index.ts
|
||
|
|
var index_exports = {};
|
||
|
|
__export(index_exports, {
|
||
|
|
ActionCategory: () => ActionCategory,
|
||
|
|
BUILT_IN_PRESETS: () => BUILT_IN_PRESETS,
|
||
|
|
BuiltInActionId: () => BuiltInActionId,
|
||
|
|
CanvasEventType: () => CanvasEventType,
|
||
|
|
DEFAULT_GESTURE_RULES: () => DEFAULT_GESTURE_RULES,
|
||
|
|
DEFAULT_MAPPINGS: () => DEFAULT_MAPPINGS,
|
||
|
|
DEFAULT_PORT: () => DEFAULT_PORT,
|
||
|
|
EDGE_ANIMATION_DURATION: () => EDGE_ANIMATION_DURATION,
|
||
|
|
EVENT_TYPE_INFO: () => EVENT_TYPE_INFO,
|
||
|
|
FallbackNodeTypeComponent: () => FallbackNodeTypeComponent,
|
||
|
|
HIT_TARGET_SIZES: () => HIT_TARGET_SIZES,
|
||
|
|
PASTE_OFFSET: () => PASTE_OFFSET,
|
||
|
|
PluginError: () => PluginError,
|
||
|
|
SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
|
||
|
|
SpatialGrid: () => SpatialGrid,
|
||
|
|
VIRTUALIZATION_BUFFER: () => VIRTUALIZATION_BUFFER,
|
||
|
|
ZOOM_EXIT_THRESHOLD: () => ZOOM_EXIT_THRESHOLD,
|
||
|
|
ZOOM_TRANSITION_THRESHOLD: () => ZOOM_TRANSITION_THRESHOLD,
|
||
|
|
activePointersAtom: () => activePointersAtom,
|
||
|
|
activePresetAtom: () => activePresetAtom,
|
||
|
|
activePresetIdAtom: () => activePresetIdAtom,
|
||
|
|
addGestureRuleAtom: () => addGestureRuleAtom,
|
||
|
|
addNodeToLocalGraphAtom: () => addNodeToLocalGraphAtom,
|
||
|
|
addNodesToSelectionAtom: () => addNodesToSelectionAtom,
|
||
|
|
alignmentGuidesAtom: () => alignmentGuidesAtom,
|
||
|
|
allPresetsAtom: () => allPresetsAtom,
|
||
|
|
animateFitToBoundsAtom: () => animateFitToBoundsAtom,
|
||
|
|
animateZoomToNodeAtom: () => animateZoomToNodeAtom,
|
||
|
|
applyDelta: () => applyDelta,
|
||
|
|
applyPresetAtom: () => applyPresetAtom,
|
||
|
|
arePortsCompatible: () => arePortsCompatible,
|
||
|
|
autoResizeGroupAtom: () => autoResizeGroupAtom,
|
||
|
|
buildActionHelpers: () => buildActionHelpers,
|
||
|
|
buildRuleIndex: () => buildRuleIndex,
|
||
|
|
calculatePortPosition: () => calculatePortPosition,
|
||
|
|
canPortAcceptConnection: () => canPortAcceptConnection,
|
||
|
|
canRedoAtom: () => canRedoAtom,
|
||
|
|
canUndoAtom: () => canUndoAtom,
|
||
|
|
cancelSelectionAtom: () => cancelSelectionAtom,
|
||
|
|
canvasMark: () => canvasMark,
|
||
|
|
canvasSettingsAtom: () => canvasSettingsAtom,
|
||
|
|
canvasToastAtom: () => canvasToastAtom,
|
||
|
|
canvasWrap: () => canvasWrap,
|
||
|
|
centerOnNodeAtom: () => centerOnNodeAtom,
|
||
|
|
classifyPointer: () => classifyPointer,
|
||
|
|
cleanupAllNodePositionsAtom: () => cleanupAllNodePositionsAtom,
|
||
|
|
cleanupNodePositionAtom: () => cleanupNodePositionAtom,
|
||
|
|
clearActions: () => clearActions,
|
||
|
|
clearAlignmentGuidesAtom: () => clearAlignmentGuidesAtom,
|
||
|
|
clearClipboardAtom: () => clearClipboardAtom,
|
||
|
|
clearEdgeSelectionAtom: () => clearEdgeSelectionAtom,
|
||
|
|
clearGraphOnSwitchAtom: () => clearGraphOnSwitchAtom,
|
||
|
|
clearHistoryAtom: () => clearHistoryAtom,
|
||
|
|
clearMutationQueueAtom: () => clearMutationQueueAtom,
|
||
|
|
clearNodeTypeRegistry: () => clearNodeTypeRegistry,
|
||
|
|
clearPlugins: () => clearPlugins,
|
||
|
|
clearPointersAtom: () => clearPointersAtom,
|
||
|
|
clearSearchAtom: () => clearSearchAtom,
|
||
|
|
clearSelectionAtom: () => clearSelectionAtom,
|
||
|
|
clipboardAtom: () => clipboardAtom,
|
||
|
|
clipboardNodeCountAtom: () => clipboardNodeCountAtom,
|
||
|
|
collapseGroupAtom: () => collapseGroupAtom,
|
||
|
|
collapsedEdgeRemapAtom: () => collapsedEdgeRemapAtom,
|
||
|
|
collapsedGroupsAtom: () => collapsedGroupsAtom,
|
||
|
|
completeMutationAtom: () => completeMutationAtom,
|
||
|
|
conditionalSnap: () => conditionalSnap,
|
||
|
|
consumerGestureRulesAtom: () => consumerGestureRulesAtom,
|
||
|
|
copyToClipboardAtom: () => copyToClipboardAtom,
|
||
|
|
createActionContext: () => createActionContext,
|
||
|
|
createActionContextFromReactEvent: () => createActionContextFromReactEvent,
|
||
|
|
createActionContextFromTouchEvent: () => createActionContextFromTouchEvent,
|
||
|
|
createCanvasAPI: () => createCanvasAPI,
|
||
|
|
createSnapshot: () => createSnapshot,
|
||
|
|
currentGraphIdAtom: () => currentGraphIdAtom,
|
||
|
|
cutToClipboardAtom: () => cutToClipboardAtom,
|
||
|
|
deletePresetAtom: () => deletePresetAtom,
|
||
|
|
departingEdgesAtom: () => departingEdgesAtom,
|
||
|
|
dequeueMutationAtom: () => dequeueMutationAtom,
|
||
|
|
detectInputCapabilities: () => detectInputCapabilities,
|
||
|
|
draggingNodeIdAtom: () => draggingNodeIdAtom,
|
||
|
|
dropTargetNodeIdAtom: () => dropTargetNodeIdAtom,
|
||
|
|
duplicateSelectionAtom: () => duplicateSelectionAtom,
|
||
|
|
edgeCreationAtom: () => edgeCreationAtom,
|
||
|
|
edgeFamilyAtom: () => edgeFamilyAtom,
|
||
|
|
edgeKeysAtom: () => edgeKeysAtom,
|
||
|
|
edgeKeysWithTempEdgeAtom: () => edgeKeysWithTempEdgeAtom,
|
||
|
|
editingEdgeLabelAtom: () => editingEdgeLabelAtom,
|
||
|
|
endNodeDragAtom: () => endNodeDragAtom,
|
||
|
|
endSelectionAtom: () => endSelectionAtom,
|
||
|
|
eventMappingsAtom: () => eventMappingsAtom,
|
||
|
|
executeAction: () => executeAction,
|
||
|
|
expandGroupAtom: () => expandGroupAtom,
|
||
|
|
exportGraph: () => exportGraph,
|
||
|
|
findAlignmentGuides: () => findAlignmentGuides,
|
||
|
|
fingerCountAtom: () => fingerCountAtom,
|
||
|
|
fitToBoundsAtom: () => fitToBoundsAtom,
|
||
|
|
focusedNodeIdAtom: () => focusedNodeIdAtom,
|
||
|
|
formatRuleLabel: () => formatRuleLabel,
|
||
|
|
fuzzyMatch: () => fuzzyMatch,
|
||
|
|
gestureRuleIndexAtom: () => gestureRuleIndexAtom,
|
||
|
|
gestureRuleSettingsAtom: () => gestureRuleSettingsAtom,
|
||
|
|
gestureRulesAtom: () => gestureRulesAtom,
|
||
|
|
getAction: () => getAction,
|
||
|
|
getActionForEvent: () => getActionForEvent,
|
||
|
|
getActionsByCategories: () => getActionsByCategories,
|
||
|
|
getActionsByCategory: () => getActionsByCategory,
|
||
|
|
getAllActions: () => getAllActions,
|
||
|
|
getAllPlugins: () => getAllPlugins,
|
||
|
|
getGestureThresholds: () => getGestureThresholds,
|
||
|
|
getHitTargetSize: () => getHitTargetSize,
|
||
|
|
getNextQueuedMutationAtom: () => getNextQueuedMutationAtom,
|
||
|
|
getNodeDescendants: () => getNodeDescendants,
|
||
|
|
getNodePorts: () => getNodePorts,
|
||
|
|
getNodeTypeComponent: () => getNodeTypeComponent,
|
||
|
|
getPlugin: () => getPlugin,
|
||
|
|
getPluginGestureContexts: () => getPluginGestureContexts,
|
||
|
|
getPluginIds: () => getPluginIds,
|
||
|
|
getRegisteredNodeTypes: () => getRegisteredNodeTypes,
|
||
|
|
getSnapGuides: () => getSnapGuides,
|
||
|
|
goToLockedPageAtom: () => goToLockedPageAtom,
|
||
|
|
graphAtom: () => graphAtom,
|
||
|
|
graphOptions: () => graphOptions,
|
||
|
|
graphUpdateVersionAtom: () => graphUpdateVersionAtom,
|
||
|
|
groupChildCountAtom: () => groupChildCountAtom,
|
||
|
|
groupSelectedNodesAtom: () => groupSelectedNodesAtom,
|
||
|
|
handleNodePointerDownSelectionAtom: () => handleNodePointerDownSelectionAtom,
|
||
|
|
hasAction: () => hasAction,
|
||
|
|
hasClipboardContentAtom: () => hasClipboardContentAtom,
|
||
|
|
hasExternalKeyboardAtom: () => hasExternalKeyboardAtom,
|
||
|
|
hasFocusedNodeAtom: () => hasFocusedNodeAtom,
|
||
|
|
hasLockedNodeAtom: () => hasLockedNodeAtom,
|
||
|
|
hasNodeTypeComponent: () => hasNodeTypeComponent,
|
||
|
|
hasPlugin: () => hasPlugin,
|
||
|
|
hasSelectionAtom: () => hasSelectionAtom,
|
||
|
|
hasUnsavedChangesAtom: () => hasUnsavedChangesAtom,
|
||
|
|
highestZIndexAtom: () => highestZIndexAtom,
|
||
|
|
highlightedSearchIndexAtom: () => highlightedSearchIndexAtom,
|
||
|
|
highlightedSearchNodeIdAtom: () => highlightedSearchNodeIdAtom,
|
||
|
|
historyLabelsAtom: () => historyLabelsAtom,
|
||
|
|
historyStateAtom: () => historyStateAtom,
|
||
|
|
importGraph: () => importGraph,
|
||
|
|
incrementRetryCountAtom: () => incrementRetryCountAtom,
|
||
|
|
inputCapabilitiesAtom: () => inputCapabilitiesAtom,
|
||
|
|
inputModeAtom: () => inputModeAtom,
|
||
|
|
interactionFeedbackAtom: () => interactionFeedbackAtom,
|
||
|
|
invertDelta: () => invertDelta,
|
||
|
|
isFilterActiveAtom: () => isFilterActiveAtom,
|
||
|
|
isGroupNodeAtom: () => isGroupNodeAtom,
|
||
|
|
isMultiTouchAtom: () => isMultiTouchAtom,
|
||
|
|
isNodeCollapsed: () => isNodeCollapsed,
|
||
|
|
isOnlineAtom: () => isOnlineAtom,
|
||
|
|
isPanelOpenAtom: () => isPanelOpenAtom,
|
||
|
|
isPickNodeModeAtom: () => isPickNodeModeAtom,
|
||
|
|
isPickingModeAtom: () => isPickingModeAtom,
|
||
|
|
isSelectingAtom: () => isSelectingAtom,
|
||
|
|
isSnappingActiveAtom: () => isSnappingActiveAtom,
|
||
|
|
isStylusActiveAtom: () => isStylusActiveAtom,
|
||
|
|
isTouchDeviceAtom: () => isTouchDeviceAtom,
|
||
|
|
isZoomTransitioningAtom: () => isZoomTransitioningAtom,
|
||
|
|
keyboardInteractionModeAtom: () => keyboardInteractionModeAtom,
|
||
|
|
lastSyncErrorAtom: () => lastSyncErrorAtom,
|
||
|
|
lastSyncTimeAtom: () => lastSyncTimeAtom,
|
||
|
|
loadGraphFromDbAtom: () => loadGraphFromDbAtom,
|
||
|
|
lockNodeAtom: () => lockNodeAtom,
|
||
|
|
lockedNodeDataAtom: () => lockedNodeDataAtom,
|
||
|
|
lockedNodeIdAtom: () => lockedNodeIdAtom,
|
||
|
|
lockedNodePageCountAtom: () => lockedNodePageCountAtom,
|
||
|
|
lockedNodePageIndexAtom: () => lockedNodePageIndexAtom,
|
||
|
|
matchSpecificity: () => matchSpecificity,
|
||
|
|
mergeNodesAtom: () => mergeNodesAtom,
|
||
|
|
mergeRules: () => mergeRules,
|
||
|
|
moveNodesToGroupAtom: () => moveNodesToGroupAtom,
|
||
|
|
mutationQueueAtom: () => mutationQueueAtom,
|
||
|
|
nestNodesOnDropAtom: () => nestNodesOnDropAtom,
|
||
|
|
nextLockedPageAtom: () => nextLockedPageAtom,
|
||
|
|
nextSearchResultAtom: () => nextSearchResultAtom,
|
||
|
|
nodeChildrenAtom: () => nodeChildrenAtom,
|
||
|
|
nodeFamilyAtom: () => nodeFamilyAtom,
|
||
|
|
nodeKeysAtom: () => nodeKeysAtom,
|
||
|
|
nodeParentAtom: () => nodeParentAtom,
|
||
|
|
nodePositionAtomFamily: () => nodePositionAtomFamily,
|
||
|
|
nodePositionUpdateCounterAtom: () => nodePositionUpdateCounterAtom,
|
||
|
|
optimisticDeleteEdgeAtom: () => optimisticDeleteEdgeAtom,
|
||
|
|
optimisticDeleteNodeAtom: () => optimisticDeleteNodeAtom,
|
||
|
|
palmRejectionEnabledAtom: () => palmRejectionEnabledAtom,
|
||
|
|
panAtom: () => panAtom,
|
||
|
|
pasteFromClipboardAtom: () => pasteFromClipboardAtom,
|
||
|
|
pendingInputResolverAtom: () => pendingInputResolverAtom,
|
||
|
|
pendingMutationsCountAtom: () => pendingMutationsCountAtom,
|
||
|
|
perfEnabledAtom: () => perfEnabledAtom,
|
||
|
|
pointInPolygon: () => pointInPolygon,
|
||
|
|
pointerDownAtom: () => pointerDownAtom,
|
||
|
|
pointerUpAtom: () => pointerUpAtom,
|
||
|
|
preDragNodeAttributesAtom: () => preDragNodeAttributesAtom,
|
||
|
|
prefersReducedMotionAtom: () => prefersReducedMotionAtom,
|
||
|
|
prevLockedPageAtom: () => prevLockedPageAtom,
|
||
|
|
prevSearchResultAtom: () => prevSearchResultAtom,
|
||
|
|
primaryInputSourceAtom: () => primaryInputSourceAtom,
|
||
|
|
provideInputAtom: () => provideInputAtom,
|
||
|
|
pushDeltaAtom: () => pushDeltaAtom,
|
||
|
|
pushHistoryAtom: () => pushHistoryAtom,
|
||
|
|
queueMutationAtom: () => queueMutationAtom,
|
||
|
|
redoAtom: () => redoAtom,
|
||
|
|
redoCountAtom: () => redoCountAtom,
|
||
|
|
registerAction: () => registerAction,
|
||
|
|
registerNodeType: () => registerNodeType,
|
||
|
|
registerNodeTypes: () => registerNodeTypes,
|
||
|
|
registerPlugin: () => registerPlugin,
|
||
|
|
removeEdgeWithAnimationAtom: () => removeEdgeWithAnimationAtom,
|
||
|
|
removeFromGroupAtom: () => removeFromGroupAtom,
|
||
|
|
removeGestureRuleAtom: () => removeGestureRuleAtom,
|
||
|
|
removeNodesFromSelectionAtom: () => removeNodesFromSelectionAtom,
|
||
|
|
resetGestureRulesAtom: () => resetGestureRulesAtom,
|
||
|
|
resetInputModeAtom: () => resetInputModeAtom,
|
||
|
|
resetKeyboardInteractionModeAtom: () => resetKeyboardInteractionModeAtom,
|
||
|
|
resetSettingsAtom: () => resetSettingsAtom,
|
||
|
|
resetViewportAtom: () => resetViewportAtom,
|
||
|
|
resolveGesture: () => resolveGesture,
|
||
|
|
resolveGestureIndexed: () => resolveGestureIndexed,
|
||
|
|
saveAsPresetAtom: () => saveAsPresetAtom,
|
||
|
|
screenToWorldAtom: () => screenToWorldAtom,
|
||
|
|
searchEdgeResultCountAtom: () => searchEdgeResultCountAtom,
|
||
|
|
searchEdgeResultsAtom: () => searchEdgeResultsAtom,
|
||
|
|
searchQueryAtom: () => searchQueryAtom,
|
||
|
|
searchResultCountAtom: () => searchResultCountAtom,
|
||
|
|
searchResultsArrayAtom: () => searchResultsArrayAtom,
|
||
|
|
searchResultsAtom: () => searchResultsAtom,
|
||
|
|
searchTotalResultCountAtom: () => searchTotalResultCountAtom,
|
||
|
|
selectEdgeAtom: () => selectEdgeAtom,
|
||
|
|
selectSingleNodeAtom: () => selectSingleNodeAtom,
|
||
|
|
selectedEdgeIdAtom: () => selectedEdgeIdAtom,
|
||
|
|
selectedNodeIdsAtom: () => selectedNodeIdsAtom,
|
||
|
|
selectedNodesCountAtom: () => selectedNodesCountAtom,
|
||
|
|
selectionPathAtom: () => selectionPathAtom,
|
||
|
|
selectionRectAtom: () => selectionRectAtom,
|
||
|
|
setEventMappingAtom: () => setEventMappingAtom,
|
||
|
|
setFocusedNodeAtom: () => setFocusedNodeAtom,
|
||
|
|
setGridSizeAtom: () => setGridSizeAtom,
|
||
|
|
setKeyboardInteractionModeAtom: () => setKeyboardInteractionModeAtom,
|
||
|
|
setNodeParentAtom: () => setNodeParentAtom,
|
||
|
|
setOnlineStatusAtom: () => setOnlineStatusAtom,
|
||
|
|
setPanelOpenAtom: () => setPanelOpenAtom,
|
||
|
|
setPerfEnabled: () => setPerfEnabled,
|
||
|
|
setSearchQueryAtom: () => setSearchQueryAtom,
|
||
|
|
setVirtualizationEnabledAtom: () => setVirtualizationEnabledAtom,
|
||
|
|
setZoomAtom: () => setZoomAtom,
|
||
|
|
showToastAtom: () => showToastAtom,
|
||
|
|
snapAlignmentEnabledAtom: () => snapAlignmentEnabledAtom,
|
||
|
|
snapEnabledAtom: () => snapEnabledAtom,
|
||
|
|
snapGridSizeAtom: () => snapGridSizeAtom,
|
||
|
|
snapTemporaryDisableAtom: () => snapTemporaryDisableAtom,
|
||
|
|
snapToGrid: () => snapToGrid,
|
||
|
|
spatialIndexAtom: () => spatialIndexAtom,
|
||
|
|
splitNodeAtom: () => splitNodeAtom,
|
||
|
|
startMutationAtom: () => startMutationAtom,
|
||
|
|
startNodeDragAtom: () => startNodeDragAtom,
|
||
|
|
startPickNodeAtom: () => startPickNodeAtom,
|
||
|
|
startPickNodesAtom: () => startPickNodesAtom,
|
||
|
|
startPickPointAtom: () => startPickPointAtom,
|
||
|
|
startSelectionAtom: () => startSelectionAtom,
|
||
|
|
swapEdgeAtomicAtom: () => swapEdgeAtomicAtom,
|
||
|
|
syncStateAtom: () => syncStateAtom,
|
||
|
|
syncStatusAtom: () => syncStatusAtom,
|
||
|
|
toggleAlignmentGuidesAtom: () => toggleAlignmentGuidesAtom,
|
||
|
|
toggleGroupCollapseAtom: () => toggleGroupCollapseAtom,
|
||
|
|
toggleNodeInSelectionAtom: () => toggleNodeInSelectionAtom,
|
||
|
|
togglePanelAtom: () => togglePanelAtom,
|
||
|
|
toggleSnapAtom: () => toggleSnapAtom,
|
||
|
|
toggleVirtualizationAtom: () => toggleVirtualizationAtom,
|
||
|
|
trackMutationErrorAtom: () => trackMutationErrorAtom,
|
||
|
|
uiNodesAtom: () => uiNodesAtom,
|
||
|
|
undoAtom: () => undoAtom,
|
||
|
|
undoCountAtom: () => undoCountAtom,
|
||
|
|
ungroupNodesAtom: () => ungroupNodesAtom,
|
||
|
|
unlockNodeAtom: () => unlockNodeAtom,
|
||
|
|
unregisterAction: () => unregisterAction,
|
||
|
|
unregisterNodeType: () => unregisterNodeType,
|
||
|
|
unregisterPlugin: () => unregisterPlugin,
|
||
|
|
updateEdgeLabelAtom: () => updateEdgeLabelAtom,
|
||
|
|
updateGestureRuleAtom: () => updateGestureRuleAtom,
|
||
|
|
updateInteractionFeedbackAtom: () => updateInteractionFeedbackAtom,
|
||
|
|
updateNodePositionAtom: () => updateNodePositionAtom,
|
||
|
|
updatePresetAtom: () => updatePresetAtom,
|
||
|
|
updateSelectionAtom: () => updateSelectionAtom,
|
||
|
|
validateSnapshot: () => validateSnapshot,
|
||
|
|
viewportRectAtom: () => viewportRectAtom,
|
||
|
|
virtualizationEnabledAtom: () => virtualizationEnabledAtom,
|
||
|
|
virtualizationMetricsAtom: () => virtualizationMetricsAtom,
|
||
|
|
visibleBoundsAtom: () => visibleBoundsAtom,
|
||
|
|
visibleEdgeKeysAtom: () => visibleEdgeKeysAtom,
|
||
|
|
visibleNodeKeysAtom: () => visibleNodeKeysAtom,
|
||
|
|
watchExternalKeyboardAtom: () => watchExternalKeyboardAtom,
|
||
|
|
watchReducedMotionAtom: () => watchReducedMotionAtom,
|
||
|
|
worldToScreenAtom: () => worldToScreenAtom,
|
||
|
|
zoomAnimationTargetAtom: () => zoomAnimationTargetAtom,
|
||
|
|
zoomAtom: () => zoomAtom,
|
||
|
|
zoomFocusNodeIdAtom: () => zoomFocusNodeIdAtom,
|
||
|
|
zoomTransitionProgressAtom: () => zoomTransitionProgressAtom
|
||
|
|
});
|
||
|
|
module.exports = __toCommonJS(index_exports);
|
||
|
|
|
||
|
|
// src/core/graph-store.ts
|
||
|
|
var import_jotai = require("jotai");
|
||
|
|
var import_graphology = __toESM(require("graphology"));
|
||
|
|
var graphOptions = {
|
||
|
|
type: "directed",
|
||
|
|
multi: true,
|
||
|
|
allowSelfLoops: true
|
||
|
|
};
|
||
|
|
var currentGraphIdAtom = (0, import_jotai.atom)(null);
|
||
|
|
var graphAtom = (0, import_jotai.atom)(new import_graphology.default(graphOptions));
|
||
|
|
var graphUpdateVersionAtom = (0, import_jotai.atom)(0);
|
||
|
|
var edgeCreationAtom = (0, import_jotai.atom)({
|
||
|
|
isCreating: false,
|
||
|
|
sourceNodeId: null,
|
||
|
|
sourceNodePosition: null,
|
||
|
|
targetPosition: null,
|
||
|
|
hoveredTargetNodeId: null,
|
||
|
|
sourceHandle: null,
|
||
|
|
targetHandle: null,
|
||
|
|
sourcePort: null,
|
||
|
|
targetPort: null,
|
||
|
|
snappedTargetPosition: null
|
||
|
|
});
|
||
|
|
var draggingNodeIdAtom = (0, import_jotai.atom)(null);
|
||
|
|
var preDragNodeAttributesAtom = (0, import_jotai.atom)(null);
|
||
|
|
|
||
|
|
// src/core/graph-position.ts
|
||
|
|
var import_jotai3 = require("jotai");
|
||
|
|
var import_jotai_family = require("jotai-family");
|
||
|
|
var import_graphology2 = __toESM(require("graphology"));
|
||
|
|
|
||
|
|
// src/utils/debug.ts
|
||
|
|
var import_debug = __toESM(require("debug"));
|
||
|
|
var NAMESPACE = "canvas";
|
||
|
|
function createDebug(module2) {
|
||
|
|
const base = (0, import_debug.default)(`${NAMESPACE}:${module2}`);
|
||
|
|
const warn = (0, import_debug.default)(`${NAMESPACE}:${module2}:warn`);
|
||
|
|
const error = (0, import_debug.default)(`${NAMESPACE}:${module2}:error`);
|
||
|
|
warn.enabled = true;
|
||
|
|
error.enabled = true;
|
||
|
|
warn.log = console.warn.bind(console);
|
||
|
|
error.log = console.error.bind(console);
|
||
|
|
const debugFn = Object.assign(base, {
|
||
|
|
warn,
|
||
|
|
error
|
||
|
|
});
|
||
|
|
return debugFn;
|
||
|
|
}
|
||
|
|
var debug = {
|
||
|
|
graph: {
|
||
|
|
node: createDebug("graph:node"),
|
||
|
|
edge: createDebug("graph:edge"),
|
||
|
|
sync: createDebug("graph:sync")
|
||
|
|
},
|
||
|
|
ui: {
|
||
|
|
selection: createDebug("ui:selection"),
|
||
|
|
drag: createDebug("ui:drag"),
|
||
|
|
resize: createDebug("ui:resize")
|
||
|
|
},
|
||
|
|
sync: {
|
||
|
|
status: createDebug("sync:status"),
|
||
|
|
mutations: createDebug("sync:mutations"),
|
||
|
|
queue: createDebug("sync:queue")
|
||
|
|
},
|
||
|
|
viewport: createDebug("viewport")
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/utils/mutation-queue.ts
|
||
|
|
var pendingNodeMutations = /* @__PURE__ */ new Map();
|
||
|
|
function clearAllPendingMutations() {
|
||
|
|
pendingNodeMutations.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/core/perf.ts
|
||
|
|
var import_jotai2 = require("jotai");
|
||
|
|
var perfEnabledAtom = (0, import_jotai2.atom)(false);
|
||
|
|
var _enabled = false;
|
||
|
|
function setPerfEnabled(enabled) {
|
||
|
|
_enabled = enabled;
|
||
|
|
}
|
||
|
|
if (typeof window !== "undefined") {
|
||
|
|
window.__canvasPerf = setPerfEnabled;
|
||
|
|
}
|
||
|
|
function canvasMark(name) {
|
||
|
|
if (!_enabled) return _noop;
|
||
|
|
const markName = `canvas:${name}`;
|
||
|
|
try {
|
||
|
|
performance.mark(markName);
|
||
|
|
} catch {
|
||
|
|
return _noop;
|
||
|
|
}
|
||
|
|
return () => {
|
||
|
|
try {
|
||
|
|
performance.measure(`canvas:${name}`, markName);
|
||
|
|
} catch {
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function _noop() {
|
||
|
|
}
|
||
|
|
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 = (0, import_jotai3.atom)(0);
|
||
|
|
var nodePositionAtomFamily = (0, import_jotai_family.atomFamily)((nodeId) => (0, import_jotai3.atom)((get) => {
|
||
|
|
get(nodePositionUpdateCounterAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(nodeId)) {
|
||
|
|
return {
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const x = graph.getNodeAttribute(nodeId, "x");
|
||
|
|
const y = graph.getNodeAttribute(nodeId, "y");
|
||
|
|
const cache = getPositionCache(graph);
|
||
|
|
const prev = cache.get(nodeId);
|
||
|
|
if (prev && prev.x === x && prev.y === y) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
const pos = {
|
||
|
|
x,
|
||
|
|
y
|
||
|
|
};
|
||
|
|
cache.set(nodeId, pos);
|
||
|
|
return pos;
|
||
|
|
}));
|
||
|
|
var updateNodePositionAtom = (0, import_jotai3.atom)(null, (get, set, {
|
||
|
|
nodeId,
|
||
|
|
position
|
||
|
|
}) => {
|
||
|
|
const end = canvasMark("drag-frame");
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasNode(nodeId)) {
|
||
|
|
debug2("Updating node %s position to %o", nodeId, position);
|
||
|
|
graph.setNodeAttribute(nodeId, "x", position.x);
|
||
|
|
graph.setNodeAttribute(nodeId, "y", position.y);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
}
|
||
|
|
end();
|
||
|
|
});
|
||
|
|
var cleanupNodePositionAtom = (0, import_jotai3.atom)(null, (get, _set, nodeId) => {
|
||
|
|
nodePositionAtomFamily.remove(nodeId);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
getPositionCache(graph).delete(nodeId);
|
||
|
|
debug2("Removed position atom for node: %s", nodeId);
|
||
|
|
});
|
||
|
|
var cleanupAllNodePositionsAtom = (0, import_jotai3.atom)(null, (get, _set) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const nodeIds = graph.nodes();
|
||
|
|
nodeIds.forEach((nodeId) => {
|
||
|
|
nodePositionAtomFamily.remove(nodeId);
|
||
|
|
});
|
||
|
|
_positionCacheByGraph.delete(graph);
|
||
|
|
debug2("Removed %d position atoms", nodeIds.length);
|
||
|
|
});
|
||
|
|
var clearGraphOnSwitchAtom = (0, import_jotai3.atom)(null, (get, set) => {
|
||
|
|
debug2("Clearing graph for switch");
|
||
|
|
set(cleanupAllNodePositionsAtom);
|
||
|
|
clearAllPendingMutations();
|
||
|
|
const emptyGraph = new import_graphology2.default(graphOptions);
|
||
|
|
set(graphAtom, emptyGraph);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/graph-derived.ts
|
||
|
|
var import_jotai8 = require("jotai");
|
||
|
|
var import_jotai_family2 = require("jotai-family");
|
||
|
|
|
||
|
|
// src/core/viewport-store.ts
|
||
|
|
var import_jotai5 = require("jotai");
|
||
|
|
|
||
|
|
// src/core/selection-store.ts
|
||
|
|
var import_jotai4 = require("jotai");
|
||
|
|
var debug3 = createDebug("selection");
|
||
|
|
var selectedNodeIdsAtom = (0, import_jotai4.atom)(/* @__PURE__ */ new Set());
|
||
|
|
var selectedEdgeIdAtom = (0, import_jotai4.atom)(null);
|
||
|
|
var handleNodePointerDownSelectionAtom = (0, import_jotai4.atom)(null, (get, set, {
|
||
|
|
nodeId,
|
||
|
|
isShiftPressed
|
||
|
|
}) => {
|
||
|
|
const currentSelection = get(selectedNodeIdsAtom);
|
||
|
|
debug3("handleNodePointerDownSelection: nodeId=%s, shift=%s, current=%o", nodeId, isShiftPressed, Array.from(currentSelection));
|
||
|
|
set(selectedEdgeIdAtom, null);
|
||
|
|
if (isShiftPressed) {
|
||
|
|
const newSelection = new Set(currentSelection);
|
||
|
|
if (newSelection.has(nodeId)) {
|
||
|
|
newSelection.delete(nodeId);
|
||
|
|
} else {
|
||
|
|
newSelection.add(nodeId);
|
||
|
|
}
|
||
|
|
debug3("Shift-click, setting selection to: %o", Array.from(newSelection));
|
||
|
|
set(selectedNodeIdsAtom, newSelection);
|
||
|
|
} else {
|
||
|
|
if (!currentSelection.has(nodeId)) {
|
||
|
|
debug3("Node not in selection, selecting: %s", nodeId);
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set([nodeId]));
|
||
|
|
} else {
|
||
|
|
debug3("Node already selected, preserving multi-select");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var selectSingleNodeAtom = (0, import_jotai4.atom)(null, (get, set, nodeId) => {
|
||
|
|
debug3("selectSingleNode: %s", nodeId);
|
||
|
|
set(selectedEdgeIdAtom, null);
|
||
|
|
if (nodeId === null || nodeId === void 0) {
|
||
|
|
debug3("Clearing selection");
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
|
||
|
|
} else {
|
||
|
|
const currentSelection = get(selectedNodeIdsAtom);
|
||
|
|
if (currentSelection.has(nodeId) && currentSelection.size === 1) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set([nodeId]));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var toggleNodeInSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeId) => {
|
||
|
|
const currentSelection = get(selectedNodeIdsAtom);
|
||
|
|
const newSelection = new Set(currentSelection);
|
||
|
|
if (newSelection.has(nodeId)) {
|
||
|
|
newSelection.delete(nodeId);
|
||
|
|
} else {
|
||
|
|
newSelection.add(nodeId);
|
||
|
|
}
|
||
|
|
set(selectedNodeIdsAtom, newSelection);
|
||
|
|
});
|
||
|
|
var clearSelectionAtom = (0, import_jotai4.atom)(null, (_get, set) => {
|
||
|
|
debug3("clearSelection");
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
|
||
|
|
});
|
||
|
|
var addNodesToSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeIds) => {
|
||
|
|
const currentSelection = get(selectedNodeIdsAtom);
|
||
|
|
const newSelection = new Set(currentSelection);
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
newSelection.add(nodeId);
|
||
|
|
}
|
||
|
|
set(selectedNodeIdsAtom, newSelection);
|
||
|
|
});
|
||
|
|
var removeNodesFromSelectionAtom = (0, import_jotai4.atom)(null, (get, set, nodeIds) => {
|
||
|
|
const currentSelection = get(selectedNodeIdsAtom);
|
||
|
|
const newSelection = new Set(currentSelection);
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
newSelection.delete(nodeId);
|
||
|
|
}
|
||
|
|
set(selectedNodeIdsAtom, newSelection);
|
||
|
|
});
|
||
|
|
var selectEdgeAtom = (0, import_jotai4.atom)(null, (get, set, edgeId) => {
|
||
|
|
set(selectedEdgeIdAtom, edgeId);
|
||
|
|
if (edgeId !== null) {
|
||
|
|
set(selectedNodeIdsAtom, /* @__PURE__ */ new Set());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var clearEdgeSelectionAtom = (0, import_jotai4.atom)(null, (_get, set) => {
|
||
|
|
set(selectedEdgeIdAtom, null);
|
||
|
|
});
|
||
|
|
var focusedNodeIdAtom = (0, import_jotai4.atom)(null);
|
||
|
|
var setFocusedNodeAtom = (0, import_jotai4.atom)(null, (_get, set, nodeId) => {
|
||
|
|
set(focusedNodeIdAtom, nodeId);
|
||
|
|
});
|
||
|
|
var hasFocusedNodeAtom = (0, import_jotai4.atom)((get) => get(focusedNodeIdAtom) !== null);
|
||
|
|
var selectedNodesCountAtom = (0, import_jotai4.atom)((get) => get(selectedNodeIdsAtom).size);
|
||
|
|
var hasSelectionAtom = (0, import_jotai4.atom)((get) => get(selectedNodeIdsAtom).size > 0);
|
||
|
|
|
||
|
|
// src/utils/layout.ts
|
||
|
|
var FitToBoundsMode = /* @__PURE__ */ (function(FitToBoundsMode2) {
|
||
|
|
FitToBoundsMode2["Graph"] = "graph";
|
||
|
|
FitToBoundsMode2["Selection"] = "selection";
|
||
|
|
return FitToBoundsMode2;
|
||
|
|
})({});
|
||
|
|
var calculateBounds = (nodes) => {
|
||
|
|
if (nodes.length === 0) {
|
||
|
|
return {
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
width: 0,
|
||
|
|
height: 0
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const minX = Math.min(...nodes.map((node) => node.x));
|
||
|
|
const minY = Math.min(...nodes.map((node) => node.y));
|
||
|
|
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
|
||
|
|
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
|
||
|
|
return {
|
||
|
|
x: minX,
|
||
|
|
y: minY,
|
||
|
|
width: maxX - minX,
|
||
|
|
height: maxY - minY
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/core/viewport-store.ts
|
||
|
|
var zoomAtom = (0, import_jotai5.atom)(1);
|
||
|
|
var panAtom = (0, import_jotai5.atom)({
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
var viewportRectAtom = (0, import_jotai5.atom)(null);
|
||
|
|
var screenToWorldAtom = (0, import_jotai5.atom)((get) => {
|
||
|
|
return (screenX, screenY) => {
|
||
|
|
const pan = get(panAtom);
|
||
|
|
const zoom = get(zoomAtom);
|
||
|
|
const rect = get(viewportRectAtom);
|
||
|
|
if (!rect) {
|
||
|
|
return {
|
||
|
|
x: screenX,
|
||
|
|
y: screenY
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const relativeX = screenX - rect.left;
|
||
|
|
const relativeY = screenY - rect.top;
|
||
|
|
return {
|
||
|
|
x: (relativeX - pan.x) / zoom,
|
||
|
|
y: (relativeY - pan.y) / zoom
|
||
|
|
};
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var worldToScreenAtom = (0, import_jotai5.atom)((get) => {
|
||
|
|
return (worldX, worldY) => {
|
||
|
|
const pan = get(panAtom);
|
||
|
|
const zoom = get(zoomAtom);
|
||
|
|
const rect = get(viewportRectAtom);
|
||
|
|
if (!rect) {
|
||
|
|
return {
|
||
|
|
x: worldX,
|
||
|
|
y: worldY
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
x: worldX * zoom + pan.x + rect.left,
|
||
|
|
y: worldY * zoom + pan.y + rect.top
|
||
|
|
};
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var setZoomAtom = (0, import_jotai5.atom)(null, (get, set, {
|
||
|
|
zoom,
|
||
|
|
centerX,
|
||
|
|
centerY
|
||
|
|
}) => {
|
||
|
|
const currentZoom = get(zoomAtom);
|
||
|
|
const pan = get(panAtom);
|
||
|
|
const rect = get(viewportRectAtom);
|
||
|
|
const newZoom = Math.max(0.1, Math.min(5, zoom));
|
||
|
|
if (centerX !== void 0 && centerY !== void 0 && rect) {
|
||
|
|
const relativeX = centerX - rect.left;
|
||
|
|
const relativeY = centerY - rect.top;
|
||
|
|
const worldX = (relativeX - pan.x) / currentZoom;
|
||
|
|
const worldY = (relativeY - pan.y) / currentZoom;
|
||
|
|
const newPanX = relativeX - worldX * newZoom;
|
||
|
|
const newPanY = relativeY - worldY * newZoom;
|
||
|
|
set(panAtom, {
|
||
|
|
x: newPanX,
|
||
|
|
y: newPanY
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(zoomAtom, newZoom);
|
||
|
|
});
|
||
|
|
var resetViewportAtom = (0, import_jotai5.atom)(null, (_get, set) => {
|
||
|
|
set(zoomAtom, 1);
|
||
|
|
set(panAtom, {
|
||
|
|
x: 0,
|
||
|
|
y: 0
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var fitToBoundsAtom = (0, import_jotai5.atom)(null, (get, set, {
|
||
|
|
mode,
|
||
|
|
padding = 20
|
||
|
|
}) => {
|
||
|
|
const normalizedMode = typeof mode === "string" ? mode === "graph" ? FitToBoundsMode.Graph : FitToBoundsMode.Selection : mode;
|
||
|
|
const viewportSize = get(viewportRectAtom);
|
||
|
|
if (!viewportSize || viewportSize.width <= 0 || viewportSize.height <= 0) return;
|
||
|
|
get(nodePositionUpdateCounterAtom);
|
||
|
|
let bounds;
|
||
|
|
if (normalizedMode === FitToBoundsMode.Graph) {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const nodes = graph.nodes().map((node) => {
|
||
|
|
const attrs = graph.getNodeAttributes(node);
|
||
|
|
return {
|
||
|
|
x: attrs.x,
|
||
|
|
y: attrs.y,
|
||
|
|
width: attrs.width || 500,
|
||
|
|
height: attrs.height || 500
|
||
|
|
};
|
||
|
|
});
|
||
|
|
bounds = calculateBounds(nodes);
|
||
|
|
} else {
|
||
|
|
const selectedIds = get(selectedNodeIdsAtom);
|
||
|
|
const allNodes = get(uiNodesAtom);
|
||
|
|
const selectedNodes = allNodes.filter((n) => selectedIds.has(n.id)).map((n) => ({
|
||
|
|
x: n.position.x,
|
||
|
|
y: n.position.y,
|
||
|
|
width: n.width ?? 500,
|
||
|
|
height: n.height ?? 500
|
||
|
|
}));
|
||
|
|
bounds = calculateBounds(selectedNodes);
|
||
|
|
}
|
||
|
|
if (bounds.width <= 0 || bounds.height <= 0) return;
|
||
|
|
const maxHPad = Math.max(0, viewportSize.width / 2 - 1);
|
||
|
|
const maxVPad = Math.max(0, viewportSize.height / 2 - 1);
|
||
|
|
const safePadding = Math.max(0, Math.min(padding, maxHPad, maxVPad));
|
||
|
|
const effW = Math.max(1, viewportSize.width - 2 * safePadding);
|
||
|
|
const effH = Math.max(1, viewportSize.height - 2 * safePadding);
|
||
|
|
const scale = Math.min(effW / bounds.width, effH / bounds.height);
|
||
|
|
if (scale <= 0 || !isFinite(scale)) return;
|
||
|
|
set(zoomAtom, scale);
|
||
|
|
const scaledW = bounds.width * scale;
|
||
|
|
const scaledH = bounds.height * scale;
|
||
|
|
const startX = safePadding + (effW - scaledW) / 2;
|
||
|
|
const startY = safePadding + (effH - scaledH) / 2;
|
||
|
|
set(panAtom, {
|
||
|
|
x: startX - bounds.x * scale,
|
||
|
|
y: startY - bounds.y * scale
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var centerOnNodeAtom = (0, import_jotai5.atom)(null, (get, set, nodeId) => {
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
const node = nodes.find((n) => n.id === nodeId);
|
||
|
|
if (!node) return;
|
||
|
|
const {
|
||
|
|
x,
|
||
|
|
y,
|
||
|
|
width = 200,
|
||
|
|
height = 100
|
||
|
|
} = node;
|
||
|
|
const zoom = get(zoomAtom);
|
||
|
|
const centerX = x + width / 2;
|
||
|
|
const centerY = y + height / 2;
|
||
|
|
const rect = get(viewportRectAtom);
|
||
|
|
const halfWidth = rect ? rect.width / 2 : 400;
|
||
|
|
const halfHeight = rect ? rect.height / 2 : 300;
|
||
|
|
set(panAtom, {
|
||
|
|
x: halfWidth - centerX * zoom,
|
||
|
|
y: halfHeight - centerY * zoom
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var ZOOM_TRANSITION_THRESHOLD = 3.5;
|
||
|
|
var ZOOM_EXIT_THRESHOLD = 2;
|
||
|
|
var zoomFocusNodeIdAtom = (0, import_jotai5.atom)(null);
|
||
|
|
var zoomTransitionProgressAtom = (0, import_jotai5.atom)(0);
|
||
|
|
var isZoomTransitioningAtom = (0, import_jotai5.atom)((get) => {
|
||
|
|
const progress = get(zoomTransitionProgressAtom);
|
||
|
|
return progress > 0 && progress < 1;
|
||
|
|
});
|
||
|
|
var zoomAnimationTargetAtom = (0, import_jotai5.atom)(null);
|
||
|
|
var animateZoomToNodeAtom = (0, import_jotai5.atom)(null, (get, set, {
|
||
|
|
nodeId,
|
||
|
|
targetZoom,
|
||
|
|
duration = 300
|
||
|
|
}) => {
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
const node = nodes.find((n) => n.id === nodeId);
|
||
|
|
if (!node) return;
|
||
|
|
const {
|
||
|
|
x,
|
||
|
|
y,
|
||
|
|
width = 200,
|
||
|
|
height = 100
|
||
|
|
} = node;
|
||
|
|
const centerX = x + width / 2;
|
||
|
|
const centerY = y + height / 2;
|
||
|
|
const rect = get(viewportRectAtom);
|
||
|
|
const halfWidth = rect ? rect.width / 2 : 400;
|
||
|
|
const halfHeight = rect ? rect.height / 2 : 300;
|
||
|
|
const finalZoom = targetZoom ?? get(zoomAtom);
|
||
|
|
const targetPan = {
|
||
|
|
x: halfWidth - centerX * finalZoom,
|
||
|
|
y: halfHeight - centerY * finalZoom
|
||
|
|
};
|
||
|
|
set(zoomFocusNodeIdAtom, nodeId);
|
||
|
|
set(zoomAnimationTargetAtom, {
|
||
|
|
targetZoom: finalZoom,
|
||
|
|
targetPan,
|
||
|
|
startZoom: get(zoomAtom),
|
||
|
|
startPan: {
|
||
|
|
...get(panAtom)
|
||
|
|
},
|
||
|
|
duration,
|
||
|
|
startTime: performance.now()
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var animateFitToBoundsAtom = (0, import_jotai5.atom)(null, (get, set, {
|
||
|
|
mode,
|
||
|
|
padding = 20,
|
||
|
|
duration = 300
|
||
|
|
}) => {
|
||
|
|
const viewportSize = get(viewportRectAtom);
|
||
|
|
if (!viewportSize || viewportSize.width <= 0 || viewportSize.height <= 0) return;
|
||
|
|
get(nodePositionUpdateCounterAtom);
|
||
|
|
let bounds;
|
||
|
|
if (mode === "graph") {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const nodes = graph.nodes().map((node) => {
|
||
|
|
const attrs = graph.getNodeAttributes(node);
|
||
|
|
return {
|
||
|
|
x: attrs.x,
|
||
|
|
y: attrs.y,
|
||
|
|
width: attrs.width || 500,
|
||
|
|
height: attrs.height || 500
|
||
|
|
};
|
||
|
|
});
|
||
|
|
bounds = calculateBounds(nodes);
|
||
|
|
} else {
|
||
|
|
const selectedIds = get(selectedNodeIdsAtom);
|
||
|
|
const allNodes = get(uiNodesAtom);
|
||
|
|
const selectedNodes = allNodes.filter((n) => selectedIds.has(n.id)).map((n) => ({
|
||
|
|
x: n.position.x,
|
||
|
|
y: n.position.y,
|
||
|
|
width: n.width ?? 500,
|
||
|
|
height: n.height ?? 500
|
||
|
|
}));
|
||
|
|
bounds = calculateBounds(selectedNodes);
|
||
|
|
}
|
||
|
|
if (bounds.width <= 0 || bounds.height <= 0) return;
|
||
|
|
const safePadding = Math.max(0, Math.min(padding, viewportSize.width / 2 - 1, viewportSize.height / 2 - 1));
|
||
|
|
const effW = Math.max(1, viewportSize.width - 2 * safePadding);
|
||
|
|
const effH = Math.max(1, viewportSize.height - 2 * safePadding);
|
||
|
|
const scale = Math.min(effW / bounds.width, effH / bounds.height);
|
||
|
|
if (scale <= 0 || !isFinite(scale)) return;
|
||
|
|
const scaledW = bounds.width * scale;
|
||
|
|
const scaledH = bounds.height * scale;
|
||
|
|
const startX = safePadding + (effW - scaledW) / 2;
|
||
|
|
const startY = safePadding + (effH - scaledH) / 2;
|
||
|
|
const targetPan = {
|
||
|
|
x: startX - bounds.x * scale,
|
||
|
|
y: startY - bounds.y * scale
|
||
|
|
};
|
||
|
|
set(zoomAnimationTargetAtom, {
|
||
|
|
targetZoom: scale,
|
||
|
|
targetPan,
|
||
|
|
startZoom: get(zoomAtom),
|
||
|
|
startPan: {
|
||
|
|
...get(panAtom)
|
||
|
|
},
|
||
|
|
duration,
|
||
|
|
startTime: performance.now()
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/group-store.ts
|
||
|
|
var import_jotai7 = require("jotai");
|
||
|
|
|
||
|
|
// src/core/history-store.ts
|
||
|
|
var import_jotai6 = require("jotai");
|
||
|
|
|
||
|
|
// src/core/history-actions.ts
|
||
|
|
function applyDelta(graph, delta) {
|
||
|
|
switch (delta.type) {
|
||
|
|
case "move-node": {
|
||
|
|
if (!graph.hasNode(delta.nodeId)) return false;
|
||
|
|
graph.setNodeAttribute(delta.nodeId, "x", delta.to.x);
|
||
|
|
graph.setNodeAttribute(delta.nodeId, "y", delta.to.y);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
case "resize-node": {
|
||
|
|
if (!graph.hasNode(delta.nodeId)) return false;
|
||
|
|
graph.setNodeAttribute(delta.nodeId, "width", delta.to.width);
|
||
|
|
graph.setNodeAttribute(delta.nodeId, "height", delta.to.height);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
case "add-node": {
|
||
|
|
if (graph.hasNode(delta.nodeId)) return false;
|
||
|
|
graph.addNode(delta.nodeId, delta.attributes);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
case "remove-node": {
|
||
|
|
if (!graph.hasNode(delta.nodeId)) return false;
|
||
|
|
graph.dropNode(delta.nodeId);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
case "add-edge": {
|
||
|
|
if (graph.hasEdge(delta.edgeId)) return false;
|
||
|
|
if (!graph.hasNode(delta.source) || !graph.hasNode(delta.target)) return false;
|
||
|
|
graph.addEdgeWithKey(delta.edgeId, delta.source, delta.target, delta.attributes);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
case "remove-edge": {
|
||
|
|
if (!graph.hasEdge(delta.edgeId)) return false;
|
||
|
|
graph.dropEdge(delta.edgeId);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
case "update-node-attr": {
|
||
|
|
if (!graph.hasNode(delta.nodeId)) return false;
|
||
|
|
graph.setNodeAttribute(delta.nodeId, delta.key, delta.to);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
case "batch": {
|
||
|
|
let structuralChange = false;
|
||
|
|
for (const d of delta.deltas) {
|
||
|
|
if (applyDelta(graph, d)) structuralChange = true;
|
||
|
|
}
|
||
|
|
return structuralChange;
|
||
|
|
}
|
||
|
|
case "full-snapshot": {
|
||
|
|
graph.clear();
|
||
|
|
for (const node of delta.nodes) {
|
||
|
|
graph.addNode(node.id, node.attributes);
|
||
|
|
}
|
||
|
|
for (const edge of delta.edges) {
|
||
|
|
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
|
||
|
|
graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge.attributes);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function invertDelta(delta) {
|
||
|
|
switch (delta.type) {
|
||
|
|
case "move-node":
|
||
|
|
return {
|
||
|
|
...delta,
|
||
|
|
from: delta.to,
|
||
|
|
to: delta.from
|
||
|
|
};
|
||
|
|
case "resize-node":
|
||
|
|
return {
|
||
|
|
...delta,
|
||
|
|
from: delta.to,
|
||
|
|
to: delta.from
|
||
|
|
};
|
||
|
|
case "add-node":
|
||
|
|
return {
|
||
|
|
type: "remove-node",
|
||
|
|
nodeId: delta.nodeId,
|
||
|
|
attributes: delta.attributes,
|
||
|
|
connectedEdges: []
|
||
|
|
};
|
||
|
|
case "remove-node": {
|
||
|
|
const batch = [{
|
||
|
|
type: "add-node",
|
||
|
|
nodeId: delta.nodeId,
|
||
|
|
attributes: delta.attributes
|
||
|
|
}, ...delta.connectedEdges.map((e) => ({
|
||
|
|
type: "add-edge",
|
||
|
|
edgeId: e.id,
|
||
|
|
source: e.source,
|
||
|
|
target: e.target,
|
||
|
|
attributes: e.attributes
|
||
|
|
}))];
|
||
|
|
return batch.length === 1 ? batch[0] : {
|
||
|
|
type: "batch",
|
||
|
|
deltas: batch
|
||
|
|
};
|
||
|
|
}
|
||
|
|
case "add-edge":
|
||
|
|
return {
|
||
|
|
type: "remove-edge",
|
||
|
|
edgeId: delta.edgeId,
|
||
|
|
source: delta.source,
|
||
|
|
target: delta.target,
|
||
|
|
attributes: delta.attributes
|
||
|
|
};
|
||
|
|
case "remove-edge":
|
||
|
|
return {
|
||
|
|
type: "add-edge",
|
||
|
|
edgeId: delta.edgeId,
|
||
|
|
source: delta.source,
|
||
|
|
target: delta.target,
|
||
|
|
attributes: delta.attributes
|
||
|
|
};
|
||
|
|
case "update-node-attr":
|
||
|
|
return {
|
||
|
|
...delta,
|
||
|
|
from: delta.to,
|
||
|
|
to: delta.from
|
||
|
|
};
|
||
|
|
case "batch":
|
||
|
|
return {
|
||
|
|
type: "batch",
|
||
|
|
deltas: delta.deltas.map(invertDelta).reverse()
|
||
|
|
};
|
||
|
|
case "full-snapshot":
|
||
|
|
return delta;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function createSnapshot(graph, label) {
|
||
|
|
const nodes = [];
|
||
|
|
const edges = [];
|
||
|
|
graph.forEachNode((nodeId, attributes) => {
|
||
|
|
nodes.push({
|
||
|
|
id: nodeId,
|
||
|
|
attributes: {
|
||
|
|
...attributes
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
graph.forEachEdge((edgeId, attributes, source, target) => {
|
||
|
|
edges.push({
|
||
|
|
id: edgeId,
|
||
|
|
source,
|
||
|
|
target,
|
||
|
|
attributes: {
|
||
|
|
...attributes
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
timestamp: Date.now(),
|
||
|
|
label,
|
||
|
|
nodes,
|
||
|
|
edges
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/core/history-store.ts
|
||
|
|
var debug4 = createDebug("history");
|
||
|
|
var MAX_HISTORY_SIZE = 50;
|
||
|
|
var historyStateAtom = (0, import_jotai6.atom)({
|
||
|
|
past: [],
|
||
|
|
future: [],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
var canUndoAtom = (0, import_jotai6.atom)((get) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
return history.past.length > 0 && !history.isApplying;
|
||
|
|
});
|
||
|
|
var canRedoAtom = (0, import_jotai6.atom)((get) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
return history.future.length > 0 && !history.isApplying;
|
||
|
|
});
|
||
|
|
var undoCountAtom = (0, import_jotai6.atom)((get) => get(historyStateAtom).past.length);
|
||
|
|
var redoCountAtom = (0, import_jotai6.atom)((get) => get(historyStateAtom).future.length);
|
||
|
|
var pushDeltaAtom = (0, import_jotai6.atom)(null, (get, set, delta) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
if (history.isApplying) return;
|
||
|
|
const {
|
||
|
|
label,
|
||
|
|
...cleanDelta
|
||
|
|
} = delta;
|
||
|
|
const entry = {
|
||
|
|
forward: cleanDelta,
|
||
|
|
reverse: invertDelta(cleanDelta),
|
||
|
|
timestamp: Date.now(),
|
||
|
|
label
|
||
|
|
};
|
||
|
|
const newPast = [...history.past, entry];
|
||
|
|
if (newPast.length > MAX_HISTORY_SIZE) newPast.shift();
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: newPast,
|
||
|
|
future: [],
|
||
|
|
// Clear redo stack
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("Pushed delta: %s (past: %d)", label || delta.type, newPast.length);
|
||
|
|
});
|
||
|
|
var pushHistoryAtom = (0, import_jotai6.atom)(null, (get, set, label) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
if (history.isApplying) return;
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const snapshot = createSnapshot(graph, label);
|
||
|
|
const forward = {
|
||
|
|
type: "full-snapshot",
|
||
|
|
nodes: snapshot.nodes,
|
||
|
|
edges: snapshot.edges
|
||
|
|
};
|
||
|
|
const entry = {
|
||
|
|
forward,
|
||
|
|
reverse: forward,
|
||
|
|
// For full snapshots, reverse IS the current state
|
||
|
|
timestamp: Date.now(),
|
||
|
|
label
|
||
|
|
};
|
||
|
|
const newPast = [...history.past, entry];
|
||
|
|
if (newPast.length > MAX_HISTORY_SIZE) newPast.shift();
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: newPast,
|
||
|
|
future: [],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("Pushed snapshot: %s (past: %d)", label || "unnamed", newPast.length);
|
||
|
|
});
|
||
|
|
var undoAtom = (0, import_jotai6.atom)(null, (get, set) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
if (history.past.length === 0 || history.isApplying) return false;
|
||
|
|
set(historyStateAtom, {
|
||
|
|
...history,
|
||
|
|
isApplying: true
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const newPast = [...history.past];
|
||
|
|
const entry = newPast.pop();
|
||
|
|
let forwardForRedo = entry.forward;
|
||
|
|
if (entry.reverse.type === "full-snapshot") {
|
||
|
|
const currentSnapshot = createSnapshot(graph, "current");
|
||
|
|
forwardForRedo = {
|
||
|
|
type: "full-snapshot",
|
||
|
|
nodes: currentSnapshot.nodes,
|
||
|
|
edges: currentSnapshot.edges
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const structuralChange = applyDelta(graph, entry.reverse);
|
||
|
|
if (structuralChange) {
|
||
|
|
set(graphAtom, graph);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
}
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
const redoEntry = {
|
||
|
|
forward: forwardForRedo,
|
||
|
|
reverse: entry.reverse,
|
||
|
|
timestamp: entry.timestamp,
|
||
|
|
label: entry.label
|
||
|
|
};
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: newPast,
|
||
|
|
future: [redoEntry, ...history.future],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("Undo: %s (past: %d, future: %d)", entry.label, newPast.length, history.future.length + 1);
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
debug4.error("Undo failed: %O", error);
|
||
|
|
set(historyStateAtom, {
|
||
|
|
...history,
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var redoAtom = (0, import_jotai6.atom)(null, (get, set) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
if (history.future.length === 0 || history.isApplying) return false;
|
||
|
|
set(historyStateAtom, {
|
||
|
|
...history,
|
||
|
|
isApplying: true
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const newFuture = [...history.future];
|
||
|
|
const entry = newFuture.shift();
|
||
|
|
let reverseForUndo = entry.reverse;
|
||
|
|
if (entry.forward.type === "full-snapshot") {
|
||
|
|
const currentSnapshot = createSnapshot(graph, "current");
|
||
|
|
reverseForUndo = {
|
||
|
|
type: "full-snapshot",
|
||
|
|
nodes: currentSnapshot.nodes,
|
||
|
|
edges: currentSnapshot.edges
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const structuralChange = applyDelta(graph, entry.forward);
|
||
|
|
if (structuralChange) {
|
||
|
|
set(graphAtom, graph);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
}
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
const undoEntry = {
|
||
|
|
forward: entry.forward,
|
||
|
|
reverse: reverseForUndo,
|
||
|
|
timestamp: entry.timestamp,
|
||
|
|
label: entry.label
|
||
|
|
};
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: [...history.past, undoEntry],
|
||
|
|
future: newFuture,
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("Redo: %s (past: %d, future: %d)", entry.label, history.past.length + 1, newFuture.length);
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
debug4.error("Redo failed: %O", error);
|
||
|
|
set(historyStateAtom, {
|
||
|
|
...history,
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var clearHistoryAtom = (0, import_jotai6.atom)(null, (_get, set) => {
|
||
|
|
set(historyStateAtom, {
|
||
|
|
past: [],
|
||
|
|
future: [],
|
||
|
|
isApplying: false
|
||
|
|
});
|
||
|
|
debug4("History cleared");
|
||
|
|
});
|
||
|
|
var historyLabelsAtom = (0, import_jotai6.atom)((get) => {
|
||
|
|
const history = get(historyStateAtom);
|
||
|
|
return {
|
||
|
|
past: history.past.map((e) => e.label || "Unnamed"),
|
||
|
|
future: history.future.map((e) => e.label || "Unnamed")
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/group-store.ts
|
||
|
|
var collapsedGroupsAtom = (0, import_jotai7.atom)(/* @__PURE__ */ new Set());
|
||
|
|
var toggleGroupCollapseAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
|
||
|
|
const current = get(collapsedGroupsAtom);
|
||
|
|
const next = new Set(current);
|
||
|
|
if (next.has(groupId)) {
|
||
|
|
next.delete(groupId);
|
||
|
|
} else {
|
||
|
|
next.add(groupId);
|
||
|
|
}
|
||
|
|
set(collapsedGroupsAtom, next);
|
||
|
|
});
|
||
|
|
var collapseGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
|
||
|
|
const current = get(collapsedGroupsAtom);
|
||
|
|
if (!current.has(groupId)) {
|
||
|
|
const next = new Set(current);
|
||
|
|
next.add(groupId);
|
||
|
|
set(collapsedGroupsAtom, next);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var expandGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
|
||
|
|
const current = get(collapsedGroupsAtom);
|
||
|
|
if (current.has(groupId)) {
|
||
|
|
const next = new Set(current);
|
||
|
|
next.delete(groupId);
|
||
|
|
set(collapsedGroupsAtom, next);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var nodeChildrenAtom = (0, import_jotai7.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return (parentId) => {
|
||
|
|
const children = [];
|
||
|
|
graph.forEachNode((nodeId, attrs) => {
|
||
|
|
if (attrs.parentId === parentId) {
|
||
|
|
children.push(nodeId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return children;
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var nodeParentAtom = (0, import_jotai7.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return (nodeId) => {
|
||
|
|
if (!graph.hasNode(nodeId)) return void 0;
|
||
|
|
return graph.getNodeAttribute(nodeId, "parentId");
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var isGroupNodeAtom = (0, import_jotai7.atom)((get) => {
|
||
|
|
const getChildren = get(nodeChildrenAtom);
|
||
|
|
return (nodeId) => getChildren(nodeId).length > 0;
|
||
|
|
});
|
||
|
|
var groupChildCountAtom = (0, import_jotai7.atom)((get) => {
|
||
|
|
const getChildren = get(nodeChildrenAtom);
|
||
|
|
return (groupId) => getChildren(groupId).length;
|
||
|
|
});
|
||
|
|
var setNodeParentAtom = (0, import_jotai7.atom)(null, (get, set, {
|
||
|
|
nodeId,
|
||
|
|
parentId
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(nodeId)) return;
|
||
|
|
if (parentId) {
|
||
|
|
if (parentId === nodeId) return;
|
||
|
|
let current = parentId;
|
||
|
|
while (current) {
|
||
|
|
if (current === nodeId) return;
|
||
|
|
if (!graph.hasNode(current)) break;
|
||
|
|
current = graph.getNodeAttribute(current, "parentId");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
graph.setNodeAttribute(nodeId, "parentId", parentId);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
});
|
||
|
|
var moveNodesToGroupAtom = (0, import_jotai7.atom)(null, (get, set, {
|
||
|
|
nodeIds,
|
||
|
|
groupId
|
||
|
|
}) => {
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
set(setNodeParentAtom, {
|
||
|
|
nodeId,
|
||
|
|
parentId: groupId
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var removeFromGroupAtom = (0, import_jotai7.atom)(null, (get, set, nodeId) => {
|
||
|
|
set(setNodeParentAtom, {
|
||
|
|
nodeId,
|
||
|
|
parentId: void 0
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var groupSelectedNodesAtom = (0, import_jotai7.atom)(null, (get, set, {
|
||
|
|
nodeIds,
|
||
|
|
groupNodeId
|
||
|
|
}) => {
|
||
|
|
set(pushHistoryAtom, `Group ${nodeIds.length} nodes`);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
if (!graph.hasNode(nodeId)) continue;
|
||
|
|
const attrs = graph.getNodeAttributes(nodeId);
|
||
|
|
minX = Math.min(minX, attrs.x);
|
||
|
|
minY = Math.min(minY, attrs.y);
|
||
|
|
maxX = Math.max(maxX, attrs.x + (attrs.width || 200));
|
||
|
|
maxY = Math.max(maxY, attrs.y + (attrs.height || 100));
|
||
|
|
}
|
||
|
|
const padding = 20;
|
||
|
|
if (graph.hasNode(groupNodeId)) {
|
||
|
|
graph.setNodeAttribute(groupNodeId, "x", minX - padding);
|
||
|
|
graph.setNodeAttribute(groupNodeId, "y", minY - padding - 30);
|
||
|
|
graph.setNodeAttribute(groupNodeId, "width", maxX - minX + 2 * padding);
|
||
|
|
graph.setNodeAttribute(groupNodeId, "height", maxY - minY + 2 * padding + 30);
|
||
|
|
}
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
if (nodeId !== groupNodeId && graph.hasNode(nodeId)) {
|
||
|
|
graph.setNodeAttribute(nodeId, "parentId", groupNodeId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
});
|
||
|
|
var ungroupNodesAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
|
||
|
|
set(pushHistoryAtom, "Ungroup nodes");
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
graph.forEachNode((nodeId, attrs) => {
|
||
|
|
if (attrs.parentId === groupId) {
|
||
|
|
graph.setNodeAttribute(nodeId, "parentId", void 0);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
});
|
||
|
|
var nestNodesOnDropAtom = (0, import_jotai7.atom)(null, (get, set, {
|
||
|
|
nodeIds,
|
||
|
|
targetId
|
||
|
|
}) => {
|
||
|
|
set(pushHistoryAtom, "Nest nodes");
|
||
|
|
for (const nodeId of nodeIds) {
|
||
|
|
if (nodeId === targetId) continue;
|
||
|
|
set(setNodeParentAtom, {
|
||
|
|
nodeId,
|
||
|
|
parentId: targetId
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(autoResizeGroupAtom, targetId);
|
||
|
|
});
|
||
|
|
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 = (0, import_jotai7.atom)((get) => {
|
||
|
|
const collapsed = get(collapsedGroupsAtom);
|
||
|
|
if (collapsed.size === 0) return /* @__PURE__ */ new Map();
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const remap = /* @__PURE__ */ new Map();
|
||
|
|
for (const nodeId of graph.nodes()) {
|
||
|
|
let current = nodeId;
|
||
|
|
let outermost = null;
|
||
|
|
while (true) {
|
||
|
|
if (!graph.hasNode(current)) break;
|
||
|
|
const parent = graph.getNodeAttribute(current, "parentId");
|
||
|
|
if (!parent) break;
|
||
|
|
if (collapsed.has(parent)) outermost = parent;
|
||
|
|
current = parent;
|
||
|
|
}
|
||
|
|
if (outermost) remap.set(nodeId, outermost);
|
||
|
|
}
|
||
|
|
return remap;
|
||
|
|
});
|
||
|
|
var autoResizeGroupAtom = (0, import_jotai7.atom)(null, (get, set, groupId) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(groupId)) return;
|
||
|
|
const children = [];
|
||
|
|
graph.forEachNode((nodeId, attrs) => {
|
||
|
|
if (attrs.parentId === groupId) {
|
||
|
|
children.push(nodeId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
if (children.length === 0) return;
|
||
|
|
const padding = 20;
|
||
|
|
const headerHeight = 30;
|
||
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
|
|
for (const childId of children) {
|
||
|
|
const attrs = graph.getNodeAttributes(childId);
|
||
|
|
minX = Math.min(minX, attrs.x);
|
||
|
|
minY = Math.min(minY, attrs.y);
|
||
|
|
maxX = Math.max(maxX, attrs.x + (attrs.width || 200));
|
||
|
|
maxY = Math.max(maxY, attrs.y + (attrs.height || 100));
|
||
|
|
}
|
||
|
|
graph.setNodeAttribute(groupId, "x", minX - padding);
|
||
|
|
graph.setNodeAttribute(groupId, "y", minY - padding - headerHeight);
|
||
|
|
graph.setNodeAttribute(groupId, "width", maxX - minX + 2 * padding);
|
||
|
|
graph.setNodeAttribute(groupId, "height", maxY - minY + 2 * padding + headerHeight);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
});
|
||
|
|
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 = (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
let maxZ = 0;
|
||
|
|
graph.forEachNode((_node, attributes) => {
|
||
|
|
if (attributes.zIndex > maxZ) {
|
||
|
|
maxZ = attributes.zIndex;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return maxZ;
|
||
|
|
});
|
||
|
|
var _prevUiNodesByGraph = /* @__PURE__ */ new WeakMap();
|
||
|
|
var uiNodesAtom = (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const currentDraggingId = get(draggingNodeIdAtom);
|
||
|
|
const collapsed = get(collapsedGroupsAtom);
|
||
|
|
const nodes = [];
|
||
|
|
graph.forEachNode((nodeId, attributes) => {
|
||
|
|
if (collapsed.size > 0) {
|
||
|
|
let current = nodeId;
|
||
|
|
let hidden = false;
|
||
|
|
while (true) {
|
||
|
|
if (!graph.hasNode(current)) break;
|
||
|
|
const pid = graph.getNodeAttributes(current).parentId;
|
||
|
|
if (!pid) break;
|
||
|
|
if (collapsed.has(pid)) {
|
||
|
|
hidden = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
current = pid;
|
||
|
|
}
|
||
|
|
if (hidden) return;
|
||
|
|
}
|
||
|
|
const position = get(nodePositionAtomFamily(nodeId));
|
||
|
|
nodes.push({
|
||
|
|
...attributes,
|
||
|
|
id: nodeId,
|
||
|
|
position,
|
||
|
|
isDragging: nodeId === currentDraggingId
|
||
|
|
});
|
||
|
|
});
|
||
|
|
const prev = _prevUiNodesByGraph.get(graph) ?? [];
|
||
|
|
if (nodes.length === prev.length && nodes.every((n, i) => n.id === prev[i].id && n.position === prev[i].position && n.isDragging === prev[i].isDragging)) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
_prevUiNodesByGraph.set(graph, nodes);
|
||
|
|
return nodes;
|
||
|
|
});
|
||
|
|
var nodeKeysAtom = (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return graph.nodes();
|
||
|
|
});
|
||
|
|
var nodeFamilyAtom = (0, import_jotai_family2.atomFamily)((nodeId) => (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(nodeId)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const attributes = graph.getNodeAttributes(nodeId);
|
||
|
|
const position = get(nodePositionAtomFamily(nodeId));
|
||
|
|
const currentDraggingId = get(draggingNodeIdAtom);
|
||
|
|
return {
|
||
|
|
...attributes,
|
||
|
|
id: nodeId,
|
||
|
|
position,
|
||
|
|
isDragging: nodeId === currentDraggingId
|
||
|
|
};
|
||
|
|
}), (a, b) => a === b);
|
||
|
|
var edgeKeysAtom = (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
return graph.edges();
|
||
|
|
});
|
||
|
|
var edgeKeysWithTempEdgeAtom = (0, import_jotai8.atom)((get) => {
|
||
|
|
const keys = get(edgeKeysAtom);
|
||
|
|
const edgeCreation = get(edgeCreationAtom);
|
||
|
|
if (edgeCreation.isCreating) {
|
||
|
|
return [...keys, "temp-creating-edge"];
|
||
|
|
}
|
||
|
|
return keys;
|
||
|
|
});
|
||
|
|
var _edgeCacheByGraph = /* @__PURE__ */ new WeakMap();
|
||
|
|
function getEdgeCache(graph) {
|
||
|
|
let cache = _edgeCacheByGraph.get(graph);
|
||
|
|
if (!cache) {
|
||
|
|
cache = /* @__PURE__ */ new Map();
|
||
|
|
_edgeCacheByGraph.set(graph, cache);
|
||
|
|
}
|
||
|
|
return cache;
|
||
|
|
}
|
||
|
|
var edgeFamilyAtom = (0, import_jotai_family2.atomFamily)((key) => (0, import_jotai8.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
if (key === "temp-creating-edge") {
|
||
|
|
const edgeCreationState = get(edgeCreationAtom);
|
||
|
|
const graph2 = get(graphAtom);
|
||
|
|
if (edgeCreationState.isCreating && edgeCreationState.sourceNodeId && edgeCreationState.targetPosition) {
|
||
|
|
const sourceNodeAttrs = graph2.getNodeAttributes(edgeCreationState.sourceNodeId);
|
||
|
|
const sourceNodePosition = get(nodePositionAtomFamily(edgeCreationState.sourceNodeId));
|
||
|
|
const pan = get(panAtom);
|
||
|
|
const zoom = get(zoomAtom);
|
||
|
|
const viewportRect = get(viewportRectAtom);
|
||
|
|
if (sourceNodeAttrs && viewportRect) {
|
||
|
|
const mouseX = edgeCreationState.targetPosition.x - viewportRect.left;
|
||
|
|
const mouseY = edgeCreationState.targetPosition.y - viewportRect.top;
|
||
|
|
const worldTargetX = (mouseX - pan.x) / zoom;
|
||
|
|
const worldTargetY = (mouseY - pan.y) / zoom;
|
||
|
|
const tempEdge = {
|
||
|
|
key: "temp-creating-edge",
|
||
|
|
sourceId: edgeCreationState.sourceNodeId,
|
||
|
|
targetId: "temp-cursor",
|
||
|
|
sourcePosition: sourceNodePosition,
|
||
|
|
targetPosition: {
|
||
|
|
x: worldTargetX,
|
||
|
|
y: worldTargetY
|
||
|
|
},
|
||
|
|
sourceNodeSize: sourceNodeAttrs.size,
|
||
|
|
sourceNodeWidth: sourceNodeAttrs.width,
|
||
|
|
sourceNodeHeight: sourceNodeAttrs.height,
|
||
|
|
targetNodeSize: 0,
|
||
|
|
targetNodeWidth: 0,
|
||
|
|
targetNodeHeight: 0,
|
||
|
|
type: "dashed",
|
||
|
|
color: "#FF9800",
|
||
|
|
weight: 2,
|
||
|
|
label: void 0,
|
||
|
|
dbData: {
|
||
|
|
id: "temp-creating-edge",
|
||
|
|
graph_id: get(currentGraphIdAtom) || "",
|
||
|
|
source_node_id: edgeCreationState.sourceNodeId,
|
||
|
|
target_node_id: "temp-cursor",
|
||
|
|
edge_type: "temp",
|
||
|
|
filter_condition: null,
|
||
|
|
ui_properties: null,
|
||
|
|
data: null,
|
||
|
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return tempEdge;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasEdge(key)) {
|
||
|
|
getEdgeCache(graph).delete(key);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const sourceId = graph.source(key);
|
||
|
|
const targetId = graph.target(key);
|
||
|
|
const attributes = graph.getEdgeAttributes(key);
|
||
|
|
const remap = get(collapsedEdgeRemapAtom);
|
||
|
|
const effectiveSourceId = remap.get(sourceId) ?? sourceId;
|
||
|
|
const effectiveTargetId = remap.get(targetId) ?? targetId;
|
||
|
|
if (!graph.hasNode(effectiveSourceId) || !graph.hasNode(effectiveTargetId)) {
|
||
|
|
getEdgeCache(graph).delete(key);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const sourceAttributes = graph.getNodeAttributes(effectiveSourceId);
|
||
|
|
const targetAttributes = graph.getNodeAttributes(effectiveTargetId);
|
||
|
|
const sourcePosition = get(nodePositionAtomFamily(effectiveSourceId));
|
||
|
|
const targetPosition = get(nodePositionAtomFamily(effectiveTargetId));
|
||
|
|
if (sourceAttributes && targetAttributes) {
|
||
|
|
const next = {
|
||
|
|
...attributes,
|
||
|
|
key,
|
||
|
|
sourceId: effectiveSourceId,
|
||
|
|
targetId: effectiveTargetId,
|
||
|
|
sourcePosition,
|
||
|
|
targetPosition,
|
||
|
|
sourceNodeSize: sourceAttributes.size,
|
||
|
|
targetNodeSize: targetAttributes.size,
|
||
|
|
sourceNodeWidth: sourceAttributes.width ?? sourceAttributes.size,
|
||
|
|
sourceNodeHeight: sourceAttributes.height ?? sourceAttributes.size,
|
||
|
|
targetNodeWidth: targetAttributes.width ?? targetAttributes.size,
|
||
|
|
targetNodeHeight: targetAttributes.height ?? targetAttributes.size
|
||
|
|
};
|
||
|
|
const edgeCache = getEdgeCache(graph);
|
||
|
|
const prev = edgeCache.get(key);
|
||
|
|
if (prev && prev.sourcePosition === next.sourcePosition && prev.targetPosition === next.targetPosition && prev.sourceId === next.sourceId && prev.targetId === next.targetId && prev.type === next.type && prev.color === next.color && prev.weight === next.weight && prev.label === next.label && prev.sourceNodeSize === next.sourceNodeSize && prev.targetNodeSize === next.targetNodeSize && prev.sourceNodeWidth === next.sourceNodeWidth && prev.sourceNodeHeight === next.sourceNodeHeight && prev.targetNodeWidth === next.targetNodeWidth && prev.targetNodeHeight === next.targetNodeHeight) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
edgeCache.set(key, next);
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
getEdgeCache(graph).delete(key);
|
||
|
|
return null;
|
||
|
|
}), (a, b) => a === b);
|
||
|
|
|
||
|
|
// src/core/graph-mutations.ts
|
||
|
|
var import_jotai12 = require("jotai");
|
||
|
|
var import_graphology3 = __toESM(require("graphology"));
|
||
|
|
|
||
|
|
// src/core/graph-mutations-edges.ts
|
||
|
|
var import_jotai10 = require("jotai");
|
||
|
|
|
||
|
|
// src/core/reduced-motion-store.ts
|
||
|
|
var import_jotai9 = require("jotai");
|
||
|
|
var prefersReducedMotionAtom = (0, import_jotai9.atom)(typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false);
|
||
|
|
var watchReducedMotionAtom = (0, import_jotai9.atom)(null, (_get, set) => {
|
||
|
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||
|
|
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
|
|
const handler = (e) => {
|
||
|
|
set(prefersReducedMotionAtom, e.matches);
|
||
|
|
};
|
||
|
|
set(prefersReducedMotionAtom, mql.matches);
|
||
|
|
mql.addEventListener("change", handler);
|
||
|
|
return () => mql.removeEventListener("change", handler);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/graph-mutations-edges.ts
|
||
|
|
var debug5 = createDebug("graph:mutations:edges");
|
||
|
|
var addEdgeToLocalGraphAtom = (0, import_jotai10.atom)(null, (get, set, newEdge) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasNode(newEdge.source_node_id) && graph.hasNode(newEdge.target_node_id)) {
|
||
|
|
const uiProps = newEdge.ui_properties || {};
|
||
|
|
const attributes = {
|
||
|
|
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
|
||
|
|
label: newEdge.edge_type ?? void 0,
|
||
|
|
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
|
||
|
|
dbData: newEdge
|
||
|
|
};
|
||
|
|
if (!graph.hasEdge(newEdge.id)) {
|
||
|
|
try {
|
||
|
|
debug5("Adding edge %s to local graph", newEdge.id);
|
||
|
|
graph.addEdgeWithKey(newEdge.id, newEdge.source_node_id, newEdge.target_node_id, attributes);
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
} catch (e) {
|
||
|
|
debug5("Failed to add edge %s: %o", newEdge.id, e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var removeEdgeFromLocalGraphAtom = (0, import_jotai10.atom)(null, (get, set, edgeId) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasEdge(edgeId)) {
|
||
|
|
graph.dropEdge(edgeId);
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var swapEdgeAtomicAtom = (0, import_jotai10.atom)(null, (get, set, {
|
||
|
|
tempEdgeId,
|
||
|
|
newEdge
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasEdge(tempEdgeId)) {
|
||
|
|
graph.dropEdge(tempEdgeId);
|
||
|
|
}
|
||
|
|
if (graph.hasNode(newEdge.source_node_id) && graph.hasNode(newEdge.target_node_id)) {
|
||
|
|
const uiProps = newEdge.ui_properties || {};
|
||
|
|
const attributes = {
|
||
|
|
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
|
||
|
|
label: newEdge.edge_type ?? void 0,
|
||
|
|
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
|
||
|
|
dbData: newEdge
|
||
|
|
};
|
||
|
|
if (!graph.hasEdge(newEdge.id)) {
|
||
|
|
try {
|
||
|
|
debug5("Atomically swapping temp edge %s with real edge %s", tempEdgeId, newEdge.id);
|
||
|
|
graph.addEdgeWithKey(newEdge.id, newEdge.source_node_id, newEdge.target_node_id, attributes);
|
||
|
|
} catch (e) {
|
||
|
|
debug5("Failed to add edge %s: %o", newEdge.id, e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
});
|
||
|
|
var departingEdgesAtom = (0, import_jotai10.atom)(/* @__PURE__ */ new Map());
|
||
|
|
var EDGE_ANIMATION_DURATION = 300;
|
||
|
|
var removeEdgeWithAnimationAtom = (0, import_jotai10.atom)(null, (get, set, edgeKey) => {
|
||
|
|
const edgeState = get(edgeFamilyAtom(edgeKey));
|
||
|
|
if (edgeState) {
|
||
|
|
const departing = new Map(get(departingEdgesAtom));
|
||
|
|
departing.set(edgeKey, edgeState);
|
||
|
|
set(departingEdgesAtom, departing);
|
||
|
|
set(removeEdgeFromLocalGraphAtom, edgeKey);
|
||
|
|
const duration = get(prefersReducedMotionAtom) ? 0 : EDGE_ANIMATION_DURATION;
|
||
|
|
setTimeout(() => {
|
||
|
|
const current = new Map(get(departingEdgesAtom));
|
||
|
|
current.delete(edgeKey);
|
||
|
|
set(departingEdgesAtom, current);
|
||
|
|
}, duration);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var editingEdgeLabelAtom = (0, import_jotai10.atom)(null);
|
||
|
|
var updateEdgeLabelAtom = (0, import_jotai10.atom)(null, (get, set, {
|
||
|
|
edgeKey,
|
||
|
|
label
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasEdge(edgeKey)) {
|
||
|
|
graph.setEdgeAttribute(edgeKey, "label", label || void 0);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/graph-mutations-advanced.ts
|
||
|
|
var import_jotai11 = require("jotai");
|
||
|
|
var debug6 = createDebug("graph:mutations:advanced");
|
||
|
|
var dropTargetNodeIdAtom = (0, import_jotai11.atom)(null);
|
||
|
|
var splitNodeAtom = (0, import_jotai11.atom)(null, (get, set, {
|
||
|
|
nodeId,
|
||
|
|
position1,
|
||
|
|
position2
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(nodeId)) return;
|
||
|
|
const attrs = graph.getNodeAttributes(nodeId);
|
||
|
|
const graphId = get(currentGraphIdAtom) || attrs.dbData.graph_id;
|
||
|
|
set(pushHistoryAtom, "Split node");
|
||
|
|
graph.setNodeAttribute(nodeId, "x", position1.x);
|
||
|
|
graph.setNodeAttribute(nodeId, "y", position1.y);
|
||
|
|
const edges = [];
|
||
|
|
graph.forEachEdge(nodeId, (_key, eAttrs, source, target) => {
|
||
|
|
edges.push({
|
||
|
|
source,
|
||
|
|
target,
|
||
|
|
attrs: eAttrs
|
||
|
|
});
|
||
|
|
});
|
||
|
|
const cloneId = `split-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
|
|
const cloneDbNode = {
|
||
|
|
...attrs.dbData,
|
||
|
|
id: cloneId,
|
||
|
|
graph_id: graphId,
|
||
|
|
ui_properties: {
|
||
|
|
...attrs.dbData.ui_properties || {},
|
||
|
|
x: position2.x,
|
||
|
|
y: position2.y
|
||
|
|
},
|
||
|
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||
|
|
};
|
||
|
|
set(addNodeToLocalGraphAtom, cloneDbNode);
|
||
|
|
for (const edge of edges) {
|
||
|
|
const newSource = edge.source === nodeId ? cloneId : edge.source;
|
||
|
|
const newTarget = edge.target === nodeId ? cloneId : edge.target;
|
||
|
|
set(addEdgeToLocalGraphAtom, {
|
||
|
|
...edge.attrs.dbData,
|
||
|
|
id: `split-e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
|
|
source_node_id: newSource,
|
||
|
|
target_node_id: newTarget
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
debug6("Split node %s \u2192 clone %s", nodeId, cloneId);
|
||
|
|
});
|
||
|
|
var mergeNodesAtom = (0, import_jotai11.atom)(null, (get, set, {
|
||
|
|
nodeIds
|
||
|
|
}) => {
|
||
|
|
if (nodeIds.length < 2) return;
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const [survivorId, ...doomed] = nodeIds;
|
||
|
|
if (!graph.hasNode(survivorId)) return;
|
||
|
|
set(pushHistoryAtom, `Merge ${nodeIds.length} nodes`);
|
||
|
|
const doomedSet = new Set(doomed);
|
||
|
|
for (const doomedId of doomed) {
|
||
|
|
if (!graph.hasNode(doomedId)) continue;
|
||
|
|
const edges = [];
|
||
|
|
graph.forEachEdge(doomedId, (_key, eAttrs, source, target) => {
|
||
|
|
edges.push({
|
||
|
|
source,
|
||
|
|
target,
|
||
|
|
attrs: eAttrs
|
||
|
|
});
|
||
|
|
});
|
||
|
|
for (const edge of edges) {
|
||
|
|
const newSource = doomedSet.has(edge.source) ? survivorId : edge.source;
|
||
|
|
const newTarget = doomedSet.has(edge.target) ? survivorId : edge.target;
|
||
|
|
if (newSource === newTarget) continue;
|
||
|
|
set(addEdgeToLocalGraphAtom, {
|
||
|
|
...edge.attrs.dbData,
|
||
|
|
id: `merge-e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
|
|
source_node_id: newSource,
|
||
|
|
target_node_id: newTarget
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(optimisticDeleteNodeAtom, {
|
||
|
|
nodeId: doomedId
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
debug6("Merged nodes %o \u2192 survivor %s", nodeIds, survivorId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/graph-mutations.ts
|
||
|
|
var debug7 = createDebug("graph:mutations");
|
||
|
|
var startNodeDragAtom = (0, import_jotai12.atom)(null, (get, set, {
|
||
|
|
nodeId
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (!graph.hasNode(nodeId)) return;
|
||
|
|
const currentAttributes = graph.getNodeAttributes(nodeId);
|
||
|
|
set(preDragNodeAttributesAtom, JSON.parse(JSON.stringify(currentAttributes)));
|
||
|
|
const currentHighestZIndex = get(highestZIndexAtom);
|
||
|
|
const newZIndex = currentHighestZIndex + 1;
|
||
|
|
graph.setNodeAttribute(nodeId, "zIndex", newZIndex);
|
||
|
|
set(draggingNodeIdAtom, nodeId);
|
||
|
|
});
|
||
|
|
var endNodeDragAtom = (0, import_jotai12.atom)(null, (get, set, _payload) => {
|
||
|
|
const currentDraggingId = get(draggingNodeIdAtom);
|
||
|
|
if (currentDraggingId) {
|
||
|
|
debug7("Node %s drag ended", currentDraggingId);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasNode(currentDraggingId)) {
|
||
|
|
const parentId = graph.getNodeAttribute(currentDraggingId, "parentId");
|
||
|
|
if (parentId) {
|
||
|
|
set(autoResizeGroupAtom, parentId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
set(draggingNodeIdAtom, null);
|
||
|
|
set(preDragNodeAttributesAtom, null);
|
||
|
|
});
|
||
|
|
var optimisticDeleteNodeAtom = (0, import_jotai12.atom)(null, (get, set, {
|
||
|
|
nodeId
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasNode(nodeId)) {
|
||
|
|
graph.dropNode(nodeId);
|
||
|
|
set(cleanupNodePositionAtom, nodeId);
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
debug7("Optimistically deleted node %s", nodeId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var optimisticDeleteEdgeAtom = (0, import_jotai12.atom)(null, (get, set, {
|
||
|
|
edgeKey
|
||
|
|
}) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasEdge(edgeKey)) {
|
||
|
|
graph.dropEdge(edgeKey);
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
debug7("Optimistically deleted edge %s", edgeKey);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var addNodeToLocalGraphAtom = (0, import_jotai12.atom)(null, (get, set, newNode) => {
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
if (graph.hasNode(newNode.id)) {
|
||
|
|
debug7("Node %s already exists, skipping", newNode.id);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const uiProps = newNode.ui_properties || {};
|
||
|
|
const attributes = {
|
||
|
|
x: typeof uiProps.x === "number" ? uiProps.x : Math.random() * 800,
|
||
|
|
y: typeof uiProps.y === "number" ? uiProps.y : Math.random() * 600,
|
||
|
|
size: typeof uiProps.size === "number" ? uiProps.size : 15,
|
||
|
|
width: typeof uiProps.width === "number" ? uiProps.width : 500,
|
||
|
|
height: typeof uiProps.height === "number" ? uiProps.height : 500,
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : "#ccc",
|
||
|
|
label: newNode.label || newNode.node_type || newNode.id,
|
||
|
|
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : 0,
|
||
|
|
dbData: newNode
|
||
|
|
};
|
||
|
|
debug7("Adding node %s to local graph at (%d, %d)", newNode.id, attributes.x, attributes.y);
|
||
|
|
graph.addNode(newNode.id, attributes);
|
||
|
|
set(graphAtom, graph.copy());
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
set(nodePositionUpdateCounterAtom, (c) => c + 1);
|
||
|
|
});
|
||
|
|
var loadGraphFromDbAtom = (0, import_jotai12.atom)(null, (get, set, fetchedNodes, fetchedEdges) => {
|
||
|
|
debug7("========== START SYNC ==========");
|
||
|
|
debug7("Fetched nodes: %d, edges: %d", fetchedNodes.length, fetchedEdges.length);
|
||
|
|
const currentGraphId = get(currentGraphIdAtom);
|
||
|
|
if (fetchedNodes.length > 0 && fetchedNodes[0].graph_id !== currentGraphId) {
|
||
|
|
debug7("Skipping sync - data belongs to different graph");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const existingGraph = get(graphAtom);
|
||
|
|
const isDragging = get(draggingNodeIdAtom) !== null;
|
||
|
|
if (isDragging) {
|
||
|
|
debug7("Skipping sync - drag in progress");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const existingNodeIds = new Set(existingGraph.nodes());
|
||
|
|
const fetchedNodeIds = new Set(fetchedNodes.map((n) => n.id));
|
||
|
|
const hasAnyCommonNodes = Array.from(existingNodeIds).some((id) => fetchedNodeIds.has(id));
|
||
|
|
let graph;
|
||
|
|
if (hasAnyCommonNodes && existingNodeIds.size > 0) {
|
||
|
|
debug7("Merging DB data into existing graph");
|
||
|
|
graph = existingGraph.copy();
|
||
|
|
} else {
|
||
|
|
debug7("Creating fresh graph (graph switch detected)");
|
||
|
|
graph = new import_graphology3.default(graphOptions);
|
||
|
|
}
|
||
|
|
const fetchedEdgeIds = new Set(fetchedEdges.map((e) => e.id));
|
||
|
|
if (hasAnyCommonNodes && existingNodeIds.size > 0) {
|
||
|
|
graph.forEachNode((nodeId) => {
|
||
|
|
if (!fetchedNodeIds.has(nodeId)) {
|
||
|
|
debug7("Removing deleted node: %s", nodeId);
|
||
|
|
graph.dropNode(nodeId);
|
||
|
|
nodePositionAtomFamily.remove(nodeId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
fetchedNodes.forEach((node) => {
|
||
|
|
const uiProps = node.ui_properties || {};
|
||
|
|
const newX = typeof uiProps.x === "number" ? uiProps.x : Math.random() * 800;
|
||
|
|
const newY = typeof uiProps.y === "number" ? uiProps.y : Math.random() * 600;
|
||
|
|
if (graph.hasNode(node.id)) {
|
||
|
|
const currentAttrs = graph.getNodeAttributes(node.id);
|
||
|
|
const attributes = {
|
||
|
|
x: newX,
|
||
|
|
y: newY,
|
||
|
|
size: typeof uiProps.size === "number" ? uiProps.size : currentAttrs.size,
|
||
|
|
width: typeof uiProps.width === "number" ? uiProps.width : currentAttrs.width ?? 500,
|
||
|
|
height: typeof uiProps.height === "number" ? uiProps.height : currentAttrs.height ?? 500,
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : currentAttrs.color,
|
||
|
|
label: node.label || node.node_type || node.id,
|
||
|
|
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : currentAttrs.zIndex,
|
||
|
|
dbData: node
|
||
|
|
};
|
||
|
|
graph.replaceNodeAttributes(node.id, attributes);
|
||
|
|
} else {
|
||
|
|
const attributes = {
|
||
|
|
x: newX,
|
||
|
|
y: newY,
|
||
|
|
size: typeof uiProps.size === "number" ? uiProps.size : 15,
|
||
|
|
width: typeof uiProps.width === "number" ? uiProps.width : 500,
|
||
|
|
height: typeof uiProps.height === "number" ? uiProps.height : 500,
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : "#ccc",
|
||
|
|
label: node.label || node.node_type || node.id,
|
||
|
|
zIndex: typeof uiProps.zIndex === "number" ? uiProps.zIndex : 0,
|
||
|
|
dbData: node
|
||
|
|
};
|
||
|
|
graph.addNode(node.id, attributes);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
graph.forEachEdge((edgeId) => {
|
||
|
|
if (!fetchedEdgeIds.has(edgeId)) {
|
||
|
|
debug7("Removing deleted edge: %s", edgeId);
|
||
|
|
graph.dropEdge(edgeId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
fetchedEdges.forEach((edge) => {
|
||
|
|
if (graph.hasNode(edge.source_node_id) && graph.hasNode(edge.target_node_id)) {
|
||
|
|
const uiProps = edge.ui_properties || {};
|
||
|
|
const attributes = {
|
||
|
|
type: typeof uiProps.style === "string" ? uiProps.style : "solid",
|
||
|
|
color: typeof uiProps.color === "string" ? uiProps.color : "#999",
|
||
|
|
label: edge.edge_type ?? void 0,
|
||
|
|
weight: typeof uiProps.weight === "number" ? uiProps.weight : 1,
|
||
|
|
dbData: edge
|
||
|
|
};
|
||
|
|
if (graph.hasEdge(edge.id)) {
|
||
|
|
graph.replaceEdgeAttributes(edge.id, attributes);
|
||
|
|
} else {
|
||
|
|
try {
|
||
|
|
graph.addEdgeWithKey(edge.id, edge.source_node_id, edge.target_node_id, attributes);
|
||
|
|
} catch (e) {
|
||
|
|
debug7("Failed to add edge %s: %o", edge.id, e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
set(graphAtom, graph);
|
||
|
|
set(graphUpdateVersionAtom, (v) => v + 1);
|
||
|
|
debug7("========== SYNC COMPLETE ==========");
|
||
|
|
debug7("Final graph: %d nodes, %d edges", graph.order, graph.size);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/sync-store.ts
|
||
|
|
var import_jotai13 = require("jotai");
|
||
|
|
var debug8 = createDebug("sync");
|
||
|
|
var syncStatusAtom = (0, import_jotai13.atom)("synced");
|
||
|
|
var pendingMutationsCountAtom = (0, import_jotai13.atom)(0);
|
||
|
|
var isOnlineAtom = (0, import_jotai13.atom)(typeof navigator !== "undefined" ? navigator.onLine : true);
|
||
|
|
var lastSyncErrorAtom = (0, import_jotai13.atom)(null);
|
||
|
|
var lastSyncTimeAtom = (0, import_jotai13.atom)(Date.now());
|
||
|
|
var mutationQueueAtom = (0, import_jotai13.atom)([]);
|
||
|
|
var syncStateAtom = (0, import_jotai13.atom)((get) => ({
|
||
|
|
status: get(syncStatusAtom),
|
||
|
|
pendingMutations: get(pendingMutationsCountAtom),
|
||
|
|
lastError: get(lastSyncErrorAtom),
|
||
|
|
lastSyncTime: get(lastSyncTimeAtom),
|
||
|
|
isOnline: get(isOnlineAtom),
|
||
|
|
queuedMutations: get(mutationQueueAtom).length
|
||
|
|
}));
|
||
|
|
var startMutationAtom = (0, import_jotai13.atom)(null, (get, set) => {
|
||
|
|
const currentCount = get(pendingMutationsCountAtom);
|
||
|
|
const newCount = currentCount + 1;
|
||
|
|
set(pendingMutationsCountAtom, newCount);
|
||
|
|
debug8("Mutation started. Pending count: %d -> %d", currentCount, newCount);
|
||
|
|
if (newCount > 0 && get(syncStatusAtom) !== "syncing") {
|
||
|
|
set(syncStatusAtom, "syncing");
|
||
|
|
debug8("Status -> syncing");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var completeMutationAtom = (0, import_jotai13.atom)(null, (get, set, success = true) => {
|
||
|
|
const currentCount = get(pendingMutationsCountAtom);
|
||
|
|
const newCount = Math.max(0, currentCount - 1);
|
||
|
|
set(pendingMutationsCountAtom, newCount);
|
||
|
|
debug8("Mutation completed (success: %s). Pending count: %d -> %d", success, currentCount, newCount);
|
||
|
|
if (success) {
|
||
|
|
set(lastSyncTimeAtom, Date.now());
|
||
|
|
if (newCount === 0) {
|
||
|
|
set(lastSyncErrorAtom, null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (newCount === 0) {
|
||
|
|
const isOnline = get(isOnlineAtom);
|
||
|
|
const hasError = get(lastSyncErrorAtom) !== null;
|
||
|
|
if (hasError) {
|
||
|
|
set(syncStatusAtom, "error");
|
||
|
|
debug8("Status -> error");
|
||
|
|
} else if (!isOnline) {
|
||
|
|
set(syncStatusAtom, "offline");
|
||
|
|
debug8("Status -> offline");
|
||
|
|
} else {
|
||
|
|
set(syncStatusAtom, "synced");
|
||
|
|
debug8("Status -> synced");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var trackMutationErrorAtom = (0, import_jotai13.atom)(null, (_get, set, error) => {
|
||
|
|
set(lastSyncErrorAtom, error);
|
||
|
|
debug8("Mutation failed: %s", error);
|
||
|
|
});
|
||
|
|
var setOnlineStatusAtom = (0, import_jotai13.atom)(null, (get, set, isOnline) => {
|
||
|
|
set(isOnlineAtom, isOnline);
|
||
|
|
const pendingCount = get(pendingMutationsCountAtom);
|
||
|
|
const hasError = get(lastSyncErrorAtom) !== null;
|
||
|
|
const queueLength = get(mutationQueueAtom).length;
|
||
|
|
if (pendingCount === 0) {
|
||
|
|
if (hasError || queueLength > 0) {
|
||
|
|
set(syncStatusAtom, "error");
|
||
|
|
} else {
|
||
|
|
set(syncStatusAtom, isOnline ? "synced" : "offline");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var queueMutationAtom = (0, import_jotai13.atom)(null, (get, set, mutation) => {
|
||
|
|
const queue = get(mutationQueueAtom);
|
||
|
|
const newMutation = {
|
||
|
|
...mutation,
|
||
|
|
id: crypto.randomUUID(),
|
||
|
|
timestamp: Date.now(),
|
||
|
|
retryCount: 0,
|
||
|
|
maxRetries: mutation.maxRetries ?? 3
|
||
|
|
};
|
||
|
|
const newQueue = [...queue, newMutation];
|
||
|
|
set(mutationQueueAtom, newQueue);
|
||
|
|
debug8("Queued mutation: %s. Queue size: %d", mutation.type, newQueue.length);
|
||
|
|
if (get(pendingMutationsCountAtom) === 0) {
|
||
|
|
set(syncStatusAtom, "error");
|
||
|
|
}
|
||
|
|
return newMutation.id;
|
||
|
|
});
|
||
|
|
var dequeueMutationAtom = (0, import_jotai13.atom)(null, (get, set, mutationId) => {
|
||
|
|
const queue = get(mutationQueueAtom);
|
||
|
|
const newQueue = queue.filter((m) => m.id !== mutationId);
|
||
|
|
set(mutationQueueAtom, newQueue);
|
||
|
|
debug8("Dequeued mutation: %s. Queue size: %d", mutationId, newQueue.length);
|
||
|
|
if (newQueue.length === 0 && get(pendingMutationsCountAtom) === 0 && get(lastSyncErrorAtom) === null) {
|
||
|
|
set(syncStatusAtom, get(isOnlineAtom) ? "synced" : "offline");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var incrementRetryCountAtom = (0, import_jotai13.atom)(null, (get, set, mutationId) => {
|
||
|
|
const queue = get(mutationQueueAtom);
|
||
|
|
const newQueue = queue.map((m) => m.id === mutationId ? {
|
||
|
|
...m,
|
||
|
|
retryCount: m.retryCount + 1
|
||
|
|
} : m);
|
||
|
|
set(mutationQueueAtom, newQueue);
|
||
|
|
});
|
||
|
|
var getNextQueuedMutationAtom = (0, import_jotai13.atom)((get) => {
|
||
|
|
const queue = get(mutationQueueAtom);
|
||
|
|
return queue.find((m) => m.retryCount < m.maxRetries) ?? null;
|
||
|
|
});
|
||
|
|
var clearMutationQueueAtom = (0, import_jotai13.atom)(null, (get, set) => {
|
||
|
|
set(mutationQueueAtom, []);
|
||
|
|
debug8("Cleared mutation queue");
|
||
|
|
if (get(pendingMutationsCountAtom) === 0 && get(lastSyncErrorAtom) === null) {
|
||
|
|
set(syncStatusAtom, get(isOnlineAtom) ? "synced" : "offline");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/interaction-store.ts
|
||
|
|
var import_jotai14 = require("jotai");
|
||
|
|
var inputModeAtom = (0, import_jotai14.atom)({
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
var keyboardInteractionModeAtom = (0, import_jotai14.atom)("navigate");
|
||
|
|
var interactionFeedbackAtom = (0, import_jotai14.atom)(null);
|
||
|
|
var pendingInputResolverAtom = (0, import_jotai14.atom)(null);
|
||
|
|
var resetInputModeAtom = (0, import_jotai14.atom)(null, (_get, set) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "normal"
|
||
|
|
});
|
||
|
|
set(interactionFeedbackAtom, null);
|
||
|
|
set(pendingInputResolverAtom, null);
|
||
|
|
});
|
||
|
|
var resetKeyboardInteractionModeAtom = (0, import_jotai14.atom)(null, (_get, set) => {
|
||
|
|
set(keyboardInteractionModeAtom, "navigate");
|
||
|
|
});
|
||
|
|
var setKeyboardInteractionModeAtom = (0, import_jotai14.atom)(null, (_get, set, mode) => {
|
||
|
|
set(keyboardInteractionModeAtom, mode);
|
||
|
|
});
|
||
|
|
var startPickNodeAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickNode",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var startPickNodesAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickNodes",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var startPickPointAtom = (0, import_jotai14.atom)(null, (_get, set, options) => {
|
||
|
|
set(inputModeAtom, {
|
||
|
|
type: "pickPoint",
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var provideInputAtom = (0, import_jotai14.atom)(null, (get, set, value) => {
|
||
|
|
set(pendingInputResolverAtom, value);
|
||
|
|
});
|
||
|
|
var updateInteractionFeedbackAtom = (0, import_jotai14.atom)(null, (get, set, feedback) => {
|
||
|
|
const current = get(interactionFeedbackAtom);
|
||
|
|
set(interactionFeedbackAtom, {
|
||
|
|
...current,
|
||
|
|
...feedback
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var isPickingModeAtom = (0, import_jotai14.atom)((get) => {
|
||
|
|
const mode = get(inputModeAtom);
|
||
|
|
return mode.type !== "normal";
|
||
|
|
});
|
||
|
|
var isPickNodeModeAtom = (0, import_jotai14.atom)((get) => {
|
||
|
|
const mode = get(inputModeAtom);
|
||
|
|
return mode.type === "pickNode" || mode.type === "pickNodes";
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/locked-node-store.ts
|
||
|
|
var import_jotai15 = require("jotai");
|
||
|
|
var lockedNodeIdAtom = (0, import_jotai15.atom)(null);
|
||
|
|
var lockedNodeDataAtom = (0, import_jotai15.atom)((get) => {
|
||
|
|
const id = get(lockedNodeIdAtom);
|
||
|
|
if (!id) return null;
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
return nodes.find((n) => n.id === id) || null;
|
||
|
|
});
|
||
|
|
var lockedNodePageIndexAtom = (0, import_jotai15.atom)(0);
|
||
|
|
var lockedNodePageCountAtom = (0, import_jotai15.atom)(1);
|
||
|
|
var lockNodeAtom = (0, import_jotai15.atom)(null, (_get, set, payload) => {
|
||
|
|
set(lockedNodeIdAtom, payload.nodeId);
|
||
|
|
set(lockedNodePageIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var unlockNodeAtom = (0, import_jotai15.atom)(null, (_get, set) => {
|
||
|
|
set(lockedNodeIdAtom, null);
|
||
|
|
});
|
||
|
|
var nextLockedPageAtom = (0, import_jotai15.atom)(null, (get, set) => {
|
||
|
|
const current = get(lockedNodePageIndexAtom);
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
set(lockedNodePageIndexAtom, (current + 1) % pageCount);
|
||
|
|
});
|
||
|
|
var prevLockedPageAtom = (0, import_jotai15.atom)(null, (get, set) => {
|
||
|
|
const current = get(lockedNodePageIndexAtom);
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
set(lockedNodePageIndexAtom, (current - 1 + pageCount) % pageCount);
|
||
|
|
});
|
||
|
|
var goToLockedPageAtom = (0, import_jotai15.atom)(null, (get, set, index) => {
|
||
|
|
const pageCount = get(lockedNodePageCountAtom);
|
||
|
|
if (index >= 0 && index < pageCount) {
|
||
|
|
set(lockedNodePageIndexAtom, index);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var hasLockedNodeAtom = (0, import_jotai15.atom)((get) => get(lockedNodeIdAtom) !== null);
|
||
|
|
|
||
|
|
// src/core/node-type-registry.tsx
|
||
|
|
var import_compiler_runtime = require("react/compiler-runtime");
|
||
|
|
var import_react = __toESM(require("react"));
|
||
|
|
var import_jsx_runtime = require("react/jsx-runtime");
|
||
|
|
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 $ = (0, import_compiler_runtime.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__ */ (0, import_jsx_runtime.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__ */ (0, import_jsx_runtime.jsx)("div", {
|
||
|
|
style: t4,
|
||
|
|
children: t5
|
||
|
|
});
|
||
|
|
$[6] = t5;
|
||
|
|
$[7] = t6;
|
||
|
|
} else {
|
||
|
|
t6 = $[7];
|
||
|
|
}
|
||
|
|
let t7;
|
||
|
|
if ($[8] !== t3 || $[9] !== t6) {
|
||
|
|
t7 = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
||
|
|
style: t1,
|
||
|
|
children: [t3, t6]
|
||
|
|
});
|
||
|
|
$[8] = t3;
|
||
|
|
$[9] = t6;
|
||
|
|
$[10] = t7;
|
||
|
|
} else {
|
||
|
|
t7 = $[10];
|
||
|
|
}
|
||
|
|
return t7;
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/core/toast-store.ts
|
||
|
|
var import_jotai16 = require("jotai");
|
||
|
|
var canvasToastAtom = (0, import_jotai16.atom)(null);
|
||
|
|
var showToastAtom = (0, import_jotai16.atom)(null, (_get, set, message) => {
|
||
|
|
const id = `toast-${Date.now()}`;
|
||
|
|
set(canvasToastAtom, {
|
||
|
|
id,
|
||
|
|
message,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
setTimeout(() => {
|
||
|
|
set(canvasToastAtom, (current) => current?.id === id ? null : current);
|
||
|
|
}, 2e3);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/snap-store.ts
|
||
|
|
var import_jotai17 = require("jotai");
|
||
|
|
var snapEnabledAtom = (0, import_jotai17.atom)(false);
|
||
|
|
var snapGridSizeAtom = (0, import_jotai17.atom)(20);
|
||
|
|
var snapTemporaryDisableAtom = (0, import_jotai17.atom)(false);
|
||
|
|
var isSnappingActiveAtom = (0, import_jotai17.atom)((get) => {
|
||
|
|
return get(snapEnabledAtom) && !get(snapTemporaryDisableAtom);
|
||
|
|
});
|
||
|
|
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 = (0, import_jotai17.atom)(null, (get, set) => {
|
||
|
|
set(snapEnabledAtom, !get(snapEnabledAtom));
|
||
|
|
});
|
||
|
|
var setGridSizeAtom = (0, import_jotai17.atom)(null, (_get, set, size) => {
|
||
|
|
set(snapGridSizeAtom, Math.max(5, Math.min(200, size)));
|
||
|
|
});
|
||
|
|
var snapAlignmentEnabledAtom = (0, import_jotai17.atom)(true);
|
||
|
|
var toggleAlignmentGuidesAtom = (0, import_jotai17.atom)(null, (get, set) => {
|
||
|
|
set(snapAlignmentEnabledAtom, !get(snapAlignmentEnabledAtom));
|
||
|
|
});
|
||
|
|
var alignmentGuidesAtom = (0, import_jotai17.atom)({
|
||
|
|
verticalGuides: [],
|
||
|
|
horizontalGuides: []
|
||
|
|
});
|
||
|
|
var clearAlignmentGuidesAtom = (0, import_jotai17.atom)(null, (_get, set) => {
|
||
|
|
set(alignmentGuidesAtom, {
|
||
|
|
verticalGuides: [],
|
||
|
|
horizontalGuides: []
|
||
|
|
});
|
||
|
|
});
|
||
|
|
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
|
||
|
|
var import_jotai18 = require("jotai");
|
||
|
|
var import_utils = require("jotai/utils");
|
||
|
|
|
||
|
|
// src/core/settings-presets.ts
|
||
|
|
var BUILT_IN_PRESETS = [{
|
||
|
|
id: "default",
|
||
|
|
name: "Default",
|
||
|
|
description: "Standard canvas interactions",
|
||
|
|
isBuiltIn: true,
|
||
|
|
mappings: DEFAULT_MAPPINGS
|
||
|
|
}, {
|
||
|
|
id: "minimal",
|
||
|
|
name: "Minimal",
|
||
|
|
description: "Only essential selection and context menu actions",
|
||
|
|
isBuiltIn: true,
|
||
|
|
mappings: {
|
||
|
|
[CanvasEventType.NodeClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.NodeDoubleClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.NodeTripleClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.NodeRightClick]: BuiltInActionId.OpenContextMenu,
|
||
|
|
[CanvasEventType.NodeLongPress]: BuiltInActionId.OpenContextMenu,
|
||
|
|
[CanvasEventType.EdgeClick]: BuiltInActionId.SelectEdge,
|
||
|
|
[CanvasEventType.EdgeDoubleClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.EdgeRightClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.BackgroundClick]: BuiltInActionId.ClearSelection,
|
||
|
|
[CanvasEventType.BackgroundDoubleClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.BackgroundRightClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.BackgroundLongPress]: BuiltInActionId.None
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
id: "power-user",
|
||
|
|
name: "Power User",
|
||
|
|
description: "Quick actions for experienced users",
|
||
|
|
isBuiltIn: true,
|
||
|
|
mappings: {
|
||
|
|
[CanvasEventType.NodeClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.NodeDoubleClick]: BuiltInActionId.ToggleLock,
|
||
|
|
[CanvasEventType.NodeTripleClick]: BuiltInActionId.DeleteSelected,
|
||
|
|
[CanvasEventType.NodeRightClick]: BuiltInActionId.OpenContextMenu,
|
||
|
|
[CanvasEventType.NodeLongPress]: BuiltInActionId.AddToSelection,
|
||
|
|
[CanvasEventType.EdgeClick]: BuiltInActionId.SelectEdge,
|
||
|
|
[CanvasEventType.EdgeDoubleClick]: BuiltInActionId.None,
|
||
|
|
[CanvasEventType.EdgeRightClick]: BuiltInActionId.OpenContextMenu,
|
||
|
|
[CanvasEventType.BackgroundClick]: BuiltInActionId.ClearSelection,
|
||
|
|
[CanvasEventType.BackgroundDoubleClick]: BuiltInActionId.CreateNode,
|
||
|
|
[CanvasEventType.BackgroundRightClick]: BuiltInActionId.OpenContextMenu,
|
||
|
|
[CanvasEventType.BackgroundLongPress]: BuiltInActionId.ApplyForceLayout
|
||
|
|
}
|
||
|
|
}];
|
||
|
|
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 = (0, import_utils.atomWithStorage)("@blinksgg/canvas/settings", DEFAULT_STATE);
|
||
|
|
var eventMappingsAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).mappings);
|
||
|
|
var activePresetIdAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).activePresetId);
|
||
|
|
var allPresetsAtom = (0, import_jotai18.atom)((get) => {
|
||
|
|
const state = get(canvasSettingsAtom);
|
||
|
|
return [...BUILT_IN_PRESETS, ...state.customPresets];
|
||
|
|
});
|
||
|
|
var activePresetAtom = (0, import_jotai18.atom)((get) => {
|
||
|
|
const presetId = get(activePresetIdAtom);
|
||
|
|
if (!presetId) return null;
|
||
|
|
const allPresets = get(allPresetsAtom);
|
||
|
|
return allPresets.find((p) => p.id === presetId) || null;
|
||
|
|
});
|
||
|
|
var isPanelOpenAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).isPanelOpen);
|
||
|
|
var virtualizationEnabledAtom = (0, import_jotai18.atom)((get) => get(canvasSettingsAtom).virtualizationEnabled ?? true);
|
||
|
|
var hasUnsavedChangesAtom = (0, import_jotai18.atom)((get) => {
|
||
|
|
const state = get(canvasSettingsAtom);
|
||
|
|
const activePreset = get(activePresetAtom);
|
||
|
|
if (!activePreset) return true;
|
||
|
|
const events = Object.values(CanvasEventType);
|
||
|
|
return events.some((event) => state.mappings[event] !== activePreset.mappings[event]);
|
||
|
|
});
|
||
|
|
var setEventMappingAtom = (0, import_jotai18.atom)(null, (get, set, {
|
||
|
|
event,
|
||
|
|
actionId
|
||
|
|
}) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
mappings: {
|
||
|
|
...current.mappings,
|
||
|
|
[event]: actionId
|
||
|
|
},
|
||
|
|
// Clear active preset since mappings have changed
|
||
|
|
activePresetId: null
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var applyPresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
|
||
|
|
const allPresets = get(allPresetsAtom);
|
||
|
|
const preset = allPresets.find((p) => p.id === presetId);
|
||
|
|
if (!preset) {
|
||
|
|
debug10.warn("Preset not found: %s", presetId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
mappings: {
|
||
|
|
...preset.mappings
|
||
|
|
},
|
||
|
|
activePresetId: presetId
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var saveAsPresetAtom = (0, import_jotai18.atom)(null, (get, set, {
|
||
|
|
name,
|
||
|
|
description
|
||
|
|
}) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
const id = `custom-${Date.now()}`;
|
||
|
|
const newPreset = {
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
description,
|
||
|
|
mappings: {
|
||
|
|
...current.mappings
|
||
|
|
},
|
||
|
|
isBuiltIn: false
|
||
|
|
};
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customPresets: [...current.customPresets, newPreset],
|
||
|
|
activePresetId: id
|
||
|
|
});
|
||
|
|
return id;
|
||
|
|
});
|
||
|
|
var updatePresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
const presetIndex = current.customPresets.findIndex((p) => p.id === presetId);
|
||
|
|
if (presetIndex === -1) {
|
||
|
|
debug10.warn("Cannot update preset: %s (not found or built-in)", presetId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const updatedPresets = [...current.customPresets];
|
||
|
|
updatedPresets[presetIndex] = {
|
||
|
|
...updatedPresets[presetIndex],
|
||
|
|
mappings: {
|
||
|
|
...current.mappings
|
||
|
|
}
|
||
|
|
};
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customPresets: updatedPresets,
|
||
|
|
activePresetId: presetId
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var deletePresetAtom = (0, import_jotai18.atom)(null, (get, set, presetId) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
const newCustomPresets = current.customPresets.filter((p) => p.id !== presetId);
|
||
|
|
if (newCustomPresets.length === current.customPresets.length) {
|
||
|
|
debug10.warn("Cannot delete preset: %s (not found or built-in)", presetId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const newActiveId = current.activePresetId === presetId ? "default" : current.activePresetId;
|
||
|
|
const newMappings = newActiveId === "default" ? DEFAULT_MAPPINGS : current.mappings;
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customPresets: newCustomPresets,
|
||
|
|
activePresetId: newActiveId,
|
||
|
|
mappings: newMappings
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var resetSettingsAtom = (0, import_jotai18.atom)(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
mappings: DEFAULT_MAPPINGS,
|
||
|
|
activePresetId: "default"
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var togglePanelAtom = (0, import_jotai18.atom)(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
isPanelOpen: !current.isPanelOpen
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var setPanelOpenAtom = (0, import_jotai18.atom)(null, (get, set, isOpen) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
isPanelOpen: isOpen
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var setVirtualizationEnabledAtom = (0, import_jotai18.atom)(null, (get, set, enabled) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
virtualizationEnabled: enabled
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var toggleVirtualizationAtom = (0, import_jotai18.atom)(null, (get, set) => {
|
||
|
|
const current = get(canvasSettingsAtom);
|
||
|
|
set(canvasSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
virtualizationEnabled: !(current.virtualizationEnabled ?? true)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/canvas-serializer.ts
|
||
|
|
var import_graphology4 = __toESM(require("graphology"));
|
||
|
|
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 import_graphology4.default(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
|
||
|
|
var import_jotai19 = require("jotai");
|
||
|
|
var debug11 = createDebug("clipboard");
|
||
|
|
var PASTE_OFFSET = {
|
||
|
|
x: 50,
|
||
|
|
y: 50
|
||
|
|
};
|
||
|
|
var clipboardAtom = (0, import_jotai19.atom)(null);
|
||
|
|
var hasClipboardContentAtom = (0, import_jotai19.atom)((get) => get(clipboardAtom) !== null);
|
||
|
|
var clipboardNodeCountAtom = (0, import_jotai19.atom)((get) => {
|
||
|
|
const clipboard = get(clipboardAtom);
|
||
|
|
return clipboard?.nodes.length ?? 0;
|
||
|
|
});
|
||
|
|
function calculateBounds2(nodes) {
|
||
|
|
if (nodes.length === 0) {
|
||
|
|
return {
|
||
|
|
minX: 0,
|
||
|
|
minY: 0,
|
||
|
|
maxX: 0,
|
||
|
|
maxY: 0
|
||
|
|
};
|
||
|
|
}
|
||
|
|
let minX = Infinity;
|
||
|
|
let minY = Infinity;
|
||
|
|
let maxX = -Infinity;
|
||
|
|
let maxY = -Infinity;
|
||
|
|
for (const node of nodes) {
|
||
|
|
minX = Math.min(minX, node.attrs.x);
|
||
|
|
minY = Math.min(minY, node.attrs.y);
|
||
|
|
maxX = Math.max(maxX, node.attrs.x + node.attrs.width);
|
||
|
|
maxY = Math.max(maxY, node.attrs.y + node.attrs.height);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
minX,
|
||
|
|
minY,
|
||
|
|
maxX,
|
||
|
|
maxY
|
||
|
|
};
|
||
|
|
}
|
||
|
|
function generatePasteId(index) {
|
||
|
|
return `paste-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
||
|
|
}
|
||
|
|
var copyToClipboardAtom = (0, import_jotai19.atom)(null, (get, set, nodeIds) => {
|
||
|
|
const selectedIds = nodeIds ?? Array.from(get(selectedNodeIdsAtom));
|
||
|
|
if (selectedIds.length === 0) {
|
||
|
|
debug11("Nothing to copy - no nodes selected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const selectedSet = new Set(selectedIds);
|
||
|
|
const nodes = [];
|
||
|
|
const edges = [];
|
||
|
|
for (const nodeId of selectedIds) {
|
||
|
|
if (!graph.hasNode(nodeId)) {
|
||
|
|
debug11("Node %s not found in graph, skipping", nodeId);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const attrs = graph.getNodeAttributes(nodeId);
|
||
|
|
nodes.push({
|
||
|
|
attrs: {
|
||
|
|
...attrs
|
||
|
|
},
|
||
|
|
dbData: {
|
||
|
|
...attrs.dbData
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
graph.forEachEdge((edgeKey, attrs, source, target) => {
|
||
|
|
if (selectedSet.has(source) && selectedSet.has(target)) {
|
||
|
|
edges.push({
|
||
|
|
source,
|
||
|
|
target,
|
||
|
|
attrs: {
|
||
|
|
...attrs
|
||
|
|
},
|
||
|
|
dbData: {
|
||
|
|
...attrs.dbData
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
const bounds = calculateBounds2(nodes);
|
||
|
|
const clipboardData = {
|
||
|
|
nodes,
|
||
|
|
edges,
|
||
|
|
bounds,
|
||
|
|
timestamp: Date.now()
|
||
|
|
};
|
||
|
|
set(clipboardAtom, clipboardData);
|
||
|
|
debug11("Copied %d nodes and %d edges to clipboard", nodes.length, edges.length);
|
||
|
|
});
|
||
|
|
var cutToClipboardAtom = (0, import_jotai19.atom)(null, (get, set, nodeIds) => {
|
||
|
|
const selectedIds = nodeIds ?? Array.from(get(selectedNodeIdsAtom));
|
||
|
|
if (selectedIds.length === 0) return;
|
||
|
|
set(copyToClipboardAtom, selectedIds);
|
||
|
|
set(pushHistoryAtom, "Cut nodes");
|
||
|
|
for (const nodeId of selectedIds) {
|
||
|
|
set(optimisticDeleteNodeAtom, {
|
||
|
|
nodeId
|
||
|
|
});
|
||
|
|
}
|
||
|
|
set(clearSelectionAtom);
|
||
|
|
debug11("Cut %d nodes \u2014 copied to clipboard and deleted from graph", selectedIds.length);
|
||
|
|
});
|
||
|
|
var pasteFromClipboardAtom = (0, import_jotai19.atom)(null, (get, set, offset) => {
|
||
|
|
const clipboard = get(clipboardAtom);
|
||
|
|
if (!clipboard || clipboard.nodes.length === 0) {
|
||
|
|
debug11("Nothing to paste - clipboard empty");
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const pasteOffset = offset ?? PASTE_OFFSET;
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
set(pushHistoryAtom, "Paste nodes");
|
||
|
|
const idMap = /* @__PURE__ */ new Map();
|
||
|
|
const newNodeIds = [];
|
||
|
|
for (let i = 0; i < clipboard.nodes.length; i++) {
|
||
|
|
const nodeData = clipboard.nodes[i];
|
||
|
|
const newId = generatePasteId(i);
|
||
|
|
idMap.set(nodeData.dbData.id, newId);
|
||
|
|
newNodeIds.push(newId);
|
||
|
|
const newDbNode = {
|
||
|
|
...nodeData.dbData,
|
||
|
|
id: newId,
|
||
|
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||
|
|
ui_properties: {
|
||
|
|
...nodeData.dbData.ui_properties || {},
|
||
|
|
x: nodeData.attrs.x + pasteOffset.x,
|
||
|
|
y: nodeData.attrs.y + pasteOffset.y
|
||
|
|
}
|
||
|
|
};
|
||
|
|
debug11("Pasting node %s -> %s at (%d, %d)", nodeData.dbData.id, newId, nodeData.attrs.x + pasteOffset.x, nodeData.attrs.y + pasteOffset.y);
|
||
|
|
set(addNodeToLocalGraphAtom, newDbNode);
|
||
|
|
}
|
||
|
|
for (const edgeData of clipboard.edges) {
|
||
|
|
const newSourceId = idMap.get(edgeData.source);
|
||
|
|
const newTargetId = idMap.get(edgeData.target);
|
||
|
|
if (!newSourceId || !newTargetId) {
|
||
|
|
debug11("Edge %s: source or target not found in id map, skipping", edgeData.dbData.id);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const newEdgeId = generatePasteId(clipboard.edges.indexOf(edgeData) + clipboard.nodes.length);
|
||
|
|
const newDbEdge = {
|
||
|
|
...edgeData.dbData,
|
||
|
|
id: newEdgeId,
|
||
|
|
source_node_id: newSourceId,
|
||
|
|
target_node_id: newTargetId,
|
||
|
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||
|
|
};
|
||
|
|
debug11("Pasting edge %s -> %s (from %s to %s)", edgeData.dbData.id, newEdgeId, newSourceId, newTargetId);
|
||
|
|
set(addEdgeToLocalGraphAtom, newDbEdge);
|
||
|
|
}
|
||
|
|
set(clearSelectionAtom);
|
||
|
|
set(addNodesToSelectionAtom, newNodeIds);
|
||
|
|
debug11("Pasted %d nodes and %d edges", newNodeIds.length, clipboard.edges.length);
|
||
|
|
return newNodeIds;
|
||
|
|
});
|
||
|
|
var duplicateSelectionAtom = (0, import_jotai19.atom)(null, (get, set) => {
|
||
|
|
set(copyToClipboardAtom);
|
||
|
|
return set(pasteFromClipboardAtom);
|
||
|
|
});
|
||
|
|
var clearClipboardAtom = (0, import_jotai19.atom)(null, (_get, set) => {
|
||
|
|
set(clipboardAtom, null);
|
||
|
|
debug11("Clipboard cleared");
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/virtualization-store.ts
|
||
|
|
var import_jotai20 = require("jotai");
|
||
|
|
|
||
|
|
// src/core/spatial-index.ts
|
||
|
|
var SpatialGrid = class {
|
||
|
|
constructor(cellSize = 500) {
|
||
|
|
/** cell key → set of node IDs in that cell */
|
||
|
|
__publicField(this, "cells", /* @__PURE__ */ new Map());
|
||
|
|
/** node ID → entry data (for update/remove) */
|
||
|
|
__publicField(this, "entries", /* @__PURE__ */ new Map());
|
||
|
|
this.cellSize = cellSize;
|
||
|
|
}
|
||
|
|
/** Number of tracked entries */
|
||
|
|
get size() {
|
||
|
|
return this.entries.size;
|
||
|
|
}
|
||
|
|
cellKey(cx, cy) {
|
||
|
|
return `${cx},${cy}`;
|
||
|
|
}
|
||
|
|
getCellRange(x, y, w, h) {
|
||
|
|
const cs = this.cellSize;
|
||
|
|
return {
|
||
|
|
minCX: Math.floor(x / cs),
|
||
|
|
minCY: Math.floor(y / cs),
|
||
|
|
maxCX: Math.floor((x + w) / cs),
|
||
|
|
maxCY: Math.floor((y + h) / cs)
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Insert a node into the index.
|
||
|
|
* If the node already exists, it is updated.
|
||
|
|
*/
|
||
|
|
insert(id, x, y, width, height) {
|
||
|
|
if (this.entries.has(id)) {
|
||
|
|
this.update(id, x, y, width, height);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const entry = {
|
||
|
|
id,
|
||
|
|
x,
|
||
|
|
y,
|
||
|
|
width,
|
||
|
|
height
|
||
|
|
};
|
||
|
|
this.entries.set(id, entry);
|
||
|
|
const {
|
||
|
|
minCX,
|
||
|
|
minCY,
|
||
|
|
maxCX,
|
||
|
|
maxCY
|
||
|
|
} = this.getCellRange(x, y, width, height);
|
||
|
|
for (let cx = minCX; cx <= maxCX; cx++) {
|
||
|
|
for (let cy = minCY; cy <= maxCY; cy++) {
|
||
|
|
const key = this.cellKey(cx, cy);
|
||
|
|
let cell = this.cells.get(key);
|
||
|
|
if (!cell) {
|
||
|
|
cell = /* @__PURE__ */ new Set();
|
||
|
|
this.cells.set(key, cell);
|
||
|
|
}
|
||
|
|
cell.add(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Update a node's position/dimensions.
|
||
|
|
*/
|
||
|
|
update(id, x, y, width, height) {
|
||
|
|
const prev = this.entries.get(id);
|
||
|
|
if (!prev) {
|
||
|
|
this.insert(id, x, y, width, height);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const prevRange = this.getCellRange(prev.x, prev.y, prev.width, prev.height);
|
||
|
|
const newRange = this.getCellRange(x, y, width, height);
|
||
|
|
prev.x = x;
|
||
|
|
prev.y = y;
|
||
|
|
prev.width = width;
|
||
|
|
prev.height = height;
|
||
|
|
if (prevRange.minCX === newRange.minCX && prevRange.minCY === newRange.minCY && prevRange.maxCX === newRange.maxCX && prevRange.maxCY === newRange.maxCY) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
for (let cx = prevRange.minCX; cx <= prevRange.maxCX; cx++) {
|
||
|
|
for (let cy = prevRange.minCY; cy <= prevRange.maxCY; cy++) {
|
||
|
|
const key = this.cellKey(cx, cy);
|
||
|
|
const cell = this.cells.get(key);
|
||
|
|
if (cell) {
|
||
|
|
cell.delete(id);
|
||
|
|
if (cell.size === 0) this.cells.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (let cx = newRange.minCX; cx <= newRange.maxCX; cx++) {
|
||
|
|
for (let cy = newRange.minCY; cy <= newRange.maxCY; cy++) {
|
||
|
|
const key = this.cellKey(cx, cy);
|
||
|
|
let cell = this.cells.get(key);
|
||
|
|
if (!cell) {
|
||
|
|
cell = /* @__PURE__ */ new Set();
|
||
|
|
this.cells.set(key, cell);
|
||
|
|
}
|
||
|
|
cell.add(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Remove a node from the index.
|
||
|
|
*/
|
||
|
|
remove(id) {
|
||
|
|
const entry = this.entries.get(id);
|
||
|
|
if (!entry) return;
|
||
|
|
const {
|
||
|
|
minCX,
|
||
|
|
minCY,
|
||
|
|
maxCX,
|
||
|
|
maxCY
|
||
|
|
} = this.getCellRange(entry.x, entry.y, entry.width, entry.height);
|
||
|
|
for (let cx = minCX; cx <= maxCX; cx++) {
|
||
|
|
for (let cy = minCY; cy <= maxCY; cy++) {
|
||
|
|
const key = this.cellKey(cx, cy);
|
||
|
|
const cell = this.cells.get(key);
|
||
|
|
if (cell) {
|
||
|
|
cell.delete(id);
|
||
|
|
if (cell.size === 0) this.cells.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.entries.delete(id);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Query all node IDs whose bounding box overlaps the given bounds.
|
||
|
|
* Returns a Set for O(1) membership checks.
|
||
|
|
*/
|
||
|
|
query(bounds) {
|
||
|
|
const result = /* @__PURE__ */ new Set();
|
||
|
|
const {
|
||
|
|
minCX,
|
||
|
|
minCY,
|
||
|
|
maxCX,
|
||
|
|
maxCY
|
||
|
|
} = this.getCellRange(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
|
||
|
|
for (let cx = minCX; cx <= maxCX; cx++) {
|
||
|
|
for (let cy = minCY; cy <= maxCY; cy++) {
|
||
|
|
const cell = this.cells.get(this.cellKey(cx, cy));
|
||
|
|
if (!cell) continue;
|
||
|
|
for (const id of cell) {
|
||
|
|
if (result.has(id)) continue;
|
||
|
|
const entry = this.entries.get(id);
|
||
|
|
const entryRight = entry.x + entry.width;
|
||
|
|
const entryBottom = entry.y + entry.height;
|
||
|
|
if (entry.x <= bounds.maxX && entryRight >= bounds.minX && entry.y <= bounds.maxY && entryBottom >= bounds.minY) {
|
||
|
|
result.add(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clear all entries.
|
||
|
|
*/
|
||
|
|
clear() {
|
||
|
|
this.cells.clear();
|
||
|
|
this.entries.clear();
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if a node is tracked.
|
||
|
|
*/
|
||
|
|
has(id) {
|
||
|
|
return this.entries.has(id);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/core/virtualization-store.ts
|
||
|
|
var VIRTUALIZATION_BUFFER = 200;
|
||
|
|
var spatialIndexAtom = (0, import_jotai20.atom)((get) => {
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
get(nodePositionUpdateCounterAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const grid = new SpatialGrid(500);
|
||
|
|
graph.forEachNode((nodeId, attrs) => {
|
||
|
|
const a = attrs;
|
||
|
|
grid.insert(nodeId, a.x, a.y, a.width || 200, a.height || 100);
|
||
|
|
});
|
||
|
|
return grid;
|
||
|
|
});
|
||
|
|
var visibleBoundsAtom = (0, import_jotai20.atom)((get) => {
|
||
|
|
const viewport = get(viewportRectAtom);
|
||
|
|
const pan = get(panAtom);
|
||
|
|
const zoom = get(zoomAtom);
|
||
|
|
if (!viewport || zoom === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const buffer = VIRTUALIZATION_BUFFER;
|
||
|
|
return {
|
||
|
|
minX: (-buffer - pan.x) / zoom,
|
||
|
|
minY: (-buffer - pan.y) / zoom,
|
||
|
|
maxX: (viewport.width + buffer - pan.x) / zoom,
|
||
|
|
maxY: (viewport.height + buffer - pan.y) / zoom
|
||
|
|
};
|
||
|
|
});
|
||
|
|
var visibleNodeKeysAtom = (0, import_jotai20.atom)((get) => {
|
||
|
|
const end = canvasMark("virtualization-cull");
|
||
|
|
const enabled = get(virtualizationEnabledAtom);
|
||
|
|
const allKeys = get(nodeKeysAtom);
|
||
|
|
if (!enabled) {
|
||
|
|
end();
|
||
|
|
return allKeys;
|
||
|
|
}
|
||
|
|
const bounds = get(visibleBoundsAtom);
|
||
|
|
if (!bounds) {
|
||
|
|
end();
|
||
|
|
return allKeys;
|
||
|
|
}
|
||
|
|
const grid = get(spatialIndexAtom);
|
||
|
|
const visibleSet = grid.query(bounds);
|
||
|
|
const result = allKeys.filter((k) => visibleSet.has(k));
|
||
|
|
end();
|
||
|
|
return result;
|
||
|
|
});
|
||
|
|
var visibleEdgeKeysAtom = (0, import_jotai20.atom)((get) => {
|
||
|
|
const enabled = get(virtualizationEnabledAtom);
|
||
|
|
const allEdgeKeys = get(edgeKeysAtom);
|
||
|
|
const edgeCreation = get(edgeCreationAtom);
|
||
|
|
const remap = get(collapsedEdgeRemapAtom);
|
||
|
|
const tempEdgeKey = edgeCreation.isCreating ? "temp-creating-edge" : null;
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const filteredEdges = allEdgeKeys.filter((edgeKey) => {
|
||
|
|
const source = graph.source(edgeKey);
|
||
|
|
const target = graph.target(edgeKey);
|
||
|
|
const effectiveSource = remap.get(source) ?? source;
|
||
|
|
const effectiveTarget = remap.get(target) ?? target;
|
||
|
|
if (effectiveSource === effectiveTarget) return false;
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
if (!enabled) {
|
||
|
|
return tempEdgeKey ? [...filteredEdges, tempEdgeKey] : filteredEdges;
|
||
|
|
}
|
||
|
|
const visibleNodeKeys = get(visibleNodeKeysAtom);
|
||
|
|
const visibleNodeSet = new Set(visibleNodeKeys);
|
||
|
|
const visibleEdges = filteredEdges.filter((edgeKey) => {
|
||
|
|
const source = graph.source(edgeKey);
|
||
|
|
const target = graph.target(edgeKey);
|
||
|
|
const effectiveSource = remap.get(source) ?? source;
|
||
|
|
const effectiveTarget = remap.get(target) ?? target;
|
||
|
|
return visibleNodeSet.has(effectiveSource) && visibleNodeSet.has(effectiveTarget);
|
||
|
|
});
|
||
|
|
return tempEdgeKey ? [...visibleEdges, tempEdgeKey] : visibleEdges;
|
||
|
|
});
|
||
|
|
var virtualizationMetricsAtom = (0, import_jotai20.atom)((get) => {
|
||
|
|
const enabled = get(virtualizationEnabledAtom);
|
||
|
|
const totalNodes = get(nodeKeysAtom).length;
|
||
|
|
const totalEdges = get(edgeKeysAtom).length;
|
||
|
|
const visibleNodes = get(visibleNodeKeysAtom).length;
|
||
|
|
const visibleEdges = get(visibleEdgeKeysAtom).length;
|
||
|
|
const bounds = get(visibleBoundsAtom);
|
||
|
|
return {
|
||
|
|
enabled,
|
||
|
|
totalNodes,
|
||
|
|
totalEdges,
|
||
|
|
visibleNodes,
|
||
|
|
visibleEdges,
|
||
|
|
culledNodes: totalNodes - visibleNodes,
|
||
|
|
culledEdges: totalEdges - visibleEdges,
|
||
|
|
bounds
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/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
|
||
|
|
var import_jotai21 = require("jotai");
|
||
|
|
var activePointersAtom = (0, import_jotai21.atom)(/* @__PURE__ */ new Map());
|
||
|
|
var primaryInputSourceAtom = (0, import_jotai21.atom)("mouse");
|
||
|
|
var inputCapabilitiesAtom = (0, import_jotai21.atom)(detectInputCapabilities());
|
||
|
|
var isStylusActiveAtom = (0, import_jotai21.atom)((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "pencil") return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
});
|
||
|
|
var isMultiTouchAtom = (0, import_jotai21.atom)((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
let fingerCount = 0;
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "finger") fingerCount++;
|
||
|
|
}
|
||
|
|
return fingerCount > 1;
|
||
|
|
});
|
||
|
|
var fingerCountAtom = (0, import_jotai21.atom)((get) => {
|
||
|
|
const pointers = get(activePointersAtom);
|
||
|
|
let count = 0;
|
||
|
|
for (const [, pointer] of pointers) {
|
||
|
|
if (pointer.source === "finger") count++;
|
||
|
|
}
|
||
|
|
return count;
|
||
|
|
});
|
||
|
|
var isTouchDeviceAtom = (0, import_jotai21.atom)((get) => {
|
||
|
|
const caps = get(inputCapabilitiesAtom);
|
||
|
|
return caps.hasTouch;
|
||
|
|
});
|
||
|
|
var pointerDownAtom = (0, import_jotai21.atom)(null, (get, set, pointer) => {
|
||
|
|
const pointers = new Map(get(activePointersAtom));
|
||
|
|
pointers.set(pointer.pointerId, pointer);
|
||
|
|
set(activePointersAtom, pointers);
|
||
|
|
set(primaryInputSourceAtom, pointer.source);
|
||
|
|
if (pointer.source === "pencil") {
|
||
|
|
const caps = get(inputCapabilitiesAtom);
|
||
|
|
if (!caps.hasStylus) {
|
||
|
|
set(inputCapabilitiesAtom, {
|
||
|
|
...caps,
|
||
|
|
hasStylus: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var pointerUpAtom = (0, import_jotai21.atom)(null, (get, set, pointerId) => {
|
||
|
|
const pointers = new Map(get(activePointersAtom));
|
||
|
|
pointers.delete(pointerId);
|
||
|
|
set(activePointersAtom, pointers);
|
||
|
|
});
|
||
|
|
var clearPointersAtom = (0, import_jotai21.atom)(null, (_get, set) => {
|
||
|
|
set(activePointersAtom, /* @__PURE__ */ new Map());
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/selection-path-store.ts
|
||
|
|
var import_jotai22 = require("jotai");
|
||
|
|
var selectionPathAtom = (0, import_jotai22.atom)(null);
|
||
|
|
var isSelectingAtom = (0, import_jotai22.atom)((get) => get(selectionPathAtom) !== null);
|
||
|
|
var startSelectionAtom = (0, import_jotai22.atom)(null, (_get, set, {
|
||
|
|
type,
|
||
|
|
point
|
||
|
|
}) => {
|
||
|
|
set(selectionPathAtom, {
|
||
|
|
type,
|
||
|
|
points: [point]
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var updateSelectionAtom = (0, import_jotai22.atom)(null, (get, set, point) => {
|
||
|
|
const current = get(selectionPathAtom);
|
||
|
|
if (!current) return;
|
||
|
|
if (current.type === "rect") {
|
||
|
|
set(selectionPathAtom, {
|
||
|
|
...current,
|
||
|
|
points: [current.points[0], point]
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
set(selectionPathAtom, {
|
||
|
|
...current,
|
||
|
|
points: [...current.points, point]
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
var cancelSelectionAtom = (0, import_jotai22.atom)(null, (_get, set) => {
|
||
|
|
set(selectionPathAtom, null);
|
||
|
|
});
|
||
|
|
var endSelectionAtom = (0, import_jotai22.atom)(null, (get, set) => {
|
||
|
|
const path = get(selectionPathAtom);
|
||
|
|
if (!path || path.points.length < 2) {
|
||
|
|
set(selectionPathAtom, null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
const selectedIds = [];
|
||
|
|
if (path.type === "rect") {
|
||
|
|
const [p1, p2] = [path.points[0], path.points[path.points.length - 1]];
|
||
|
|
const minX = Math.min(p1.x, p2.x);
|
||
|
|
const maxX = Math.max(p1.x, p2.x);
|
||
|
|
const minY = Math.min(p1.y, p2.y);
|
||
|
|
const maxY = Math.max(p1.y, p2.y);
|
||
|
|
for (const node of nodes) {
|
||
|
|
const nodeRight = node.position.x + (node.width ?? 200);
|
||
|
|
const nodeBottom = node.position.y + (node.height ?? 100);
|
||
|
|
if (node.position.x < maxX && nodeRight > minX && node.position.y < maxY && nodeBottom > minY) {
|
||
|
|
selectedIds.push(node.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const polygon = path.points;
|
||
|
|
for (const node of nodes) {
|
||
|
|
const cx = node.position.x + (node.width ?? 200) / 2;
|
||
|
|
const cy = node.position.y + (node.height ?? 100) / 2;
|
||
|
|
if (pointInPolygon(cx, cy, polygon)) {
|
||
|
|
selectedIds.push(node.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
set(selectedNodeIdsAtom, new Set(selectedIds));
|
||
|
|
set(selectionPathAtom, null);
|
||
|
|
});
|
||
|
|
var selectionRectAtom = (0, import_jotai22.atom)((get) => {
|
||
|
|
const path = get(selectionPathAtom);
|
||
|
|
if (!path || path.type !== "rect" || path.points.length < 2) return null;
|
||
|
|
const [p1, p2] = [path.points[0], path.points[path.points.length - 1]];
|
||
|
|
return {
|
||
|
|
x: Math.min(p1.x, p2.x),
|
||
|
|
y: Math.min(p1.y, p2.y),
|
||
|
|
width: Math.abs(p2.x - p1.x),
|
||
|
|
height: Math.abs(p2.y - p1.y)
|
||
|
|
};
|
||
|
|
});
|
||
|
|
function pointInPolygon(px, py, polygon) {
|
||
|
|
let inside = false;
|
||
|
|
const n = polygon.length;
|
||
|
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||
|
|
const xi = polygon[i].x;
|
||
|
|
const yi = polygon[i].y;
|
||
|
|
const xj = polygon[j].x;
|
||
|
|
const yj = polygon[j].y;
|
||
|
|
if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
|
||
|
|
inside = !inside;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return inside;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/core/search-store.ts
|
||
|
|
var import_jotai23 = require("jotai");
|
||
|
|
var searchQueryAtom = (0, import_jotai23.atom)("");
|
||
|
|
var setSearchQueryAtom = (0, import_jotai23.atom)(null, (_get, set, query) => {
|
||
|
|
set(searchQueryAtom, query);
|
||
|
|
set(highlightedSearchIndexAtom, 0);
|
||
|
|
});
|
||
|
|
var clearSearchAtom = (0, import_jotai23.atom)(null, (_get, set) => {
|
||
|
|
set(searchQueryAtom, "");
|
||
|
|
set(highlightedSearchIndexAtom, 0);
|
||
|
|
});
|
||
|
|
function fuzzyMatch(query, ...haystacks) {
|
||
|
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||
|
|
if (tokens.length === 0) return false;
|
||
|
|
const combined = haystacks.join(" ").toLowerCase();
|
||
|
|
return tokens.every((token) => combined.includes(token));
|
||
|
|
}
|
||
|
|
var searchResultsAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
const query = get(searchQueryAtom).trim();
|
||
|
|
if (!query) return /* @__PURE__ */ new Set();
|
||
|
|
const nodes = get(uiNodesAtom);
|
||
|
|
const matches = /* @__PURE__ */ new Set();
|
||
|
|
for (const node of nodes) {
|
||
|
|
if (fuzzyMatch(query, node.label || "", node.dbData.node_type || "", node.id)) {
|
||
|
|
matches.add(node.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return matches;
|
||
|
|
});
|
||
|
|
var searchResultsArrayAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
return Array.from(get(searchResultsAtom));
|
||
|
|
});
|
||
|
|
var searchResultCountAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
return get(searchResultsAtom).size;
|
||
|
|
});
|
||
|
|
var searchEdgeResultsAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
const query = get(searchQueryAtom).trim();
|
||
|
|
if (!query) return /* @__PURE__ */ new Set();
|
||
|
|
get(graphUpdateVersionAtom);
|
||
|
|
const graph = get(graphAtom);
|
||
|
|
const matches = /* @__PURE__ */ new Set();
|
||
|
|
graph.forEachEdge((edgeKey, attrs) => {
|
||
|
|
const label = attrs.label || "";
|
||
|
|
const edgeType = attrs.dbData?.edge_type || "";
|
||
|
|
if (fuzzyMatch(query, label, edgeType, edgeKey)) {
|
||
|
|
matches.add(edgeKey);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return matches;
|
||
|
|
});
|
||
|
|
var searchEdgeResultCountAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
return get(searchEdgeResultsAtom).size;
|
||
|
|
});
|
||
|
|
var isFilterActiveAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
return get(searchQueryAtom).trim().length > 0;
|
||
|
|
});
|
||
|
|
var searchTotalResultCountAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
return get(searchResultCountAtom) + get(searchEdgeResultCountAtom);
|
||
|
|
});
|
||
|
|
var highlightedSearchIndexAtom = (0, import_jotai23.atom)(0);
|
||
|
|
var nextSearchResultAtom = (0, import_jotai23.atom)(null, (get, set) => {
|
||
|
|
const results = get(searchResultsArrayAtom);
|
||
|
|
if (results.length === 0) return;
|
||
|
|
const currentIndex = get(highlightedSearchIndexAtom);
|
||
|
|
const nextIndex = (currentIndex + 1) % results.length;
|
||
|
|
set(highlightedSearchIndexAtom, nextIndex);
|
||
|
|
const nodeId = results[nextIndex];
|
||
|
|
set(centerOnNodeAtom, nodeId);
|
||
|
|
set(selectSingleNodeAtom, nodeId);
|
||
|
|
});
|
||
|
|
var prevSearchResultAtom = (0, import_jotai23.atom)(null, (get, set) => {
|
||
|
|
const results = get(searchResultsArrayAtom);
|
||
|
|
if (results.length === 0) return;
|
||
|
|
const currentIndex = get(highlightedSearchIndexAtom);
|
||
|
|
const prevIndex = (currentIndex - 1 + results.length) % results.length;
|
||
|
|
set(highlightedSearchIndexAtom, prevIndex);
|
||
|
|
const nodeId = results[prevIndex];
|
||
|
|
set(centerOnNodeAtom, nodeId);
|
||
|
|
set(selectSingleNodeAtom, nodeId);
|
||
|
|
});
|
||
|
|
var highlightedSearchNodeIdAtom = (0, import_jotai23.atom)((get) => {
|
||
|
|
const results = get(searchResultsArrayAtom);
|
||
|
|
if (results.length === 0) return null;
|
||
|
|
const index = get(highlightedSearchIndexAtom);
|
||
|
|
return results[index] ?? null;
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/gesture-rules-defaults.ts
|
||
|
|
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
|
||
|
|
var import_jotai24 = require("jotai");
|
||
|
|
var import_utils2 = require("jotai/utils");
|
||
|
|
var DEFAULT_RULE_STATE = {
|
||
|
|
customRules: [],
|
||
|
|
palmRejection: true
|
||
|
|
};
|
||
|
|
var gestureRuleSettingsAtom = (0, import_utils2.atomWithStorage)("canvas-gesture-rules", DEFAULT_RULE_STATE);
|
||
|
|
var consumerGestureRulesAtom = (0, import_jotai24.atom)([]);
|
||
|
|
var gestureRulesAtom = (0, import_jotai24.atom)((get) => {
|
||
|
|
const settings = get(gestureRuleSettingsAtom);
|
||
|
|
const consumerRules = get(consumerGestureRulesAtom);
|
||
|
|
let rules = mergeRules(DEFAULT_GESTURE_RULES, settings.customRules);
|
||
|
|
if (consumerRules.length > 0) {
|
||
|
|
rules = mergeRules(rules, consumerRules);
|
||
|
|
}
|
||
|
|
return rules;
|
||
|
|
});
|
||
|
|
var gestureRuleIndexAtom = (0, import_jotai24.atom)((get) => {
|
||
|
|
return buildRuleIndex(get(gestureRulesAtom));
|
||
|
|
});
|
||
|
|
var palmRejectionEnabledAtom = (0, import_jotai24.atom)((get) => get(gestureRuleSettingsAtom).palmRejection, (get, set, enabled) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
palmRejection: enabled
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var addGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, rule) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
const existing = current.customRules.findIndex((r) => r.id === rule.id);
|
||
|
|
const newRules = [...current.customRules];
|
||
|
|
if (existing >= 0) {
|
||
|
|
newRules[existing] = rule;
|
||
|
|
} else {
|
||
|
|
newRules.push(rule);
|
||
|
|
}
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: newRules
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var removeGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, ruleId) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: current.customRules.filter((r) => r.id !== ruleId)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var updateGestureRuleAtom = (0, import_jotai24.atom)(null, (get, set, {
|
||
|
|
id,
|
||
|
|
updates
|
||
|
|
}) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
const index = current.customRules.findIndex((r) => r.id === id);
|
||
|
|
if (index < 0) return;
|
||
|
|
const newRules = [...current.customRules];
|
||
|
|
newRules[index] = {
|
||
|
|
...newRules[index],
|
||
|
|
...updates
|
||
|
|
};
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: newRules
|
||
|
|
});
|
||
|
|
});
|
||
|
|
var resetGestureRulesAtom = (0, import_jotai24.atom)(null, (get, set) => {
|
||
|
|
const current = get(gestureRuleSettingsAtom);
|
||
|
|
set(gestureRuleSettingsAtom, {
|
||
|
|
...current,
|
||
|
|
customRules: []
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/core/external-keyboard-store.ts
|
||
|
|
var import_jotai25 = require("jotai");
|
||
|
|
var hasExternalKeyboardAtom = (0, import_jotai25.atom)(false);
|
||
|
|
var watchExternalKeyboardAtom = (0, import_jotai25.atom)(null, (get, set) => {
|
||
|
|
if (typeof window === "undefined") return;
|
||
|
|
const handler = (e) => {
|
||
|
|
if (e.key && e.key.length === 1 || ["Tab", "Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||
|
|
set(hasExternalKeyboardAtom, true);
|
||
|
|
window.removeEventListener("keydown", handler);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
window.addEventListener("keydown", handler);
|
||
|
|
return () => window.removeEventListener("keydown", handler);
|
||
|
|
});
|
||
|
|
|
||
|
|
// src/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
|
||
|
|
};
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=index.js.map
|