Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations

This commit is contained in:
leeyj
2026-03-22 22:37:24 +09:00
commit 5cea93e317
192 changed files with 14449 additions and 0 deletions
@@ -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';
}