context menu
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user