static 폴더 및 하위 파일 업로드

This commit is contained in:
sotam0316
2026-04-22 12:05:03 +09:00
commit 514b209a5a
203 changed files with 29494 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';
}
+152
View File
@@ -0,0 +1,152 @@
import { t } from '../i18n.js';
/**
* help_modal.js - Advanced Tabbed Markdown Guide.
* Uses marked.js for high-fidelity rendering.
*/
export async function showHelpModal() {
const modalContainer = document.getElementById('modal-container');
if (!modalContainer) return;
const existing = document.getElementById('help-modal');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'help-modal';
overlay.className = 'modal-overlay animate-fade-in active';
overlay.innerHTML = `
<div class="modal-content glass-panel animate-scale-up" style="max-width: 900px; width: 95%; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 85vh; max-height: 1000px;">
<!-- Header -->
<div class="modal-header" style="padding: 20px 24px; border-bottom: 1px solid var(--panel-border); background: var(--item-bg); display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 16px;">
<div style="width: 42px; height: 42px; background: linear-gradient(135deg, #3b82f6, #6366f1); border-radius: 10px; display: flex; align-items: center; justify-content: center; color: white; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);">
<span style="font-weight: 900; font-size: 18px;">dN</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 900; color: var(--text-color); margin: 0;">${t('help_center') || 'drawNET Help Center'}</h2>
<p style="font-size: 11px; color: var(--sub-text); margin: 2px 0 0 0;">Engineering Systems Guide & Documentation</p>
</div>
</div>
<button class="modal-close" id="close-help" style="background: none; border: none; font-size: 24px; color: var(--sub-text); cursor: pointer; padding: 4px;">&times;</button>
</div>
<!-- Tab Navigation Bar -->
<div id="help-tabs" style="display: flex; background: var(--item-bg); border-bottom: 1px solid var(--panel-border); padding: 0 12px; overflow-x: auto; gap: 4px; scrollbar-width: none;">
<div style="padding: 15px 20px; color: var(--sub-text); font-size: 12px;">Initializing tabs...</div>
</div>
<!-- Content Area -->
<div id="help-modal-body" class="modal-body" style="padding: 32px 48px; overflow-y: auto; flex: 1; scrollbar-width: thin; background: rgba(var(--bg-rgb), 0.2);">
<div class="loading-state" style="text-align: center; padding: 60px; color: var(--sub-text);">
<i class="fas fa-circle-notch fa-spin"></i> Initializing Help System...
</div>
</div>
<!-- Footer -->
<div class="modal-footer" style="padding: 16px 24px; background: var(--item-bg); text-align: right; border-top: 1px solid var(--panel-border);">
<button class="btn-primary" id="help-confirm" style="padding: 8px 32px; border-radius: 8px; background: var(--accent-color); color: white; border: none; cursor: pointer; font-weight: 700; transition: all 0.2s;">
${t('close_btn') || 'Close Guide'}
</button>
</div>
</div>
`;
modalContainer.appendChild(overlay);
const tabsBar = overlay.querySelector('#help-tabs');
const contentArea = overlay.querySelector('#help-modal-body');
async function loadTabContent(fileId, tabElement) {
// Update tab styles
tabsBar.querySelectorAll('.help-tab').forEach(t => {
t.style.borderBottom = '2px solid transparent';
t.style.color = 'var(--sub-text)';
t.style.background = 'transparent';
});
tabElement.style.borderBottom = '2px solid var(--accent-color)';
tabElement.style.color = 'var(--accent-color)';
tabElement.style.background = 'rgba(59, 130, 246, 0.05)';
contentArea.innerHTML = `
<div style="text-align: center; padding: 60px; color: var(--sub-text);">
<i class="fas fa-circle-notch fa-spin"></i> Loading ${fileId}...
</div>
`;
try {
const response = await fetch(`/manual/${fileId}`);
if (!response.ok) throw new Error('File not found');
let markdown = await response.text();
// Image Path Pre-processing: Fix relative paths for marked.js
markdown = markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, src) => {
const finalSrc = (src.startsWith('http') || src.startsWith('/')) ? src : `/manual/${src}`;
return `![${alt}](${finalSrc})`;
});
// Use marked library
const htmlContent = window.marked.parse(markdown);
contentArea.innerHTML = `<div class="markdown-body animate-fade-in">${htmlContent}</div>`;
contentArea.scrollTop = 0;
} catch (err) {
contentArea.innerHTML = `<div style="color: #ef4444; padding: 20px; text-align: center;">Error: ${err.message}</div>`;
}
}
// Load Help File List
try {
const response = await fetch('/api/help/list');
const helpFiles = await response.json();
tabsBar.innerHTML = ''; // Clear "Initializing"
helpFiles.forEach((file, index) => {
const tab = document.createElement('div');
tab.className = 'help-tab';
tab.dataset.id = file.id;
tab.innerText = file.title;
tab.style.cssText = `
padding: 14px 20px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
color: var(--sub-text);
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: all 0.2s;
`;
tab.addEventListener('mouseenter', () => {
if (tab.style.borderBottomColor === 'transparent') {
tab.style.color = 'var(--text-color)';
}
});
tab.addEventListener('mouseleave', () => {
if (tab.style.borderBottomColor === 'transparent') {
tab.style.color = 'var(--sub-text)';
}
});
tab.onclick = () => loadTabContent(file.id, tab);
tabsBar.appendChild(tab);
// Auto-load first tab
if (index === 0) loadTabContent(file.id, tab);
});
} catch (err) {
tabsBar.innerHTML = `<div style="padding: 15px; color: #ef4444;">Failed to load help list.</div>`;
}
const closeModal = () => {
overlay.classList.replace('animate-fade-in', 'animate-fade-out');
overlay.querySelector('.modal-content').classList.replace('animate-scale-up', 'animate-scale-down');
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector('#close-help').onclick = closeModal;
overlay.querySelector('#help-confirm').onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
}
+393
View File
@@ -0,0 +1,393 @@
import { state } from '../state.js';
import { applyLayerFilters } from '../graph/layers.js';
import { makeDraggable, showConfirmModal, showToast } from './utils.js';
import { markDirty } from '../persistence.js';
import { t } from '../i18n.js';
/**
* Updates the floating active layer indicator in the top-right corner
*/
export function updateActiveLayerIndicator() {
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const indicator = document.getElementById('current-layer-name');
const badge = document.getElementById('active-layer-indicator');
if (activeLayer && indicator && badge) {
indicator.innerText = activeLayer.name.toUpperCase();
indicator.style.color = activeLayer.color || '#3b82f6';
// Add a small pop animation
badge.classList.remove('badge-pop');
void badge.offsetWidth; // trigger reflow
badge.classList.add('badge-pop');
}
}
/**
* initLayerPanel - Initializes the Layer Management Panel
*/
export function initLayerPanel() {
const container = document.getElementById('layer-panel');
if (!container) return;
renderLayerPanel(container);
updateActiveLayerIndicator(); // Sync on load
// Refresh UI on project load/recovery
if (state.graph) {
state.graph.on('project:restored', () => {
renderLayerPanel(container);
applyLayerFilters();
updateActiveLayerIndicator();
});
}
}
/**
* toggleLayerPanel - Shows/Hides the layer panel
*/
export function toggleLayerPanel() {
const container = document.getElementById('layer-panel');
if (!container) return;
if (container.style.display === 'none' || container.style.display === '') {
container.style.display = 'flex';
renderLayerPanel(container); // Refresh state
} else {
container.style.display = 'none';
}
}
/**
* renderLayerPanel - Renders the internal content of the Layer Panel
*/
export function renderLayerPanel(container) {
container.innerHTML = `
<div class="panel-header">
<i class="fas fa-layer-group"></i> <span>Layers</span>
<button id="add-layer-btn" title="Add Layer"><i class="fas fa-plus"></i></button>
</div>
<div id="layer-list" class="layer-list">
${state.layers.map(layer => `
<div class="layer-item ${state.activeLayerId === layer.id ? 'active' : ''}" data-id="${layer.id}" draggable="true">
<i class="fas fa-grip-vertical drag-handle"></i>
<div class="layer-type-toggle" data-id="${layer.id}" title="${layer.type === 'logical' ? 'Logical (Edge Only)' : 'Standard (All)'}">
<i class="fas ${layer.type === 'logical' ? 'fa-project-diagram' : 'fa-desktop'}" style="color: ${layer.type === 'logical' ? '#10b981' : '#64748b'}; font-size: 11px;"></i>
</div>
<div class="layer-color" style="background: ${layer.color}"></div>
<span class="layer-name" data-id="${layer.id}">${layer.name}</span>
<div class="layer-actions">
<button class="toggle-lock" data-id="${layer.id}" title="${layer.locked ? 'Unlock Layer' : 'Lock Layer'}">
<i class="fas ${layer.locked ? 'fa-lock' : 'fa-lock-open'}" style="color: ${layer.locked ? '#ef4444' : '#64748b'}; font-size: 11px;"></i>
</button>
<button class="rename-layer" data-id="${layer.id}" title="Rename Layer" ${layer.locked ? 'disabled style="opacity:0.3; cursor:not-allowed;"' : ''}>
<i class="fas fa-pen" style="font-size: 10px;"></i>
</button>
<button class="toggle-vis" data-id="${layer.id}">
<i class="fas ${layer.visible ? 'fa-eye' : 'fa-eye-slash'}"></i>
</button>
</div>
</div>
`).join('')}
</div>
<div class="layer-footer">
<div class="opacity-control">
<i class="fas fa-ghost" title="Inactive Layer Opacity"></i>
<input type="range" id="layer-opacity-slider" min="0" max="1" step="0.1" value="${state.inactiveLayerOpacity}">
<span id="opacity-val">${Math.round(state.inactiveLayerOpacity * 100)}%</span>
</div>
<div id="layer-trash" class="layer-trash" title="Drag layer here to delete">
<i class="fas fa-trash-alt"></i>
<span>Discard Layer</span>
</div>
</div>
`;
// Drag and Drop reordering logic
const layerList = container.querySelector('#layer-list');
if (layerList) {
layerList.addEventListener('dragstart', (e) => {
const item = e.target.closest('.layer-item');
if (item) {
e.dataTransfer.setData('text/plain', item.dataset.id);
item.classList.add('dragging');
}
});
layerList.addEventListener('dragend', (e) => {
const item = e.target.closest('.layer-item');
if (item) item.classList.remove('dragging');
});
layerList.addEventListener('dragover', (e) => {
e.preventDefault();
const draggingItem = layerList.querySelector('.dragging');
const targetItem = e.target.closest('.layer-item');
if (targetItem && targetItem !== draggingItem) {
const rect = targetItem.getBoundingClientRect();
const next = (e.clientY - rect.top) > (rect.height / 2);
layerList.insertBefore(draggingItem, next ? targetItem.nextSibling : targetItem);
}
});
layerList.addEventListener('drop', (e) => {
e.preventDefault();
const newOrderIds = Array.from(layerList.querySelectorAll('.layer-item')).map(el => el.dataset.id);
const newLayers = newOrderIds.map(id => state.layers.find(l => l.id === id));
state.layers = newLayers;
markDirty();
applyLayerFilters();
renderLayerPanel(container);
});
}
// Trash Drop Handling
const trash = container.querySelector('#layer-trash');
if (trash) {
trash.ondragover = (e) => {
e.preventDefault();
trash.classList.add('drag-over');
};
trash.ondragleave = () => trash.classList.remove('drag-over');
trash.ondrop = (e) => {
e.preventDefault();
trash.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) deleteLayer(id);
};
}
// Events
const slider = document.getElementById('layer-opacity-slider');
const opacityVal = document.getElementById('opacity-val');
if (slider) {
slider.oninput = (e) => {
const val = parseFloat(e.target.value);
state.inactiveLayerOpacity = val;
if (opacityVal) opacityVal.innerText = `${Math.round(val * 100)}%`;
applyLayerFilters();
};
}
const startRename = (id, nameSpan) => {
const layer = state.layers.find(l => l.id === id);
if (!layer) return;
if (layer.locked) {
showToast(t('err_layer_locked'), 'warning');
return;
}
const input = document.createElement('input');
input.type = 'text';
input.className = 'layer-rename-input';
input.value = layer.name;
nameSpan.replaceWith(input);
input.focus();
input.select();
const finishRename = () => {
const newName = input.value.trim();
if (newName && newName !== layer.name) {
layer.name = newName;
markDirty();
updateActiveLayerIndicator();
}
renderLayerPanel(container);
};
input.onblur = finishRename;
input.onkeydown = (e) => {
if (e.key === 'Enter') finishRename();
if (e.key === 'Escape') renderLayerPanel(container);
};
};
container.querySelectorAll('.layer-name').forEach(nameSpan => {
nameSpan.ondblclick = (e) => {
e.stopPropagation();
startRename(nameSpan.dataset.id, nameSpan);
};
});
container.querySelectorAll('.rename-layer').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const nameSpan = container.querySelector(`.layer-name[data-id="${id}"]`);
if (nameSpan) startRename(id, nameSpan);
};
});
container.querySelectorAll('.layer-item').forEach(item => {
item.onclick = (e) => {
if (e.target.tagName === 'INPUT' || e.target.closest('.layer-actions')) return;
const id = item.dataset.id;
const layer = state.layers.find(l => l.id === id);
state.activeLayerId = id;
renderLayerPanel(container);
updateActiveLayerIndicator();
applyLayerFilters();
};
});
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
layer.visible = !layer.visible;
renderLayerPanel(container);
applyLayerFilters();
}
};
});
container.querySelectorAll('.toggle-lock').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
layer.locked = !layer.locked;
markDirty();
renderLayerPanel(container);
}
};
});
container.querySelectorAll('.layer-type-toggle').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
// Clear nudge if active
btn.classList.remove('pulse-nudge');
const tooltip = btn.querySelector('.nudge-tooltip');
if (tooltip) tooltip.remove();
layer.type = (layer.type === 'logical') ? 'standard' : 'logical';
markDirty();
renderLayerPanel(container);
updateActiveLayerIndicator();
}
};
});
const addBtn = document.getElementById('add-layer-btn');
if (addBtn) {
addBtn.onclick = () => {
const nextIndex = state.layers.length + 1;
const newId = `l${Date.now()}`;
state.layers.push({
id: newId,
name: `Layer ${nextIndex}`,
visible: true,
locked: false,
type: 'standard', // Explicitly standard
color: `#${Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')}`
});
state.activeLayerId = newId;
renderLayerPanel(container);
updateActiveLayerIndicator();
applyLayerFilters();
// Apply Nudge & Guide Toast
setTimeout(() => {
const newToggle = container.querySelector(`.layer-type-toggle[data-id="${newId}"]`);
if (newToggle) {
newToggle.classList.add('pulse-nudge');
// Add mini tooltip
const tooltip = document.createElement('div');
tooltip.className = 'nudge-tooltip';
tooltip.innerText = t('nudge_logical_hint');
newToggle.appendChild(tooltip);
// Show breadcrumb toast
showToast(t('nudge_new_layer'), 'info', 4000);
// Auto-cleanup nudge
setTimeout(() => {
newToggle.classList.remove('pulse-nudge');
tooltip.style.animation = 'tooltipFadeOut 0.4s ease-in forwards';
setTimeout(() => tooltip.remove(), 400);
}, 5000);
}
}, 100);
};
}
// Delete Logic
function deleteLayer(id) {
if (state.layers.length <= 1) {
showToast(t('err_last_layer'), 'warning');
return;
}
const layer = state.layers.find(l => l.id === id);
if (!layer) return;
if (layer.locked) {
showToast(t('err_layer_locked'), 'warning');
return;
}
const cellsOnLayer = state.graph ? state.graph.getCells().filter(cell => {
const data = cell.getData() || {};
return data.layerId === id;
}) : [];
const finalizeDeletion = () => {
if (state.graph) {
state.graph.removeCells(cellsOnLayer);
}
state.layers = state.layers.filter(l => l.id !== id);
if (state.activeLayerId === id) {
state.activeLayerId = state.layers[0].id;
}
markDirty();
applyLayerFilters();
renderLayerPanel(container);
updateActiveLayerIndicator();
};
if (cellsOnLayer.length > 0) {
// Phase 1: Object count warning
showConfirmModal({
title: t('warning') || 'Warning',
message: t('layer_contains_objects').replace('{name}', layer.name).replace('{count}', cellsOnLayer.length),
onConfirm: () => {
// Phase 2: IRREVERSIBLE final warning
setTimeout(() => {
showConfirmModal({
title: t('confirm_delete_layer_final')?.split(':')[0] || 'Final Confirmation',
message: t('confirm_delete_layer_final').replace('{name}', layer.name),
onConfirm: finalizeDeletion
});
}, 300); // Small delay for smooth transition
}
});
} else {
// Clean layer: Single confirm
showConfirmModal({
title: t('remove'),
message: t('confirm_delete_layer').replace('{name}', layer.name),
onConfirm: finalizeDeletion
});
}
}
// Keyboard selection handling (optional: could keep for activating layers, but currently not needed as click handles it)
container.querySelectorAll('.layer-item').forEach(item => {
item.setAttribute('tabindex', '0'); // Make focusable for accessibility
});
// Re-bind draggability after the structural render
makeDraggable(container, '.panel-header');
}
+31
View File
@@ -0,0 +1,31 @@
import { state } from '../state.js';
export function initSidebar() {
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
const footer = document.querySelector('.sidebar-footer');
if (sidebarToggle && sidebar) {
const toggleSidebar = () => {
sidebar.classList.toggle('collapsed');
if (footer) {
footer.style.display = sidebar.classList.contains('collapsed') ? 'none' : 'block';
}
setTimeout(() => {
if (state.graph) state.graph.zoomToFit({ padding: 50 });
}, 350);
};
sidebarToggle.addEventListener('click', toggleSidebar);
const searchIcon = document.querySelector('.search-container i');
if (searchIcon) {
searchIcon.addEventListener('click', () => {
if (sidebar.classList.contains('collapsed')) {
toggleSidebar();
document.getElementById('asset-search').focus();
}
});
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { logger } from '../utils/logger.js';
/**
* handleNewProject - Clears the current graph and resets project meta
*/
export function handleNewProject() {
if (!state.graph) return;
if (confirm(t('confirm_new_project') || "Are you sure you want to start a new project?")) {
state.graph.clearCells();
// Reset Project Metadata in UI
const projectTitle = document.getElementById('project-title');
if (projectTitle) projectTitle.innerText = "Untitled_Project";
// Reset state
state.layers = [
{ id: 'l1', name: 'Main Layer', visible: true, locked: false, color: '#3b82f6' }
];
state.activeLayerId = 'l1';
state.selectionOrder = [];
// Clear Persistence (Optional: could trigger immediate save of empty state)
import('../persistence.js').then(m => {
if (m.clearLastState) m.clearLastState();
m.markDirty();
});
logger.high("New project created. Canvas cleared.");
}
}
export function initSystemMenu() {
const systemBtn = document.getElementById('system-menu-btn');
const systemFlyout = document.getElementById('system-flyout');
const studioBtn = document.getElementById('studio-btn');
const newProjectBtn = document.getElementById('new-project');
if (systemBtn && systemFlyout) {
// Toggle Flyout
systemBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = systemFlyout.style.display === 'flex';
systemFlyout.style.display = isVisible ? 'none' : 'flex';
systemBtn.classList.toggle('active', !isVisible);
});
// "New Project" Handler
if (newProjectBtn) {
newProjectBtn.addEventListener('click', () => {
handleNewProject();
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
});
}
// "Object Studio" Handler
if (studioBtn) {
studioBtn.addEventListener('click', () => {
window.open('/studio', '_blank');
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
});
}
// Close on outside click
document.addEventListener('click', (e) => {
if (!systemFlyout.contains(e.target) && e.target !== systemBtn) {
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
}
});
}
}
// initUndoRedo is removed from UI as requested (Hotkeys still work via actionHandlers)
+105
View File
@@ -0,0 +1,105 @@
/**
* toast.js - Modularized Notification System for drawNET
*/
let toastContainer = null;
/**
* Ensures a singleton container for toasts exists in the DOM.
*/
function ensureContainer() {
if (toastContainer) return toastContainer;
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.style.cssText = `
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 10000;
pointer-events: none;
`;
document.body.appendChild(toastContainer);
return toastContainer;
}
/**
* showToast - Displays a stylish, non-blocking notification
* @param {string} message - Message to display
* @param {string} type - 'info', 'success', 'warning', 'error'
* @param {number} duration - MS to stay visible (default 3500)
*/
export function showToast(message, type = 'info', duration = 3500) {
const container = ensureContainer();
const toast = document.createElement('div');
toast.className = `toast-item ${type}`;
const colors = {
info: '#3b82f6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
};
const icons = {
info: 'fa-info-circle',
success: 'fa-check-circle',
warning: 'fa-exclamation-triangle',
error: 'fa-times-circle'
};
toast.innerHTML = `
<i class="fas ${icons[type]}"></i>
<span>${message}</span>
`;
toast.style.cssText = `
background: rgba(15, 23, 42, 0.9);
color: white;
padding: 12px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 12px;
border-left: 4px solid ${colors[type]};
backdrop-filter: blur(8px);
pointer-events: auto;
animation: toastIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
min-width: 280px;
`;
// Add CSS Keyframes if not present
if (!document.getElementById('toast-styles')) {
const style = document.createElement('style');
style.id = 'toast-styles';
style.innerHTML = `
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px) scale(0.95); }
}
`;
document.head.appendChild(style);
}
container.appendChild(toast);
const dismiss = () => {
toast.style.animation = 'toastOut 0.3s ease-in forwards';
setTimeout(() => toast.remove(), 300);
};
setTimeout(dismiss, duration);
toast.onclick = dismiss;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* ui/utils.js - Shared UI utilities
*/
/**
* makeDraggable - Helper to make an element draggable by its header or itself
*/
export function makeDraggable(el, headerSelector) {
const header = headerSelector ? el.querySelector(headerSelector) : el;
if (!header) return;
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
header.onmousedown = dragMouseDown;
header.style.cursor = 'move';
function dragMouseDown(e) {
// Don't prevent default on inputs, buttons, or sliders inside
if (['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return;
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
el.style.top = (el.offsetTop - pos2) + "px";
el.style.left = (el.offsetLeft - pos1) + "px";
el.style.bottom = 'auto'; // Break fixed constraints
el.style.right = 'auto';
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
}
}
/**
* showConfirmModal - Displays a professional confirmation modal
* @param {Object} options { title, message, onConfirm, showDontAskAgain }
*/
export function showConfirmModal({ title, message, onConfirm, onCancel, showDontAskAgain = false, checkboxKey = '' }) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay glass-panel active';
overlay.style.zIndex = '10000';
const modal = document.createElement('div');
modal.className = 'modal-content animate-up';
modal.style.width = '320px';
modal.style.padding = '0';
modal.innerHTML = `
<div class="modal-header" style="padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<h2 style="font-size: 14px; margin:0; color: #f1f5f9;">${title}</h2>
</div>
<div class="modal-body" style="padding: 24px 20px;">
<p style="font-size: 13px; color: #94a3b8; margin: 0; line-height: 1.6;">${message}</p>
${showDontAskAgain ? `
<label style="display:flex; align-items:center; gap:8px; margin-top:20px; cursor:pointer;">
<input type="checkbox" id="dont-ask-again" style="width:14px; height:14px; accent-color:#3b82f6;">
<span style="font-size:11px; color:#64748b;">Don't show this again</span>
</label>
` : ''}
</div>
<div class="modal-footer" style="padding: 16px 20px; display:flex; gap:10px; background: rgba(0,0,0,0.1);">
<button id="modal-cancel" class="btn-secondary" style="flex:1; height:36px; font-size:12px;">CANCEL</button>
<button id="modal-confirm" class="btn-primary" style="flex:1; height:36px; font-size:12px; background:#ef4444; border-color:#ef4444;">DISCONNECT</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => overlay.remove();
modal.querySelector('#modal-cancel').onclick = () => {
close();
if (onCancel) onCancel();
};
modal.querySelector('#modal-confirm').onclick = () => {
const dontAsk = modal.querySelector('#dont-ask-again')?.checked;
if (dontAsk && checkboxKey) {
import('../settings/store.js').then(m => m.updateSetting(checkboxKey, false));
}
close();
if (onConfirm) onConfirm();
};
overlay.onclick = (e) => { if (e.target === overlay) close(); };
}
import { showToast as coreShowToast } from './toast.js';
/**
* showToast - Compatibility wrapper for modularized toast
*/
export function showToast(message, type = 'info', duration = 3500) {
coreShowToast(message, type, duration);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* window.js - Reusable Draggable Floating Window System
*/
export class FloatingWindow {
constructor(id, title, options = {}) {
this.id = id;
this.title = title;
this.options = {
width: options.width || 400,
height: options.height || 300,
x: options.x || (window.innerWidth / 2 - (options.width || 400) / 2),
y: options.y || (window.innerHeight / 2 - (options.height || 300) / 2),
resizable: options.resizable !== false,
...options
};
this.element = null;
this.isDragging = false;
this.dragOffset = { x: 0, y: 0 };
}
render(contentHtml) {
if (this.element) return this.element;
this.element = document.createElement('div');
this.element.id = `win-${this.id}`;
this.element.className = 'floating-window';
this.element.style.width = `${this.options.width}px`;
this.element.style.height = `${this.options.height}px`;
this.element.style.left = `${this.options.x}px`;
this.element.style.top = `${this.options.y}px`;
this.element.style.zIndex = '1000';
this.element.innerHTML = `
<div class="win-header">
<div class="win-title"><i class="${this.options.icon || 'fas fa-window-maximize'}"></i> ${this.title}</div>
<div class="win-controls">
<button class="win-btn win-minimize"><i class="fas fa-minus"></i></button>
<button class="win-btn win-close"><i class="fas fa-times"></i></button>
</div>
</div>
<div class="win-body">
${contentHtml}
</div>
`;
document.body.appendChild(this.element);
this.initEvents();
return this.element;
}
initEvents() {
const header = this.element.querySelector('.win-header');
const closeBtn = this.element.querySelector('.win-close');
const minBtn = this.element.querySelector('.win-minimize');
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.win-controls')) return;
this.isDragging = true;
this.dragOffset.x = e.clientX - this.element.offsetLeft;
this.dragOffset.y = e.clientY - this.element.offsetTop;
this.bringToFront();
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
this.element.style.left = `${e.clientX - this.dragOffset.x}px`;
this.element.style.top = `${e.clientY - this.dragOffset.y}px`;
});
window.addEventListener('mouseup', () => {
this.isDragging = false;
});
closeBtn.addEventListener('click', () => this.destroy());
minBtn.addEventListener('click', () => {
this.element.classList.toggle('minimized');
if (this.element.classList.contains('minimized')) {
this.element.style.height = '48px';
} else {
this.element.style.height = `${this.options.height}px`;
}
});
this.element.addEventListener('mousedown', () => this.bringToFront());
}
bringToFront() {
const allWins = document.querySelectorAll('.floating-window');
let maxZ = 1000;
allWins.forEach(win => {
const z = parseInt(win.style.zIndex);
if (z > maxZ) maxZ = z;
});
this.element.style.zIndex = maxZ + 1;
}
destroy() {
if (this.element) {
this.element.remove();
this.element = null;
}
if (this.onClose) this.onClose();
}
}