context menu

This commit is contained in:
overcuriousity
2025-09-14 23:42:45 +02:00
parent f02381910d
commit 2658bd148b
25 changed files with 429 additions and 4 deletions

View File

@@ -12,6 +12,8 @@ class GraphManager {
this.isInitialized = false;
this.currentLayout = 'physics';
this.nodeInfoPopup = null;
this.contextMenu = null;
this.history = [];
this.options = {
nodes: {
@@ -117,6 +119,8 @@ class GraphManager {
};
this.createNodeInfoPopup();
this.createContextMenu();
document.body.addEventListener('click', () => this.hideContextMenu());
}
/**
@@ -128,6 +132,24 @@ class GraphManager {
this.nodeInfoPopup.style.display = 'none';
document.body.appendChild(this.nodeInfoPopup);
}
/**
* Create context menu
*/
createContextMenu() {
this.contextMenu = document.createElement('div');
this.contextMenu.id = 'graph-context-menu';
this.contextMenu.className = 'graph-context-menu';
this.contextMenu.style.display = 'none';
// Prevent body click listener from firing when clicking the menu itself
this.contextMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
document.body.appendChild(this.contextMenu);
}
/**
* Initialize the network graph
@@ -173,6 +195,8 @@ class GraphManager {
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
<button class="graph-control-btn" id="graph-unhide" title="Unhide All">[UNHIDE]</button>
<button class="graph-control-btn" id="graph-revert" title="Revert Last Action">[REVERT]</button>
`;
this.container.appendChild(controlsContainer);
@@ -181,6 +205,8 @@ class GraphManager {
document.getElementById('graph-fit').addEventListener('click', () => this.fitView());
document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics());
document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering());
document.getElementById('graph-unhide').addEventListener('click', () => this.unhideAll());
document.getElementById('graph-revert').addEventListener('click', () => this.revertLastAction());
}
/**
@@ -189,8 +215,29 @@ class GraphManager {
setupNetworkEvents() {
if (!this.network) return;
// Use a standard DOM event listener for the context menu for better reliability
this.container.addEventListener('contextmenu', (event) => {
event.preventDefault();
// Get coordinates relative to the canvas
const pointer = {
x: event.offsetX,
y: event.offsetY
};
const nodeId = this.network.getNodeAt(pointer);
if (nodeId) {
// Pass the original client event for positioning
this.showContextMenu(nodeId, event);
} else {
this.hideContextMenu();
}
});
// Node click event with details
this.network.on('click', (params) => {
this.hideContextMenu();
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
if (this.network.isCluster(nodeId)) {
@@ -216,10 +263,6 @@ class GraphManager {
}
});
this.network.on('oncontext', (params) => {
params.event.preventDefault();
});
// Stabilization events with progress
this.network.on('stabilizationProgress', (params) => {
const progress = params.iterations / params.total;
@@ -846,6 +889,7 @@ class GraphManager {
clear() {
this.nodes.clear();
this.edges.clear();
this.history = [];
// Show placeholder
const placeholder = this.container.querySelector('.graph-placeholder');
@@ -919,6 +963,238 @@ class GraphManager {
console.log('Filters applied.');
}
/**
* Show context menu for a node
* @param {string} nodeId - The ID of the node
* @param {Event} event - The contextmenu event
*/
showContextMenu(nodeId, event) {
this.contextMenu.innerHTML = `
<ul>
<li data-action="hide" data-node-id="${nodeId}">Hide Node</li>
<li data-action="delete" data-node-id="${nodeId}">Delete Node</li>
</ul>
`;
this.contextMenu.style.left = `${event.clientX}px`;
this.contextMenu.style.top = `${event.clientY}px`;
this.contextMenu.style.display = 'block';
this.contextMenu.querySelectorAll('li').forEach(item => {
item.addEventListener('click', (e) => {
const action = e.target.dataset.action;
const nodeId = e.target.dataset.nodeId;
this.performContextMenuAction(action, nodeId);
this.hideContextMenu();
});
});
}
/**
* Hide the context menu
*/
hideContextMenu() {
if (this.contextMenu) {
this.contextMenu.style.display = 'none';
}
}
/**
* Perform action from the context menu
* @param {string} action - The action to perform ('hide' or 'delete')
* @param {string} nodeId - The ID of the node
*/
performContextMenuAction(action, nodeId) {
switch (action) {
case 'hide':
this.hideNodeAndOrphans(nodeId);
break;
case 'delete':
this.deleteNodeAndOrphans(nodeId);
break;
}
}
/**
* Add an operation to the history stack
* @param {string} type - The type of operation ('hide', 'delete')
* @param {Object} data - The data needed to revert the operation
*/
addToHistory(type, data) {
this.history.push({ type, data });
}
/**
* Revert the last action
*/
async revertLastAction() {
const lastAction = this.history.pop();
if (!lastAction) {
console.log('No actions to revert.');
return;
}
switch (lastAction.type) {
case 'hide':
// Revert hiding nodes by un-hiding them
const updates = lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }));
this.nodes.update(updates);
break;
case 'delete':
try {
const response = await fetch('/api/graph/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(lastAction)
});
const result = await response.json();
if (result.success) {
console.log('Delete action reverted successfully on backend.');
// Re-add all nodes and edges from the history to the local view
this.nodes.add(lastAction.data.nodes);
this.edges.add(lastAction.data.edges);
} else {
console.error('Failed to revert delete action on backend:', result.error);
// Push the action back onto the history stack if the API call failed
this.history.push(lastAction);
}
} catch (error) {
console.error('Error during revert API call:', error);
this.history.push(lastAction);
}
break;
}
}
/**
* Hide a node and recursively hide any neighbors that become disconnected.
* @param {string} nodeId - The ID of the node to start hiding from.
*/
hideNodeAndOrphans(nodeId) {
const historyData = { nodeIds: [] };
const queue = [nodeId];
const visited = new Set([nodeId]);
while (queue.length > 0) {
const currentId = queue.shift();
const node = this.nodes.get(currentId);
if (!node || node.hidden) continue;
// 1. Hide the current node and add to history
this.nodes.update({ id: currentId, hidden: true });
historyData.nodeIds.push(currentId);
// 2. Check its neighbors
const neighbors = this.network.getConnectedNodes(currentId);
for (const neighborId of neighbors) {
if (visited.has(neighborId)) continue;
const connectedEdges = this.network.getConnectedEdges(neighborId);
let hasVisibleEdge = false;
// 3. See if the neighbor still has any visible connections
for (const edgeId of connectedEdges) {
const edge = this.edges.get(edgeId);
const sourceNode = this.nodes.get(edge.from);
const targetNode = this.nodes.get(edge.to);
if ((sourceNode && !sourceNode.hidden) && (targetNode && !targetNode.hidden)) {
hasVisibleEdge = true;
break;
}
}
// 4. If no visible connections, add to queue to be hidden
if (!hasVisibleEdge) {
queue.push(neighborId);
visited.add(neighborId);
}
}
}
if (historyData.nodeIds.length > 0) {
this.addToHistory('hide', historyData);
}
}
/**
* Delete a node and recursively delete any neighbors that become disconnected.
* @param {string} nodeId - The ID of the node to start deleting from.
*/
async deleteNodeAndOrphans(nodeId) {
const deletionQueue = [nodeId];
const processedForDeletion = new Set([nodeId]);
const historyData = { nodes: [], edges: [] };
let operationFailed = false;
while (deletionQueue.length > 0) {
const currentId = deletionQueue.shift();
const node = this.nodes.get(currentId);
if (!node) continue;
const neighbors = this.network.getConnectedNodes(currentId);
const connectedEdgeIds = this.network.getConnectedEdges(currentId);
const edges = this.edges.get(connectedEdgeIds);
// Store state for potential revert
historyData.nodes.push(node);
historyData.edges.push(...edges);
try {
const response = await fetch(`/api/graph/node/${currentId}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) {
console.error(`Failed to delete node ${currentId} from backend:`, result.error);
operationFailed = true;
break;
}
console.log(`Node ${currentId} deleted from backend.`);
this.nodes.remove({ id: currentId }); // Remove from view
// Check if former neighbors are now orphans
neighbors.forEach(neighborId => {
if (!processedForDeletion.has(neighborId) && this.nodes.get(neighborId)) {
if (this.network.getConnectedEdges(neighborId).length === 0) {
deletionQueue.push(neighborId);
processedForDeletion.add(neighborId);
}
}
});
} catch (error) {
console.error('Error during node deletion API call:', error);
operationFailed = true;
break;
}
}
// Add to history only if the entire operation was successful
if (!operationFailed && historyData.nodes.length > 0) {
// Ensure edges in history are unique
historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values());
this.addToHistory('delete', historyData);
} else if (operationFailed) {
console.log("Reverting UI changes due to failed delete operation.");
// If any part of the chain failed, restore the UI to its original state
this.nodes.add(historyData.nodes);
this.edges.add(historyData.edges);
}
}
/**
* Unhide all hidden nodes
*/
unhideAll() {
const allNodes = this.nodes.get({
filter: (node) => node.hidden === true
});
const updates = allNodes.map(node => ({ id: node.id, hidden: false }));
this.nodes.update(updates);
}
}
// Export for use in main.js