mirror of
https://github.com/sotam0316/drawNET.git
synced 2026-04-25 03:58:37 +09:00
Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { state } from '../../state.js';
|
||||
import { toggleSidebar } from '../../properties_sidebar/index.js';
|
||||
|
||||
/**
|
||||
* handleMenuAction - Executes the logic for a clicked context menu item
|
||||
* Direct X6 API version (Replaces legacy DSL-based handler)
|
||||
*/
|
||||
export function handleMenuAction(action, cellId) {
|
||||
if (!state.graph) return;
|
||||
|
||||
// Recursive Selection Focus
|
||||
if (action === 'select-cell' && cellId) {
|
||||
const target = state.graph.getCellById(cellId);
|
||||
if (target) {
|
||||
state.graph.cleanSelection();
|
||||
state.graph.select(target);
|
||||
toggleSidebar(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCells = state.graph.getSelectedCells();
|
||||
const selectedNodes = selectedCells.filter(c => c.isNode());
|
||||
|
||||
if (action === 'properties') {
|
||||
if (cellId) {
|
||||
const target = state.graph.getCellById(cellId);
|
||||
if (target) {
|
||||
state.graph.cleanSelection();
|
||||
state.graph.select(target);
|
||||
}
|
||||
}
|
||||
toggleSidebar(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Alignment & Distribution Actions
|
||||
const isAlignmentAction = action.startsWith('align') || action.startsWith('distribute');
|
||||
const isZOrderAction = action === 'to-front' || action === 'to-back';
|
||||
|
||||
if (isAlignmentAction || isZOrderAction) {
|
||||
if (action === 'to-front') { selectedCells.forEach(c => c.toFront({ deep: true })); return; }
|
||||
if (action === 'to-back') { selectedCells.forEach(c => c.toBack({ deep: true })); return; }
|
||||
|
||||
import('/static/js/modules/graph/alignment.js').then(m => {
|
||||
const alignMap = {
|
||||
'alignLeft': 'left', 'align-left': 'left',
|
||||
'alignRight': 'right', 'align-right': 'right',
|
||||
'alignTop': 'top', 'align-top': 'top',
|
||||
'alignBottom': 'bottom', 'align-bottom': 'bottom',
|
||||
'alignCenter': 'center', 'align-center': 'center',
|
||||
'alignMiddle': 'middle', 'align-middle': 'middle'
|
||||
};
|
||||
const distMap = {
|
||||
'distributeHorizontal': 'horizontal', 'distribute-h': 'horizontal',
|
||||
'distributeVertical': 'vertical', 'distribute-v': 'vertical'
|
||||
};
|
||||
|
||||
if (alignMap[action]) m.alignNodes(alignMap[action]);
|
||||
else if (distMap[action]) m.distributeNodes(distMap[action]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Group Management (X6 API directly) ---
|
||||
if (action === 'group') {
|
||||
if (selectedNodes.length < 2) return;
|
||||
const groupLabel = 'group_' + Math.random().toString(36).substr(2, 4);
|
||||
|
||||
// Calculate BBox of selected nodes
|
||||
const bbox = state.graph.getCellsBBox(selectedNodes);
|
||||
const padding = 40;
|
||||
|
||||
const groupNode = state.graph.addNode({
|
||||
shape: 'drawnet-node',
|
||||
x: bbox.x - padding,
|
||||
y: bbox.y - padding,
|
||||
width: bbox.width + padding * 2,
|
||||
height: bbox.height + padding * 2,
|
||||
zIndex: 1,
|
||||
data: {
|
||||
label: groupLabel,
|
||||
is_group: true,
|
||||
background: '#e0f2fe',
|
||||
description: "",
|
||||
asset_tag: "",
|
||||
tags: []
|
||||
},
|
||||
attrs: {
|
||||
label: { text: groupLabel },
|
||||
body: { fill: '#e0f2fe', stroke: '#3b82f6' }
|
||||
}
|
||||
});
|
||||
|
||||
// Set child nodes
|
||||
selectedNodes.forEach(n => groupNode.addChild(n));
|
||||
state.graph.cleanSelection();
|
||||
state.graph.select(groupNode);
|
||||
|
||||
import('/static/js/modules/persistence.js').then(m => m.markDirty());
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'ungroup') {
|
||||
if (selectedNodes.length === 0) return;
|
||||
const group = selectedNodes[0];
|
||||
if (!group.getData()?.is_group) return;
|
||||
|
||||
// Separate child nodes
|
||||
const children = group.getChildren() || [];
|
||||
children.forEach(child => group.removeChild(child));
|
||||
state.graph.removeNode(group);
|
||||
|
||||
import('/static/js/modules/persistence.js').then(m => m.markDirty());
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Disconnect (X6 API directly) ---
|
||||
if (action === 'disconnect') {
|
||||
if (selectedNodes.length < 2) return;
|
||||
const nodeIds = new Set(selectedNodes.map(n => n.id));
|
||||
const edgesToRemove = state.graph.getEdges().filter(e => {
|
||||
return nodeIds.has(e.getSourceCellId()) && nodeIds.has(e.getTargetCellId());
|
||||
});
|
||||
edgesToRemove.forEach(e => state.graph.removeEdge(e));
|
||||
|
||||
import('/static/js/modules/persistence.js').then(m => m.markDirty());
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Connect / Line Style Update actions ---
|
||||
const selectedEdges = selectedCells.filter(c => c.isEdge());
|
||||
|
||||
const styleMap = {
|
||||
'connect-solid': { routing: 'manhattan' },
|
||||
'connect-straight': { routing: 'straight' },
|
||||
'connect-dashed': { style: 'dashed' }
|
||||
};
|
||||
|
||||
if (selectedEdges.length > 0 && styleMap[action]) {
|
||||
const edgeData = styleMap[action];
|
||||
selectedEdges.forEach(async (edge) => {
|
||||
const currentData = edge.getData() || {};
|
||||
const newData = { ...currentData, ...edgeData };
|
||||
const { handleEdgeUpdate } = await import('../../properties_sidebar/handlers/edge.js');
|
||||
await handleEdgeUpdate(edge, newData);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connectableNodes = selectedCells.filter(c => c.isNode());
|
||||
if (connectableNodes.length < 2) return;
|
||||
|
||||
// Sort by selection order if available
|
||||
const sortedNodes = [...connectableNodes].sort((a, b) => {
|
||||
const idxA = state.selectionOrder.indexOf(a.id);
|
||||
const idxB = state.selectionOrder.indexOf(b.id);
|
||||
if (idxA === -1 || idxB === -1) return 0;
|
||||
return idxA - idxB;
|
||||
});
|
||||
|
||||
const edgeData = styleMap[action] || {};
|
||||
|
||||
// Chain connection logic (1 -> 2, 2 -> 3, ...)
|
||||
for (let i = 0; i < sortedNodes.length - 1; i++) {
|
||||
const src = sortedNodes[i];
|
||||
const dst = sortedNodes[i+1];
|
||||
|
||||
// Prevent duplicate connections (bi-directional check)
|
||||
const exists = state.graph.getEdges().some(e =>
|
||||
(e.getSourceCellId() === src.id && e.getTargetCellId() === dst.id) ||
|
||||
(e.getSourceCellId() === dst.id && e.getTargetCellId() === src.id)
|
||||
);
|
||||
if (exists) continue;
|
||||
|
||||
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
|
||||
const config = getX6EdgeConfig({
|
||||
id: `e_${src.id}_${dst.id}_${Date.now()}`,
|
||||
source: src.id,
|
||||
target: dst.id,
|
||||
description: "",
|
||||
asset_tag: "",
|
||||
routing_offset: 20,
|
||||
...edgeData
|
||||
});
|
||||
state.graph.addEdge(config);
|
||||
});
|
||||
}
|
||||
|
||||
import('/static/js/modules/persistence.js').then(m => m.markDirty());
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { state } from '../../state.js';
|
||||
import { handleMenuAction } from './handlers.js';
|
||||
import { generateMenuHTML, generateSelectionMenuHTML, showContextMenu, hideContextMenu } from './renderer.js';
|
||||
|
||||
let contextMenuElement = null;
|
||||
|
||||
/**
|
||||
* initContextMenu - Initializes the context menu module and event listeners
|
||||
*/
|
||||
export function initContextMenu() {
|
||||
if (contextMenuElement) return;
|
||||
|
||||
// 1. Create and Setup Menu Element
|
||||
contextMenuElement = document.createElement('div');
|
||||
contextMenuElement.id = 'floating-context-menu';
|
||||
contextMenuElement.className = 'floating-menu';
|
||||
contextMenuElement.style.display = 'none';
|
||||
contextMenuElement.style.position = 'fixed';
|
||||
contextMenuElement.style.zIndex = '1000';
|
||||
document.body.appendChild(contextMenuElement);
|
||||
|
||||
// 2. Event Listeners for menu items
|
||||
contextMenuElement.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('[data-action]');
|
||||
if (!item) return;
|
||||
|
||||
const action = item.getAttribute('data-action');
|
||||
const cellId = item.getAttribute('data-cell-id');
|
||||
|
||||
if (action) {
|
||||
handleMenuAction(action, cellId);
|
||||
}
|
||||
hideContextMenu(contextMenuElement);
|
||||
});
|
||||
|
||||
// Close menu on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (contextMenuElement && !contextMenuElement.contains(e.target)) {
|
||||
hideContextMenu(contextMenuElement);
|
||||
}
|
||||
});
|
||||
|
||||
if (state.graph) {
|
||||
// Listen for right click on Graph Cells
|
||||
state.graph.on('cell:contextmenu', ({ e, cell }) => {
|
||||
e.preventDefault();
|
||||
if (state.isRightDragging) return;
|
||||
|
||||
// Photoshop-style recursive selection if Ctrl is pressed
|
||||
if (e.ctrlKey) {
|
||||
const local = state.graph.clientToLocal(e.clientX, e.clientY);
|
||||
const models = state.graph.getCells().filter(cell => cell.getBBox().containsPoint(local));
|
||||
contextMenuElement.innerHTML = generateSelectionMenuHTML(models);
|
||||
} else {
|
||||
const selected = state.graph.getSelectedCells();
|
||||
const nodes = selected.filter(c => c.isNode());
|
||||
const isGroupSelected = nodes.length === 1 && nodes[0].getData()?.is_group;
|
||||
contextMenuElement.innerHTML = generateMenuHTML(nodes, isGroupSelected);
|
||||
}
|
||||
|
||||
showContextMenu(contextMenuElement, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Blank context menu (Handle Ctrl+RightClick here too)
|
||||
state.graph.on('blank:contextmenu', ({ e }) => {
|
||||
e.preventDefault();
|
||||
if (state.isRightDragging) return;
|
||||
|
||||
if (e.ctrlKey) {
|
||||
const local = state.graph.clientToLocal(e.clientX, e.clientY);
|
||||
const models = state.graph.getCells().filter(cell => cell.getBBox().containsPoint(local));
|
||||
contextMenuElement.innerHTML = generateSelectionMenuHTML(models);
|
||||
showContextMenu(contextMenuElement, e.clientX, e.clientY);
|
||||
} else {
|
||||
hideContextMenu(contextMenuElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* renderer.js - UI Generation and Positioning for Context Menu
|
||||
*/
|
||||
export function generateMenuHTML(nodes, isGroupSelected) {
|
||||
let html = '';
|
||||
|
||||
// 1. Connection Group
|
||||
html += `
|
||||
<div class="menu-label">Connection</div>
|
||||
<div class="menu-item" data-action="connect-solid"><i class="fas fa-link" style="margin-right:8px; opacity:0.6;"></i> Connect (Solid)</div>
|
||||
<div class="menu-item" data-action="connect-straight"><i class="fas fa-slash" style="margin-right:8px; opacity:0.6; transform: rotate(-45deg);"></i> Connect (Straight)</div>
|
||||
<div class="menu-item" data-action="connect-dashed"><i class="fas fa-ellipsis-h" style="margin-right:8px; opacity:0.6;"></i> Connect (Dashed)</div>
|
||||
<div class="menu-item" data-action="disconnect"><i class="fas fa-unlink" style="margin-right:8px; opacity:0.6;"></i> Disconnect</div>
|
||||
`;
|
||||
|
||||
// 2. Node Management Group
|
||||
if (nodes.length >= 2 || isGroupSelected) {
|
||||
html += `<div class="menu-divider"></div><div class="menu-label">Nodes</div>`;
|
||||
if (nodes.length >= 2) {
|
||||
html += `<div class="menu-item" data-action="group"><i class="fas fa-object-group" style="margin-right:8px; opacity:0.6;"></i> Group Selected</div>`;
|
||||
}
|
||||
if (isGroupSelected) {
|
||||
html += `<div class="menu-item" data-action="ungroup"><i class="fas fa-object-ungroup" style="margin-right:8px; opacity:0.6;"></i> Ungroup</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Alignment Group
|
||||
if (nodes.length >= 2) {
|
||||
html += `
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-label">Align & Distribute</div>
|
||||
<div class="menu-icon-row">
|
||||
<div class="icon-btn" data-action="align-top" title="Align Top (Shift+1)"><i class="fas fa-align-left fa-rotate-90"></i></div>
|
||||
<div class="icon-btn" data-action="align-bottom" title="Align Bottom (Shift+2)"><i class="fas fa-align-right fa-rotate-90"></i></div>
|
||||
<div class="icon-btn" data-action="align-left" title="Align Left (Shift+3)"><i class="fas fa-align-left"></i></div>
|
||||
<div class="icon-btn" data-action="align-right" title="Align Right (Shift+4)"><i class="fas fa-align-right"></i></div>
|
||||
<div class="icon-btn" data-action="align-middle" title="Align Middle (Shift+5)"><i class="fas fa-align-center fa-rotate-90"></i></div>
|
||||
<div class="icon-btn" data-action="align-center" title="Align Center (Shift+6)"><i class="fas fa-align-center"></i></div>
|
||||
</div>
|
||||
`;
|
||||
if (nodes.length >= 3) {
|
||||
html += `
|
||||
<div class="menu-icon-row" style="border-top: 1px solid rgba(255,255,255,0.05); margin-top:4px;">
|
||||
<div class="icon-btn" data-action="distribute-h" title="Distribute Horizontal"><i class="fas fa-arrows-alt-h"></i></div>
|
||||
<div class="icon-btn" style="opacity:0.2; cursor:default;"><i class="fas fa-arrows-alt-v"></i></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Other Group
|
||||
html += `
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-item" data-action="properties"><i class="fas fa-cog" style="margin-right:8px; opacity:0.6;"></i> Properties...</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function generateSelectionMenuHTML(models) {
|
||||
let html = '<div class="menu-label">Select Object</div>';
|
||||
|
||||
if (models.length === 0) {
|
||||
html += '<div class="menu-item disabled">No objects here</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
models.forEach(model => {
|
||||
const data = model.getData() || {};
|
||||
const type = (data.type || model.shape || 'Unknown').toUpperCase();
|
||||
const label = data.label || model.id.substring(0, 8);
|
||||
const iconClass = model.isNode() ? (data.is_group ? 'fa-object-group' : 'fa-microchip') : 'fa-link';
|
||||
|
||||
html += `
|
||||
<div class="menu-item" data-action="select-cell" data-cell-id="${model.id}">
|
||||
<i class="fas ${iconClass}" style="margin-right:8px; width:14px; opacity:0.6;"></i>
|
||||
<span style="font-size:10px; opacity:0.5; margin-right:4px;">[${type}]</span>
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* showContextMenu - Positions and displays the menu
|
||||
*/
|
||||
export function showContextMenu(el, x, y) {
|
||||
if (!el) return;
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.display = 'block';
|
||||
|
||||
const menuWidth = el.offsetWidth;
|
||||
const menuHeight = el.offsetHeight;
|
||||
const padding = 10;
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let left = x;
|
||||
if (x + menuWidth + padding > windowWidth) {
|
||||
left = windowWidth - menuWidth - padding;
|
||||
}
|
||||
|
||||
let top = y;
|
||||
if (y + menuHeight + padding > windowHeight) {
|
||||
top = windowHeight - menuHeight - padding;
|
||||
}
|
||||
|
||||
el.style.left = `${Math.max(padding, left)}px`;
|
||||
el.style.top = `${Math.max(padding, top)}px`;
|
||||
el.style.opacity = '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* hideContextMenu - Hides the menu
|
||||
*/
|
||||
export function hideContextMenu(el) {
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
Reference in New Issue
Block a user