mirror of
https://github.com/sotam0316/drawNET_test.git
synced 2026-04-25 03:58:38 +09:00
static 폴더 및 하위 파일 업로드
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';
|
||||
}
|
||||
@@ -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;">×</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 ``;
|
||||
});
|
||||
|
||||
// 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(); };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user