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

This commit is contained in:
leeyj
2026-03-22 22:37:24 +09:00
commit 5cea93e317
192 changed files with 14449 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import { initAssets } from './modules/assets/index.js';
import { initGraph } from './modules/graph.js';
import { initUIToggles } from './modules/ui.js';
import { initSettings } from './modules/settings/ui.js';
import { applySavedSettings, applySavedTheme } from './modules/settings/store.js';
import { initHotkeys } from './modules/hotkeys/index.js';
import { initGlobalSearch } from './modules/graph/search.js';
import { initInventory } from './modules/graph/analysis.js';
import { initLayerPanel } from './modules/ui/layer_panel.js';
import { applyLayerFilters } from './modules/graph/layers.js';
import { initI18n } from './modules/i18n.js';
import { initPropertiesSidebar } from './modules/properties_sidebar/index.js';
import { initPersistence } from './modules/persistence.js';
import { initGraphIO } from './modules/graph/io/index.js';
import { initContextMenu } from './modules/ui/context_menu/index.js';
import { initLicense } from './modules/ui/license.js';
import { logger } from './modules/utils/logger.js';
document.addEventListener('DOMContentLoaded', async () => {
// 0. Initialize i18n (Language first)
await initI18n();
// 1. Apply Saved Theme immediately (prevent flash)
applySavedTheme();
// 2. Load Assets & Sidebar
await initAssets();
// 2.5 Load Global Config
try {
const configRes = await fetch('/api/config');
if (configRes.ok) {
const configData = await configRes.json();
const { state } = await import('./modules/state.js');
state.appConfig = configData;
logger.info("Global configuration loaded from server.");
}
} catch (err) {
logger.high("Failed to load global config, using defaults.");
}
// 3. Initialize X6 Graph
initGraph();
// 4. Initialize UI Toggles
initUIToggles();
// 5. Initialize Settings Modal Logic
initSettings();
// 6. Apply Saved Visual Settings (Grid, Size)
applySavedSettings();
// 7. Initialize Keyboard Hotkeys
initHotkeys();
// 8. Initialize UI Components & Persistence
initPropertiesSidebar();
initContextMenu();
initGraphIO();
initGlobalSearch();
initInventory();
initLayerPanel();
applyLayerFilters();
initPersistence();
initLicense();
// Help Button
const helpBtn = document.getElementById('help-btn');
if (helpBtn) {
helpBtn.addEventListener('click', async () => {
const { showHelpModal } = await import('./modules/ui/help_modal.js');
showHelpModal();
});
}
logger.high("drawNET Premium initialized successfully.");
});
+17
View File
@@ -0,0 +1,17 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* fetchAssets - Fetches the complete asset and pack list from the server
*/
export async function fetchAssets() {
try {
const response = await fetch('/assets');
const data = await response.json();
state.assetsData = data.assets || [];
return data;
} catch (e) {
logger.critical("Failed to fetch assets:", e);
return { assets: [], packs: [] };
}
}
+110
View File
@@ -0,0 +1,110 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
export const FIXED_ASSETS = [
{ id: 'fixed-group', type: 'group', label: 'Group Container', icon: 'group.png', is_img: true },
{ id: 'fixed-rect', type: 'rect', label: 'Rectangle', icon: 'far fa-square', is_img: false },
{ id: 'fixed-rounded-rect', type: 'rounded-rect', label: 'Rounded Rectangle', icon: 'fas fa-vector-square', is_img: false },
{ id: 'fixed-circle', type: 'circle', label: 'Circle', icon: 'far fa-circle', is_img: false },
{ id: 'fixed-text', type: 'text-box', label: 'Text Box', icon: 'fas fa-font', is_img: false },
{ id: 'fixed-label', type: 'label', label: 'Label', icon: 'fas fa-tag', is_img: false },
{ id: 'fixed-blank', type: 'blank', label: 'Blank Point (Invisible)', icon: 'fas fa-crosshairs', is_img: false, extra_style: 'opacity: 0.5;' },
{ id: 'fixed-dot', type: 'dot', label: 'Dot (Small visible point)', icon: 'fas fa-circle', is_img: false, extra_style: 'font-size: 6px;' },
{ id: 'fixed-ellipse', type: 'ellipse', label: 'Ellipse', icon: 'fas fa-egg', is_img: false, extra_style: 'transform: rotate(-45deg);' },
{ id: 'fixed-polyline', type: 'polyline', label: 'Polyline', icon: 'fas fa-chart-line', is_img: false },
{ id: 'fixed-browser', type: 'browser', label: 'Browser', icon: 'fas fa-window-maximize', is_img: false },
{ id: 'fixed-user', type: 'user', label: 'User', icon: 'fas fa-user', is_img: false },
{ id: 'fixed-admin', type: 'admin', label: 'Admin', icon: 'fas fa-user-shield', is_img: false },
{ id: 'fixed-triangle', type: 'triangle', label: 'Triangle', icon: 'fas fa-caret-up', is_img: false, extra_style: 'font-size: 22px; margin-top: -2px;' },
{ id: 'fixed-diamond', type: 'diamond', label: 'Diamond', icon: 'fas fa-square', is_img: false, extra_style: 'transform: rotate(45deg) scale(0.8);' },
{ id: 'fixed-parallelogram', type: 'parallelogram', label: 'Parallelogram', icon: 'fas fa-square', is_img: false, extra_style: 'transform: skewX(-20deg) scaleX(1.2);' },
{ id: 'fixed-cylinder', type: 'cylinder', label: 'Cylinder', icon: 'fas fa-database', is_img: false },
{ id: 'fixed-document', type: 'document', label: 'Document', icon: 'fas fa-file-alt', is_img: false },
{ id: 'fixed-manual-input', type: 'manual-input', label: 'Manual Input', icon: 'fas fa-keyboard', is_img: false },
{ id: 'fixed-table', type: 'table', label: 'Table', icon: 'fas fa-table', is_img: false },
{ id: 'fixed-rack', type: 'rack', label: 'Rack', icon: 'fas fa-columns', is_img: false },
{ id: 'fixed-hourglass', type: 'hourglass', label: 'Hourglass', icon: 'fas fa-hourglass-half', is_img: false },
{ id: 'fixed-arrow-up', type: 'arrow-up', label: 'Arrow Up', icon: 'fas fa-arrow-up', is_img: false },
{ id: 'fixed-arrow-down', type: 'arrow-down', label: 'Arrow Down', icon: 'fas fa-arrow-down', is_img: false },
{ id: 'fixed-arrow-left', type: 'arrow-left', label: 'Arrow Left', icon: 'fas fa-arrow-left', is_img: false },
{ id: 'fixed-arrow-right', type: 'arrow-right', label: 'Arrow Right', icon: 'fas fa-arrow-right', is_img: false },
{ id: 'fixed-rich-card', type: 'rich-card', label: t('rich_card') || 'Rich Text Card', icon: 'fas fa-address-card', is_img: false }
];
export function renderFixedSection(container) {
const fixedSection = document.createElement('div');
fixedSection.className = 'asset-category fixed-category';
const title = t('fixed_objects') || 'Fixed Objects';
fixedSection.innerHTML = `
<div class="category-header">
<span data-i18n="fixed_objects">${title}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="category-collapsed-icon" title="${title}">
<i class="fas fa-layer-group" style="font-size: 20px; color: #64748b;"></i>
</div>
<div class="category-content">
<div class="asset-grid"></div>
</div>
<div class="category-divider" style="height: 1px; background: rgba(255,255,255,0.1); margin: 15px 10px;"></div>
`;
// Toggle Logic
const header = fixedSection.querySelector('.category-header');
header.addEventListener('click', () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
fixedSection.classList.toggle('active');
}
});
// Handle Sidebar Collapsed State (Flyout)
const collapsedIcon = fixedSection.querySelector('.category-collapsed-icon');
collapsedIcon.addEventListener('click', (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
// Since we don't want to import renderFlyout here to avoid circular dependencies
// we'll dispatch a custom event or check if it's available globally (though not recommended)
// or just let assets.js handle it if we restructure.
// For now, let's trigger a click on a hidden flyout handler or use state.
const event = new CustomEvent('render-fixed-flyout', {
detail: {
container: fixedSection,
assets: FIXED_ASSETS,
meta: { id: 'fixed_objects', label: title }
}
});
document.dispatchEvent(event);
}
});
const grid = fixedSection.querySelector('.asset-grid');
FIXED_ASSETS.forEach(asset => {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type;
item.dataset.assetId = asset.id;
item.title = asset.label;
if (asset.is_img) {
item.innerHTML = `<img src="/static/assets/${asset.icon}" width="22" height="22" loading="lazy">`;
} else {
item.innerHTML = `<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', asset.id);
state.assetMap[asset.id] = {
id: asset.id,
type: asset.type,
path: asset.is_img ? asset.icon : '',
label: asset.label
};
});
grid.appendChild(item);
});
container.appendChild(fixedSection);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { fetchAssets } from './api.js';
import { renderLibrary, createAssetElement, renderFlyout } from './renderer.js';
import { renderPackSelector } from './selector.js';
import { initSearch } from './search.js';
import { renderFixedSection } from './fixed_objects.js';
import { logger } from '../utils/logger.js';
/**
* initAssets - Main initialization for the asset library sidebar
*/
export async function initAssets() {
try {
const data = await fetchAssets();
const library = document.querySelector('.asset-library');
if (!library) return;
// 1. Initial Header & Global UI
library.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin: 24px 16px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px;">
<h3 style="margin:0; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--sub-text); font-weight: 800;">
${t('asset_library') || 'Asset Library'}
</h3>
<button id="manage-packs-btn" style="background:none; border:none; color: var(--sub-text); cursor:pointer; font-size: 12px; transition: color 0.2s;" title="Manage Packages">
<i class="fas fa-filter"></i>
</button>
</div>
`;
// 2. Setup Manage Packs Button
document.getElementById('manage-packs-btn').onclick = (e) => {
e.stopPropagation();
renderPackSelector(data.packs || []);
};
// 3. Initialize selection if empty
if (state.selectedPackIds.length === 0 && data.packs && data.packs.length > 0) {
state.selectedPackIds = data.packs.map(p => p.id);
localStorage.setItem('selectedPackIds', JSON.stringify(state.selectedPackIds));
}
// 4. Render Sections
renderFixedSection(library);
renderLibrary({ ...data, state }, library, renderPackSelector);
// 5. Handle Flyouts for Fixed/Custom Sections via Event
document.addEventListener('render-fixed-flyout', (e) => {
const { container, assets, meta } = e.detail;
renderFlyout(container, assets, meta);
});
initSearch();
} catch (e) {
logger.critical("Asset Initialization Error:", e);
}
}
export { createAssetElement, renderFlyout, renderPackSelector };
+197
View File
@@ -0,0 +1,197 @@
import { t } from '../i18n.js';
import { DEFAULTS } from '../constants.js';
/**
* createAssetElement - Creates a single asset item DOM element
* @param {Object} asset
* @returns {HTMLElement}
*/
export function createAssetElement(asset, compositeId = null) {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type || asset.id;
item.dataset.assetId = compositeId || asset.id;
item.title = asset.label;
// Support legacy 'path', new 'views.icon' or FontAwesome icon class
const isFontAwesome = asset.is_img === false || (!asset.path && !asset.views && asset.icon && asset.icon.includes('fa-'));
if (isFontAwesome) {
item.innerHTML = `
<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
} else {
const iconPath = (asset.views && asset.views.icon) ? asset.views.icon : (asset.path || DEFAULTS.DEFAULT_ICON);
item.innerHTML = `
<img src="/static/assets/${iconPath}" width="24" height="24" loading="lazy">
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', item.dataset.assetId);
});
return item;
}
/**
* renderFlyout - Renders the floating category window when sidebar is collapsed
* @param {HTMLElement} container
* @param {Array} assets
* @param {Object} meta
*/
export function renderFlyout(container, assets, meta) {
document.querySelectorAll('.category-flyout').forEach(f => f.remove());
const flyout = document.createElement('div');
flyout.className = 'category-flyout glass-panel active';
const rect = container.getBoundingClientRect();
flyout.style.top = `${Math.max(10, Math.min(window.innerHeight - 400, rect.top))}px`;
flyout.style.left = '90px';
flyout.innerHTML = `
<div class="flyout-header">
<strong>${t(meta.id) || meta.name || meta.label}</strong>
<small>${assets.length} items</small>
</div>
<div class="flyout-content" style="max-height: 400px; overflow-y: auto;"></div>
`;
const content = flyout.querySelector('.flyout-content');
// Group by category for flyout as well
const groups = assets.reduce((acc, a) => {
const cat = a.category || 'Other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(a);
return acc;
}, {});
Object.keys(groups).sort().forEach(catName => {
if (Object.keys(groups).length > 1 || catName !== 'Other') {
const label = document.createElement('div');
label.className = 'asset-group-label';
label.textContent = catName;
content.appendChild(label);
}
const grid = document.createElement('div');
grid.className = 'asset-grid flyout-grid';
groups[catName].forEach(asset => grid.appendChild(createAssetElement(asset)));
content.appendChild(grid);
});
document.body.appendChild(flyout);
const closeFlyout = (e) => {
if (!flyout.contains(e.target)) {
flyout.remove();
document.removeEventListener('click', closeFlyout);
}
};
setTimeout(() => document.addEventListener('click', closeFlyout), 10);
}
/**
* renderLibrary - The main loop that renders the entire asset library
*/
export function renderLibrary(data, library, renderPackSelector) {
const { state } = data; // We expect state to be passed or accessible
const packs = data.packs || [];
const filteredPacks = packs.filter(p => state.selectedPackIds.includes(p.id));
// 1. Clear Library (except fixed categories and header)
const dynamicPacks = library.querySelectorAll('.asset-category:not(.fixed-category)');
dynamicPacks.forEach(p => p.remove());
// 2. Group Custom Assets by Pack -> Category
const packGroups = state.assetsData.reduce((acc, asset) => {
const packId = asset.pack_id || 'legacy';
if (packId !== 'legacy' && !state.selectedPackIds.includes(packId)) return acc;
const category = asset.category || 'Other';
if (!acc[packId]) acc[packId] = {};
if (!acc[packId][category]) acc[packId][category] = [];
acc[packId][category].push(asset);
return acc;
}, {});
// 3. Render each visible Pack
Object.keys(packGroups).forEach((packId, index) => {
const packMeta = packs.find(p => p.id === packId) || { id: packId, name: (packId === 'legacy' ? t('other_assets') : packId) };
const categories = packGroups[packId];
const allAssetsInPack = Object.values(categories).flat();
// Ensure proper path is set for pack-based assets
if (packId !== 'legacy') {
allAssetsInPack.forEach(asset => {
if (asset.views && asset.views.icon) {
const icon = asset.views.icon;
// Only prepend packs/ if not already present
if (!icon.startsWith('packs/')) {
asset.path = `packs/${packId}/${icon}`;
} else {
asset.path = icon;
}
}
});
}
const firstAsset = allAssetsInPack[0] || {};
const iconPath = (firstAsset.views && firstAsset.views.icon) ? firstAsset.views.icon : (firstAsset.path || DEFAULTS.DEFAULT_ICON);
const catContainer = document.createElement('div');
catContainer.className = `asset-category shadow-sm`;
const header = document.createElement('div');
header.className = 'category-header';
header.innerHTML = `<span>${packMeta.name || packMeta.id}</span><i class="fas fa-chevron-down"></i>`;
header.onclick = () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
catContainer.classList.toggle('active');
}
};
const collapsedIcon = document.createElement('div');
collapsedIcon.className = 'category-collapsed-icon';
collapsedIcon.title = packMeta.name || packMeta.id;
collapsedIcon.innerHTML = `<img src="/static/assets/${iconPath}" width="28" height="28">`;
collapsedIcon.onclick = (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
renderFlyout(catContainer, allAssetsInPack, packMeta);
}
};
const content = document.createElement('div');
content.className = 'category-content';
// Render each Category within the Pack
Object.keys(categories).sort().forEach(catName => {
const assets = categories[catName];
if (Object.keys(categories).length > 1 || catName !== 'Other') {
const subHeader = document.createElement('div');
subHeader.className = 'asset-group-label';
subHeader.textContent = catName;
content.appendChild(subHeader);
}
const itemGrid = document.createElement('div');
itemGrid.className = 'asset-grid';
assets.forEach(asset => {
const compositeId = `${packId}|${asset.id}`;
state.assetMap[compositeId] = asset;
itemGrid.appendChild(createAssetElement(asset, compositeId));
});
content.appendChild(itemGrid);
});
catContainer.append(header, collapsedIcon, content);
library.appendChild(catContainer);
});
}
+35
View File
@@ -0,0 +1,35 @@
import { state } from '../state.js';
/**
* initSearch - Initializes the asset search input listener
*/
export function initSearch() {
const searchInput = document.getElementById('asset-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
const categories = document.querySelectorAll('.asset-category');
categories.forEach(cat => {
const items = cat.querySelectorAll('.asset-item');
let hasVisibleItem = false;
items.forEach(item => {
const assetId = item.dataset.assetId;
const asset = state.assetMap[assetId];
if (!asset) return;
const matches = (asset.type && asset.type.toLowerCase().includes(query)) ||
(asset.label && asset.label.toLowerCase().includes(query)) ||
(asset.vendor && asset.vendor.toLowerCase().includes(query));
item.style.display = matches ? 'flex' : 'none';
if (matches) hasVisibleItem = true;
});
cat.style.display = (hasVisibleItem || query === '') ? 'block' : 'none';
if (query !== '' && hasVisibleItem) cat.classList.add('active');
});
});
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
/**
* renderPackSelector - Show a modal to enable/disable asset packages
*/
export function renderPackSelector(packs) {
let modal = document.getElementById('pack-selector-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'pack-selector-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const selectedIds = state.selectedPackIds;
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>${t('manage_packages') || 'Manage Packages'}</h3>
<i class="fas fa-times close-modal"></i>
</div>
<div class="modal-body" style="padding: 15px;">
<p style="font-size: 12px; color: #64748b; margin-bottom: 15px;">Select which asset packages to load in your sidebar.</p>
<div class="pack-list">
${packs.map(p => `
<div class="pack-item" style="display: flex; align-items: center; justify-content: space-between; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-box" style="color: #38bdf8; font-size: 14px;"></i>
<span style="font-size: 13px; font-weight: 600;">${p.name || p.id}</span>
</div>
<input type="checkbox" data-id="${p.id}" ${selectedIds.includes(p.id) ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
</div>
`).join('')}
</div>
</div>
<div class="modal-footer" style="padding: 15px; border-top: 1px solid rgba(255,255,255,0.05); text-align: right;">
<button class="btn-primary apply-packs" style="background: #38bdf8; color: #0f172a; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 700; cursor: pointer;">Apply & Refresh</button>
</div>
</div>
`;
modal.classList.add('active');
// Close handlers
modal.querySelector('.close-modal').onclick = () => modal.classList.remove('active');
modal.onclick = (e) => { if (e.target === modal) modal.classList.remove('active'); };
// Apply handler
modal.querySelector('.apply-packs').onclick = () => {
const checkboxes = modal.querySelectorAll('input[type="checkbox"]');
const newSelectedIds = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.id);
state.selectedPackIds = newSelectedIds;
localStorage.setItem('selectedPackIds', JSON.stringify(newSelectedIds));
// This is a global refresh to re-init everything with new filters
// For a more seamless experience, we could re-call initAssets()
window.location.reload();
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* constants.js - Central repository for magic strings and app configuration
*/
// LocalStorage Keys
export const STORAGE_KEYS = {
SETTINGS: 'drawNET-settings',
THEME: 'drawNET-theme',
AUTOSAVE: 'drawNET_autosave',
LANGUAGE: 'drawNET_lang'
};
// Default Configuration
export const DEFAULTS = {
GRID_SPACING: 20,
GRID_COLOR: '#e2e8f0',
MAJOR_GRID_COLOR: '#cbd5e1',
MAJOR_GRID_INTERVAL: 5,
CANVAS_PRESET: 'A4',
CANVAS_ORIENTATION: 'portrait',
APP_VERSION: '2.0.0-pro',
DEFAULT_ICON: 'router.svg'
};
// UI Toggles & States
export const UI_STATES = {
QUAKE_COLLAPSED_HEIGHT: '40px',
QUAKE_EXPANDED_HEIGHT: '300px'
};
+34
View File
@@ -0,0 +1,34 @@
/**
* graph.js - Main Entry for AntV X6 Graph Module
* Modularized version splitting styles, events, interactions, and IO.
*/
import { state } from './state.js';
import { registerCustomShapes } from './graph/styles.js';
import { initGraphEvents } from './graph/events/index.js';
import { initInteractions } from './graph/interactions/index.js';
import { initGraphIO } from './graph/io/index.js';
import { initPropertiesSidebar } from './properties_sidebar/index.js';
import { initContextMenu } from './ui/context_menu/index.js';
import { getGraphConfig } from './graph/config.js';
import { initGraphPlugins } from './graph/plugins.js';
import { logger } from './utils/logger.js';
export function initGraph() {
const container = document.getElementById('graph-container');
if (!container) return;
// 1. Register Custom Shapes
registerCustomShapes();
// 2. Initialize AntV X6 Graph Instance
state.graph = new X6.Graph(getGraphConfig(container));
// 3. Enable Plugins
initGraphPlugins(state.graph);
// 4. Initialize Sub-modules
initGraphEvents();
initInteractions();
logger.high("AntV X6 Graph initialized with modules.");
}
+139
View File
@@ -0,0 +1,139 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* Alignment and Distribution tools for drawNET (X6 version).
*/
/**
* alignNodes - Aligns selected nodes based on the FIRST selected node
* @param {'top'|'bottom'|'left'|'right'|'middle'|'center'} type
*/
export function alignNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 2) return;
// First selected object is the reference per user request
const reference = selected[0];
const refPos = reference.getPosition();
const refSize = reference.getSize();
selected.slice(1).forEach(node => {
const pos = node.getPosition();
const size = node.getSize();
switch (type) {
case 'top':
node.setPosition(pos.x, refPos.y);
break;
case 'bottom':
node.setPosition(pos.x, refPos.y + refSize.height - size.height);
break;
case 'left':
node.setPosition(refPos.x, pos.y);
break;
case 'right':
node.setPosition(refPos.x + refSize.width - size.width, pos.y);
break;
case 'middle':
node.setPosition(pos.x, refPos.y + (refSize.height / 2) - (size.height / 2));
break;
case 'center':
node.setPosition(refPos.x + (refSize.width / 2) - (size.width / 2), pos.y);
break;
}
});
notifyChanges();
logger.info(`drawNET: Aligned ${selected.length} nodes (${type}).`);
}
/**
* moveNodes - Moves selected nodes by dx, dy
*/
export function moveNodes(dx, dy) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length === 0) return;
selected.forEach(node => {
const pos = node.getPosition();
node.setPosition(pos.x + dx, pos.y + dy);
});
// Sync with persistence after move (with debounce)
clearTimeout(state.moveSyncTimer);
state.moveSyncTimer = setTimeout(() => {
notifyChanges();
}, 500);
}
export function distributeNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 3) return;
const bboxes = selected.map(node => ({
node,
bbox: node.getBBox()
}));
if (type === 'horizontal') {
// Sort nodes from left to right
bboxes.sort((a, b) => a.bbox.x - b.bbox.x);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
// Calculate Total Span (Right edge of last - Left edge of first)
const totalSpan = (last.bbox.x + last.bbox.width) - first.bbox.x;
// Calculate Sum of Node Widths
const sumWidths = bboxes.reduce((sum, b) => sum + b.bbox.width, 0);
// Total Gap Space
const totalGapSpace = totalSpan - sumWidths;
if (totalGapSpace < 0) return; // Overlapping too much? Skip for now
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentX = first.bbox.x;
bboxes.forEach((b, i) => {
if (i > 0) {
currentX += bboxes[i-1].bbox.width + gapSize;
b.node.setPosition(currentX, b.bbox.y);
}
});
} else if (type === 'vertical') {
// Sort nodes from top to bottom
bboxes.sort((a, b) => a.bbox.y - b.bbox.y);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
const totalSpan = (last.bbox.y + last.bbox.height) - first.bbox.y;
const sumHeights = bboxes.reduce((sum, b) => sum + b.bbox.height, 0);
const totalGapSpace = totalSpan - sumHeights;
if (totalGapSpace < 0) return;
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentY = first.bbox.y;
bboxes.forEach((b, i) => {
if (i > 0) {
currentY += bboxes[i-1].bbox.height + gapSize;
b.node.setPosition(b.bbox.x, currentY);
}
});
}
notifyChanges();
}
/**
* notifyChanges - Marks dirty for persistence
*/
function notifyChanges() {
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
+167
View File
@@ -0,0 +1,167 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
import { makeDraggable } from '../ui/utils.js';
/**
* initInventory - Initializes the real-time inventory counting panel
*/
export function initInventory() {
const inventoryPanel = document.getElementById('inventory-panel');
const inventoryList = document.getElementById('inventory-list');
if (!inventoryPanel || !inventoryList) return;
// Make Draggable
makeDraggable(inventoryPanel, '.inventory-header');
// Initial update
updateInventory(inventoryList);
// Listen for graph changes
if (state.graph) {
state.graph.on('node:added', () => updateInventory(inventoryList));
state.graph.on('node:removed', () => updateInventory(inventoryList));
state.graph.on('cell:changed', ({ cell }) => {
if (cell.isNode()) updateInventory(inventoryList);
});
// project:restored 이벤트 수신 (복구 시점)
state.graph.on('project:restored', () => {
logger.info("Project restored. Updating inventory.");
updateInventory(inventoryList);
});
}
}
/**
* toggleInventory - Toggles the visibility of the inventory panel
*/
export function toggleInventory() {
const panel = document.getElementById('inventory-panel');
if (!panel) return;
// Use getComputedStyle for more reliable check
const currentDisplay = window.getComputedStyle(panel).display;
if (currentDisplay === 'none') {
panel.style.display = 'flex';
panel.classList.add('animate-up');
} else {
panel.style.display = 'none';
}
}
/**
* updateInventory - Aggregates node counts by type and updates the UI
*/
export function updateInventory(container) {
if (!state.graph) return;
// Use default container if not provided
if (!container) {
container = document.getElementById('inventory-list');
}
if (!container) return;
const nodes = state.graph.getNodes();
const counts = {};
nodes.forEach(node => {
const data = node.getData() || {};
const type = data.type || 'Unknown';
counts[type] = (counts[type] || 0) + 1;
});
// Render list
container.innerHTML = '';
const sortedTypes = Object.keys(counts).sort();
if (sortedTypes.length === 0) {
container.innerHTML = `<div style="font-size: 10px; color: #94a3b8; text-align: center; margin-top: 10px;">${t('no_objects_found') || 'No objects found'}</div>`;
return;
}
sortedTypes.forEach(type => {
const item = document.createElement('div');
item.className = 'inventory-item';
const displayType = t(type.toLowerCase()) || type;
item.innerHTML = `
<span class="lbl">${displayType}</span>
<span class="val">${counts[type]}</span>
`;
container.appendChild(item);
});
// BOM 내보내기 버튼 추가 (푸터)
let footer = document.querySelector('.inventory-footer');
if (!footer) {
footer = document.createElement('div');
footer.className = 'inventory-footer';
}
footer.innerHTML = `
<button id="btn-export-bom" class="btn-export-bom">
<i class="fas fa-file-excel"></i>
<span>${t('export_bom') || 'Export BOM (Excel)'}</span>
</button>
`;
container.appendChild(footer);
const btn = document.getElementById('btn-export-bom');
if (btn) btn.onclick = downloadBOM;
}
/**
* downloadBOM - 가시화된 모든 노드 정보를 엑셀/CSV 형태로 추출
*/
function downloadBOM() {
if (!state.graph) return;
const nodes = state.graph.getNodes();
if (nodes.length === 0) return;
// 헤더 정의
const headers = [
t('prop_id'), t('display_name'), t('prop_type'),
t('prop_vendor'), t('prop_model'), t('prop_ip'),
t('prop_status'), t('prop_asset_tag'), t('prop_project'), t('prop_env')
];
// 데이터 행 생성
const rows = nodes.map(node => {
const data = node.getData() || {};
const label = node.attr('label/text') || '';
return [
node.id,
label,
data.type || '',
data.vendor || '',
data.model || '',
data.ip || '',
data.status || '',
data.asset_tag || '',
data.project || '',
data.env || ''
].map(val => `"${String(val).replace(/"/g, '""')}"`); // CSV 이스케이프
});
// CSV 문자열 합치기
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel kor
headers.join(',') + "\n" +
rows.map(r => r.join(',')).join('\n');
// 다운로드 트리거
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.setAttribute("href", url);
link.setAttribute("download", `drawNET_BOM_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
logger.info("BOM exported successfully.");
}
+134
View File
@@ -0,0 +1,134 @@
import { DEFAULTS } from '../constants.js';
import { state } from '../state.js';
import { calculateCellZIndex } from './layers.js';
import { getSettings } from '../settings/store.js';
/**
* graph/config.js - X6 Graph configuration options
*/
export const getGraphConfig = (container) => {
const settings = getSettings();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const defaultBg = isDark ? '#1e293b' : '#f8fafc';
return {
container: container,
autoResize: true,
background: { color: settings.bgColor || defaultBg },
grid: {
visible: settings.gridStyle !== 'none',
type: settings.gridStyle === 'solid' ? 'mesh' : (settings.gridStyle === 'dashed' ? 'doubleMesh' : 'dot'),
size: settings.gridSpacing || DEFAULTS.GRID_SPACING,
args: {
color: settings.gridColor || DEFAULTS.GRID_COLOR,
thickness: settings.gridThickness || 1
},
},
panning: {
enabled: true,
modifiers: 'space',
},
history: {
enabled: true,
},
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
factor: state.appConfig?.mousewheel?.factor || 1.05,
minScale: state.appConfig?.mousewheel?.minScale || 0.1,
maxScale: state.appConfig?.mousewheel?.maxScale || 10,
},
connecting: {
router: null,
connector: { name: 'jumpover', args: { type: 'arc', size: 6 } },
anchor: 'orth',
connectionPoint: 'boundary',
allowNode: false,
allowBlank: false,
snap: { radius: 20 },
createEdge() {
const edgeData = {
layerId: state.activeLayerId || 'l1',
routing_offset: 20,
direction: 'none',
description: "",
asset_tag: ""
};
return new X6.Shape.Edge({
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: null
},
},
zIndex: calculateCellZIndex(edgeData, 0, false),
data: edgeData
})
},
validateConnection({ sourceMagnet, targetMagnet }) {
return !!sourceMagnet && !!targetMagnet;
},
highlight: true,
},
embedding: {
enabled: true,
findParent({ node }) {
const bbox = node.getBBox();
const candidates = this.getNodes().filter((n) => {
const data = n.getData();
if (data && data.is_group && n.id !== node.id) {
const targetBBox = n.getBBox();
return targetBBox.containsRect(bbox);
}
return false;
});
if (candidates.length === 0) return [];
if (candidates.length === 1) return [candidates[0]];
// Return the one with the smallest area (the innermost one)
const bestParent = candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
});
return [bestParent];
},
validate({ child, parent }) {
return parent.getData()?.is_group;
},
},
interacting: {
nodeMovable: (view) => {
if (state.isCtrlPressed) return false; // Block default move during Ctrl+Drag
return view.cell.getProp('movable') !== false;
},
edgeMovable: (view) => {
if (state.isCtrlPressed) return false;
return view.cell.getProp('movable') !== false;
},
edgeLabelMovable: (view) => view.cell.getProp('movable') !== false,
arrowheadMovable: (view) => view.cell.getProp('movable') !== false,
vertexMovable: (view) => view.cell.getProp('movable') !== false,
vertexAddable: (view) => {
if (view.cell.getProp('movable') === false) return false;
// Only allow adding vertices if the edge is ALREADY selected.
// This prevents accidental addition on the first click (which selects the edge).
return view.graph.isSelected(view.cell);
},
vertexDeletable: (view) => view.cell.getProp('movable') !== false,
},
};
};
+18
View File
@@ -0,0 +1,18 @@
import { initSystemEvents } from './system.js';
import { initViewportEvents } from './viewport.js';
import { initSelectionEvents } from './selection.js';
import { initNodeEvents } from './nodes.js';
import { initRoutingEvents } from './routing.js';
export { updateGridBackground } from './system.js';
/**
* initGraphEvents - Orchestrates the initialization of all specialized event modules.
*/
export function initGraphEvents() {
initSystemEvents();
initViewportEvents();
initSelectionEvents();
initNodeEvents();
initRoutingEvents();
}
+98
View File
@@ -0,0 +1,98 @@
import { state } from '../../state.js';
import { generateRackUnitsPath } from '../styles/utils.js';
export function initNodeEvents() {
if (!state.graph) return;
// Hover Guides for specific objects
state.graph.on('node:mouseenter', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'rgba(59, 130, 246, 0.4)');
node.attr('hit/strokeDasharray', '2,2');
}
});
state.graph.on('node:mouseleave', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'transparent');
node.attr('hit/strokeDasharray', '0');
}
});
// Rack Drill Down Interaction
state.graph.on('node:dblclick', ({ node }) => {
const data = node.getData() || {};
if (data.layerId !== state.activeLayerId) return; // Guard: Block drills into other layers
if (node.shape === 'drawnet-rack') {
state.graph.zoomToCell(node, {
padding: { top: 60, bottom: 60, left: 100, right: 100 },
animation: { duration: 600 }
});
state.graph.getCells().forEach(cell => {
if (cell.id !== node.id && !node.getDescendants().some(d => d.id === cell.id)) {
cell.setAttrs({ body: { opacity: 0.1 }, image: { opacity: 0.1 }, label: { opacity: 0.1 }, line: { opacity: 0.1 } });
} else {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
}
});
showBackToTopBtn();
}
});
// Parent/Structural Changes
state.graph.on('node:change:parent', ({ node, current }) => {
const parentId = current || null;
const currentData = node.getData() || {};
node.setData({ ...currentData, parent: parentId });
import('../layers.js').then(m => m.applyLayerFilters());
import('/static/js/modules/properties_sidebar/index.js').then(m => m.renderProperties());
import('/static/js/modules/persistence.js').then(m => m.markDirty());
});
// Data Syncing (Rack-specific)
state.graph.on('node:change:data', ({ node, current }) => {
if (node.shape === 'drawnet-rack') {
const slots = current.slots || 42;
const bbox = node.getBBox();
node.attr('units/d', generateRackUnitsPath(bbox.height, slots));
if (!current.label || current.label.startsWith('Rack (')) {
node.attr('label/text', `Rack (${slots}U)`);
}
}
// Rich Card Content Refresh on Data Change
if (node.shape === 'drawnet-rich-card') {
import('../styles/utils.js').then(m => m.renderRichContent(node));
}
});
}
function showBackToTopBtn() {
let btn = document.getElementById('rack-back-btn');
if (btn) btn.remove();
btn = document.createElement('div');
btn.id = 'rack-back-btn';
btn.innerHTML = '<i class="fas fa-arrow-left"></i> Back to Topology';
btn.style.cssText = `
position: absolute; top: 100px; left: 300px; z-index: 1000;
padding: 10px 20px; background: #3b82f6; color: white;
border-radius: 8px; cursor: pointer; font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.2s;
`;
btn.onclick = () => {
state.graph.zoomToFit({ padding: 100, animation: { duration: 500 } });
state.graph.getCells().forEach(cell => {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
});
btn.remove();
};
document.getElementById('graph-container').parentElement.appendChild(btn);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
export function initRoutingEvents() {
if (!state.graph) return;
// Re-evaluate routing when nodes are moved to handle adaptive fallback (Phase 1.18)
state.graph.on('node:change:position', ({ node }) => {
const edges = state.graph.getConnectedEdges(node);
if (edges.length === 0) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
edges.forEach(edge => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
if (config.router !== edge.getRouter()) {
edge.setRouter(config.router || null);
logger.info(`drawNET: Dynamic Routing Update for ${edge.id}`);
}
if (config.connector !== edge.getConnector()) {
edge.setConnector(config.connector);
}
// Sync physical connections during move (especially for manual anchors)
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
});
});
});
// Unify styling when an edge is newly connected via mouse (Phase 1.19)
state.graph.on('edge:connected', ({ edge }) => {
if (!edge || !edge.isEdge()) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
// Apply official style & configuration
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
edge.setRouter(config.router || null);
edge.setConnector(config.connector);
edge.setZIndex(config.zIndex || 5);
if (config.attrs) {
edge.setAttrs(config.attrs);
}
logger.info(`drawNET: Unified Style applied to new edge ${edge.id}`);
});
});
}
@@ -0,0 +1,55 @@
import { state } from '../../state.js';
export function initSelectionEvents() {
if (!state.graph) return;
state.graph.on('cell:selected', ({ cell }) => {
if (cell.isNode()) {
if (!state.selectionOrder.includes(cell.id)) {
state.selectionOrder.push(cell.id);
}
} else if (cell.isEdge()) {
// Add vertices tool for manual routing control (unless locked)
if (!cell.getData()?.locked) {
cell.addTools([{ name: 'vertices' }]);
}
// ADD S/T labels for clear identification of Start and Target
const currentLabels = cell.getLabels() || [];
cell.setLabels([
...currentLabels,
{ id: 'selection-source-label', position: { distance: 0.05 }, attrs: { text: { text: 'S', fill: '#10b981', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#10b981', strokeWidth: 1, rx: 2, ry: 2 } } },
{ id: 'selection-target-label', position: { distance: 0.95 }, attrs: { text: { text: 'T', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#ef4444', strokeWidth: 1, rx: 2, ry: 2 } } }
]);
}
});
state.graph.on('cell:unselected', ({ cell }) => {
const isLocked = cell.getData()?.locked;
if (cell.isEdge()) {
if (!isLocked) {
cell.removeTools();
} else {
// Keep boundary if locked, but remove selection-only tools if any
cell.removeTools(['vertices']);
}
// Remove selection labels safely
const labels = cell.getLabels() || [];
const filteredLabels = labels.filter(l =>
l.id !== 'selection-source-label' && l.id !== 'selection-target-label'
);
cell.setLabels(filteredLabels);
} else if (cell.isNode()) {
if (!isLocked) {
cell.removeTools();
}
}
state.selectionOrder = state.selectionOrder.filter(id => id !== cell.id);
});
state.graph.on('blank:click', () => {
state.selectionOrder = [];
});
}
+132
View File
@@ -0,0 +1,132 @@
import { state } from '../../state.js';
import { updateGraphTheme } from '../styles.js';
import { DEFAULTS } from '../../constants.js';
export function initSystemEvents() {
if (!state.graph) return;
// Theme changes
window.addEventListener('themeChanged', () => {
updateGraphTheme();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
state.graph.drawBackground({ color: isDark ? '#1e293b' : '#f8fafc' });
});
// Background color changes (from settings)
window.addEventListener('backgroundChanged', (e) => {
if (state.graph) {
state.graph.drawBackground({ color: e.detail.color });
}
});
// Canvas resize event
window.addEventListener('canvasResize', (e) => {
const { width, height } = e.detail;
state.canvasSize = { width, height };
const container = document.getElementById('graph-container');
if (width === '100%') {
container.style.width = '100%';
container.style.height = '100%';
container.style.boxShadow = 'none';
container.style.margin = '0';
state.graph.resize();
} else {
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.boxShadow = '0 10px 30px rgba(0,0,0,0.1)';
container.style.margin = '40px auto';
container.style.backgroundColor = '#fff';
state.graph.resize(width, height);
}
});
// Grid type change
window.addEventListener('gridChanged', (e) => {
state.gridStyle = e.detail.style;
if (!state.graph) return;
if (e.detail.style === 'none') {
state.graph.hideGrid();
state.isSnapEnabled = false;
} else {
state.graph.showGrid();
state.isSnapEnabled = true;
if (e.detail.showMajor) {
state.graph.drawGrid({
type: 'doubleMesh',
args: [
{
color: e.detail.majorColor || DEFAULTS.MAJOR_GRID_COLOR,
thickness: (e.detail.thickness || 1) * 1.5,
factor: e.detail.majorInterval || DEFAULTS.MAJOR_GRID_INTERVAL
},
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
} else {
const gridType = e.detail.style === 'solid' ? 'mesh' : (e.detail.style === 'dashed' ? 'doubleMesh' : 'dot');
state.graph.drawGrid({
type: gridType,
args: [
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
}
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
}
});
// Grid spacing change
window.addEventListener('gridSpacingChanged', (e) => {
state.gridSpacing = e.detail.spacing;
if (state.graph) {
state.graph.setGridSize(state.gridSpacing);
}
});
// Global Data Sanitizer & Enforcer (Ensures all objects have latest schema defaults)
// This handles all creation paths: Drag-and-Drop, Copy-Paste, Grouping, and programatic addEdge.
state.graph.on('cell:added', ({ cell }) => {
const data = cell.getData() || {};
let updated = false;
// 1. Common Fields for all Node/Edge objects
if (data.description === undefined) { data.description = ""; updated = true; }
if (data.asset_tag === undefined) { data.asset_tag = ""; updated = true; }
if (!data.tags) { data.tags = []; updated = true; }
if (data.locked === undefined) { data.locked = false; updated = true; }
// 2. Ensure label_pos exists to prevent display reset
if (data.label_pos === undefined) {
data.label_pos = data.is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label'].includes((data.type || '').toLowerCase()) ? 'center' : 'bottom');
updated = true;
}
// 3. Edge Specific Defaults
if (cell.isEdge()) {
if (data.routing_offset === undefined) { data.routing_offset = 20; updated = true; }
}
if (updated) {
// Use silent: true to prevent triggering another 'cell:added' if someone is listening to data changes
cell.setData(data, { silent: true });
}
// Keep existing layer logic
import('../layers.js').then(m => m.applyLayerFilters());
});
}
export function updateGridBackground() {
if (!state.graph) return;
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
state.graph.showGrid();
}
@@ -0,0 +1,80 @@
import { state } from '../../state.js';
let isRightDragging = false;
let startX = 0;
let startY = 0;
const threshold = 5;
/**
* Handle mousedown on the graph to initialize right-click panning.
*/
const handleGraphMouseDown = ({ e }) => {
if (e.button === 2) { // Right Click
isRightDragging = false;
startX = e.clientX;
startY = e.clientY;
state.isRightDragging = false;
}
};
/**
* Global event handler for mousemove to support panning.
* Registered only once on document level.
*/
const onMouseMove = (e) => {
if (e.buttons === 2 && state.graph) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// Detect dragging start (beyond threshold)
if (!isRightDragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) {
isRightDragging = true;
state.isRightDragging = true;
// Sync start positions to current mouse position to avoid the initial jump
startX = e.clientX;
startY = e.clientY;
return;
}
if (isRightDragging) {
state.graph.translateBy(dx, dy);
startX = e.clientX;
startY = e.clientY;
}
}
};
/**
* Global event handler for mouseup to finish panning.
* Registered only once on document level.
*/
const onMouseUp = () => {
if (isRightDragging) {
// Reset after a short delay to allow contextmenu event to check the flag
setTimeout(() => {
state.isRightDragging = false;
isRightDragging = false;
}, 100);
}
};
/**
* Initializes viewport-related events for the current graph instance.
* Should be called whenever a new graph is created.
*/
export function initViewportEvents() {
if (!state.graph) return;
// Attach to the specific graph instance, ensuring no duplicates
state.graph.off('blank:mousedown', handleGraphMouseDown);
state.graph.on('blank:mousedown', handleGraphMouseDown);
state.graph.off('cell:mousedown', handleGraphMouseDown);
state.graph.on('cell:mousedown', handleGraphMouseDown);
// Register document-level listeners only once per page session
if (!window._drawNetViewportEventsInitialized) {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
window._drawNetViewportEventsInitialized = true;
}
}
@@ -0,0 +1,95 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { getX6NodeConfig } from '../styles.js';
/**
* initDropHandling - Sets up drag and drop from the asset library to the graph
*/
export function initDropHandling(container) {
container.addEventListener('dragover', (e) => e.preventDefault());
container.addEventListener('drop', (e) => {
e.preventDefault();
const assetId = e.dataTransfer.getData('assetId');
const asset = state.assetMap[assetId];
const pos = state.graph.clientToLocal(e.clientX, e.clientY);
logger.info(`Drop Event at {${pos.x}, ${pos.y}}`, {
assetId: assetId,
found: !!asset,
id: asset?.id,
path: asset?.path,
type: asset?.type,
rawAsset: asset
});
if (!asset) {
logger.high(`Asset [${assetId}] not found in map`);
return;
}
// Logical Layer Guard: Block dropping new nodes
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
return;
}
const isPrimitive = ['rect', 'rounded-rect', 'circle', 'text-box', 'label'].includes(asset.type);
const prefix = asset.id === 'fixed-group' ? 'group_' : (asset.type === 'blank' || asset.type === 'dot' ? 'p_' : (isPrimitive ? 'shp_' : 'node_'));
const id = prefix + Math.random().toString(36).substr(2, 4);
// Use translate keys or asset label first, or fallback to generic name
let initialLabel = asset.label || t(asset.id) || (asset.id === 'fixed-group' ? 'New Group' : 'New ' + (asset.type || 'Object'));
if (asset.id === 'fixed-group') initialLabel = t('group') || 'New Group';
// Find if dropped over a group (prefer the smallest/innermost one)
const candidates = state.graph.getNodes().filter(n => {
if (!n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(pos);
});
const parent = candidates.length > 0
? candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
})
: undefined;
// 2. Add to X6 immediately
const config = getX6NodeConfig({
id: id,
label: initialLabel,
type: asset.type || asset.category || asset.label || 'Unknown',
assetId: assetId, // Store unique composite ID for lookup
assetPath: asset.id === 'fixed-group' ? undefined : asset.path,
is_group: asset.id === 'fixed-group' || asset.is_group === true,
pos: { x: pos.x, y: pos.y },
parent: parent ? parent.id : undefined,
layerId: state.activeLayerId,
description: "",
asset_tag: "",
tags: []
});
const newNode = state.graph.addNode(config);
if (parent) {
parent.addChild(newNode);
}
// Ensure the new node follows the active layer's interaction rules immediately
import('../layers.js').then(m => m.applyLayerFilters());
});
}
@@ -0,0 +1,47 @@
/**
* graph/interactions/edges.js - Handles edge connection logic and metadata assignment.
*/
import { state } from '../../state.js';
/**
* initEdgeHandling - Sets up edge connection events
*/
export function initEdgeHandling() {
state.graph.on('edge:connected', ({ edge }) => {
const sourceId = edge.getSourceCellId();
const targetId = edge.getTargetCellId();
if (!sourceId || !targetId) return;
// Assign to current active layer and apply standard styling
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
// [6개월 뒤의 나를 위한 메모]
// 단순 유추가 아닌 명시적 기록을 통해, 도면 전체를 전송하지 않고도 연결선 단독으로
// 크로스 레이어 여부를 파악할수 있게 하여 외부 도구/AI 연동 안정성을 높임.
const sourceNode = state.graph.getCellById(sourceId);
const targetNode = state.graph.getCellById(targetId);
const sourceLayer = sourceNode?.getData()?.layerId || state.activeLayerId;
const targetLayer = targetNode?.getData()?.layerId || state.activeLayerId;
const isCrossLayer = (sourceLayer !== targetLayer);
const edgeData = {
source: sourceId,
target: targetId,
layerId: state.activeLayerId,
source_layer: sourceLayer,
target_layer: targetLayer,
is_cross_layer: isCrossLayer,
...edge.getData()
};
const config = getX6EdgeConfig(edgeData);
// Apply standardized config to the new edge
edge.setData(edgeData, { silent: true });
if (config.router) edge.setRouter(config.router);
else edge.setRouter(null);
if (config.connector) edge.setConnector(config.connector);
edge.setAttrs(config.attrs);
});
});
}
@@ -0,0 +1,23 @@
import { state } from '../../state.js';
import { initDropHandling } from './drop.js';
import { initEdgeHandling } from './edges.js';
import { initSnapping } from './snapping.js';
import { initSpecialShortcuts } from './shortcuts.js';
import { logger } from '../../utils/logger.js';
/**
* initInteractions - Entry point for all graph interactions
*/
export function initInteractions() {
if (!state.graph) return;
const container = state.graph.container;
// 1. Initialize Sub-modules
initDropHandling(container);
initEdgeHandling();
initSnapping();
initSpecialShortcuts();
logger.info("Graph interactions initialized via modules.");
}
@@ -0,0 +1,125 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* initSpecialShortcuts - Sets up specialized mouse/keyboard combined gestures
*/
export function initSpecialShortcuts() {
if (!state.graph) return;
let dragStartData = null;
// Ctrl + Drag to Copy: Improved Ghost Logic
state.graph.on('node:mousedown', ({ e, node }) => {
// Support both Ctrl (Windows) and Cmd (Mac)
const isModifier = e.ctrlKey || e.metaKey;
if (isModifier && e.button === 0) {
const selected = state.graph.getSelectedCells();
const targets = selected.includes(node) ? selected : [node];
// 1. Initial State for tracking
const mouseStart = state.graph.clientToLocal(e.clientX, e.clientY);
dragStartData = {
targets: targets,
mouseStart: mouseStart,
initialNodesPos: targets.map(t => ({ ...t.position() })),
ghost: null
};
const onMouseMove = (moveEvt) => {
if (!dragStartData) return;
const mouseNow = state.graph.clientToLocal(moveEvt.clientX, moveEvt.clientY);
const dx = mouseNow.x - dragStartData.mouseStart.x;
const dy = mouseNow.y - dragStartData.mouseStart.y;
// Create Ghost on movement
if (!dragStartData.ghost && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
const cellsBBox = state.graph.getCellsBBox(dragStartData.targets);
dragStartData.ghost = state.graph.addNode({
shape: 'rect',
x: cellsBBox.x,
y: cellsBBox.y,
width: cellsBBox.width,
height: cellsBBox.height,
attrs: {
body: {
fill: 'rgba(59, 130, 246, 0.1)',
stroke: '#3b82f6',
strokeWidth: 2,
strokeDasharray: '5,5',
pointerEvents: 'none'
}
},
zIndex: 1000
});
dragStartData.initialGhostPos = { x: cellsBBox.x, y: cellsBBox.y };
}
if (dragStartData.ghost) {
dragStartData.ghost.position(
dragStartData.initialGhostPos.x + dx,
dragStartData.initialGhostPos.y + dy
);
}
};
const onMouseUp = (upEvt) => {
cleanup(upEvt.button === 0);
};
const onKeyDown = (keyEvt) => {
if (keyEvt.key === 'Escape') {
cleanup(false); // Cancel on ESC
}
};
function cleanup(doPaste) {
if (dragStartData) {
if (doPaste && dragStartData.ghost) {
const finalPos = dragStartData.ghost.position();
const dx = finalPos.x - dragStartData.initialGhostPos.x;
const dy = finalPos.y - dragStartData.initialGhostPos.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
state.graph.copy(dragStartData.targets, { deep: true });
let clones = state.graph.paste({ offset: { dx, dy }, select: true });
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
clones.forEach(clone => {
if (clone.isNode()) clone.remove();
});
clones = clones.filter(c => !c.isNode());
if (clones.length === 0) {
import('/static/js/modules/ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
}
}
clones.forEach(clone => {
if (clone.isNode()) {
const d = clone.getData() || {};
clone.setData({ ...d, id: clone.id }, { silent: true });
}
});
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
}
if (dragStartData.ghost) dragStartData.ghost.remove();
}
dragStartData = null;
window.removeEventListener('mousemove', onMouseMove, true);
window.removeEventListener('mouseup', onMouseUp, true);
window.removeEventListener('keydown', onKeyDown, true);
}
window.addEventListener('mousemove', onMouseMove, true);
window.addEventListener('mouseup', onMouseUp, true);
window.addEventListener('keydown', onKeyDown, true);
}
});
logger.info("initSpecialShortcuts (Ctrl+Drag with ESC Failsafe) initialized.");
}
@@ -0,0 +1,48 @@
import { state } from '../../state.js';
/**
* initSnapping - Sets up snapping and grouping interactions during movement
*/
export function initSnapping() {
// 1. Rack Snapping Logic
state.graph.on('node:moving', ({ node }) => {
const parent = node.getParent();
if (parent && parent.shape === 'drawnet-rack') {
const rackBBox = parent.getBBox();
const nodeSize = node.size();
const pos = node.getPosition();
const headerHeight = 30;
const rackBodyHeight = rackBBox.height - headerHeight;
const unitCount = parent.getData()?.slots || 42;
const unitHeight = rackBodyHeight / unitCount;
const centerX = rackBBox.x + (rackBBox.width - nodeSize.width) / 2;
const relativeY = pos.y - rackBBox.y - headerHeight;
const snapY = rackBBox.y + headerHeight + Math.round(relativeY / unitHeight) * unitHeight;
node.setPosition(centerX, snapY, { silent: true });
}
});
// 2. Parent-Child Relationship Update on Move End
state.graph.on('node:moved', ({ node }) => {
if (node.getData()?.is_group) return;
let parent = node.getParent();
if (!parent) {
const pos = node.getPosition();
const center = { x: pos.x + node.size().width / 2, y: pos.y + node.size().height / 2 };
parent = state.graph.getNodes().find(n => {
if (n.id === node.id || !n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(center);
});
}
if (parent && node.getParent()?.id !== parent.id) {
parent.addChild(node);
}
});
}
@@ -0,0 +1,120 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
/**
* excel_exporter.js - Exports topology data to a structured CSV format compatible with Excel.
* Includes all metadata (Parent Group, Tags, Description) for both Nodes and Edges.
*/
export function exportToExcel() {
if (!state.graph) return;
const nodes = state.graph.getNodes();
const edges = state.graph.getEdges();
if (nodes.length === 0 && edges.length === 0) {
alert(t('pptx_no_data') || 'No data to export.');
return;
}
// Header Definition (Unified for mapping ease)
const headers = [
"Category",
"ID",
"Label/Name",
"Type",
"Parent Group",
"Source (ID/Label)",
"Target (ID/Label)",
"Tags",
"Description",
"IP Address",
"Vendor",
"Model",
"Asset Tag"
];
const csvRows = [];
// Add Nodes
nodes.forEach(node => {
const d = node.getData() || {};
const label = node.attr('label/text') || d.label || '';
// Find Parent Group Name
let parentName = '';
const parent = node.getParent();
if (parent && parent.isNode()) {
parentName = parent.attr('label/text') || parent.getData()?.label || parent.id;
}
csvRows.push([
"Node",
node.id,
label,
d.type || '',
parentName,
"", // Source
"", // Target
(d.tags || []).join('; '),
d.description || '',
d.ip || '',
d.vendor || '',
d.model || '',
d.asset_tag || ''
]);
});
// Add Edges
edges.forEach(edge => {
const d = edge.getData() || {};
const label = (edge.getLabels()[0]?.attrs?.label?.text) || d.label || '';
const sourceCell = edge.getSourceCell();
const targetCell = edge.getTargetCell();
const sourceStr = sourceCell ? (sourceCell.attr('label/text') || sourceCell.id) : (edge.getSource().id || 'Unknown');
const targetStr = targetCell ? (targetCell.attr('label/text') || targetCell.id) : (edge.getTarget().id || 'Unknown');
csvRows.push([
"Edge",
edge.id,
label,
d.type || 'Connection',
"", // Parent Group (Edges usually not grouped in same way)
sourceStr,
targetStr,
(d.tags || []).join('; '),
d.description || '',
"", // IP
"", // Vendor
"", // Model
d.asset_tag || ''
]);
});
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'Excel'), 'info', 2000));
// Generate CSV Content with UTF-8 BOM
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel (Required for Korean/Special chars)
headers.join(',') + "\n" +
csvRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
// Trigger Download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const projectName = document.getElementById('project-title')?.innerText || "Project";
link.setAttribute("href", url);
link.setAttribute("download", `drawNET_Inventory_${projectName}_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'Excel'), 'success'));
logger.high(`Inventory exported for ${nodes.length} nodes and ${edges.length} edges.`);
}
@@ -0,0 +1,259 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { showToast } from '../../ui/toast.js';
/**
* 전역 내보내기 로딩 오버레이 표시/숨김
*/
function showLoading() {
const overlay = document.createElement('div');
overlay.id = 'export-loading-overlay';
overlay.className = 'export-overlay';
overlay.innerHTML = `
<div class="export-spinner"></div>
<div class="export-text">${t('export_preparing')}</div>
`;
document.body.appendChild(overlay);
}
function hideLoading() {
const overlay = document.getElementById('export-loading-overlay');
if (overlay) overlay.remove();
}
/**
* 이미지를 Base64 Data URL로 변환 (캐시 및 재시도 적용)
*/
const assetCache = new Map();
async function toDataURL(url, retries = 2) {
if (!url || typeof url !== 'string') return url;
if (assetCache.has(url)) return assetCache.get(url);
const origin = window.location.origin;
const absoluteUrl = url.startsWith('http') ? url :
(url.startsWith('/') ? `${origin}${url}` : `${origin}/${url}`);
for (let i = 0; i <= retries; i++) {
try {
const response = await fetch(absoluteUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
assetCache.set(url, dataUrl);
return dataUrl;
} catch (e) {
if (i === retries) {
logger.critical(`Base64 conversion failed after ${retries} retries for: ${url}`, e);
return url;
}
await new Promise(r => setTimeout(r, 100 * (i + 1))); // 지수 백오프 대기
}
}
}
/**
* 내보내기 전 모든 노드의 이미지 속성을 Base64로 일괄 변환 (상속된 속성 포함)
*/
export async function prepareExport() {
if (!state.graph) return null;
const backupNodes = [];
const nodes = state.graph.getNodes();
const promises = nodes.map(async (node) => {
const imageAttrs = [];
const attrs = node.getAttrs();
// 1. 노드의 모든 속성을 재귀적으로 탐색하여 이미지 경로처럼 보이는 문자열 식별
const selectors = Object.keys(attrs);
// 기본 'image' 선택자 외에도 커스텀 선택자가 있을 수 있으므로 모두 확인
const targetSelectors = new Set(['image', 'icon', 'body', ...selectors]);
for (const selector of targetSelectors) {
// xlink:href와 href 두 가지 모두 명시적으로 점검
const keys = ['xlink:href', 'href', 'src'];
for (const key of keys) {
const path = `${selector}/${key}`;
const val = node.attr(path);
if (val && typeof val === 'string' && !val.startsWith('data:')) {
const trimmedVal = val.trim();
if (trimmedVal.includes('.') || trimmedVal.startsWith('/') || trimmedVal.startsWith('http')) {
const base64Data = await toDataURL(trimmedVal);
if (base64Data && base64Data !== trimmedVal) {
imageAttrs.push({ path, originalValue: val });
// 중요: 한 쪽에서만 발견되어도 호환성을 위해 두 가지 속성 모두 업데이트
const pairKey = key === 'xlink:href' ? 'href' : (key === 'href' ? 'xlink:href' : null);
node.attr(path, base64Data);
if (pairKey) {
const pairPath = `${selector}/${pairKey}`;
imageAttrs.push({ path: pairPath, originalValue: node.attr(pairPath) });
node.attr(pairPath, base64Data);
}
}
}
}
}
}
if (imageAttrs.length > 0) {
backupNodes.push({ node, imageAttrs });
}
});
await Promise.all(promises);
// PC 사양에 따른 DOM 동기화 안정성을 위해 1초 대기 (사용자 요청 반영)
await new Promise(resolve => setTimeout(resolve, 1000));
return backupNodes;
}
/**
* 내보내기 후 원래 속성으로 복구
*/
export function cleanupExport(backup) {
if (!backup) return;
try {
backup.forEach(item => {
item.imageAttrs.forEach(attr => {
item.node.attr(attr.path, attr.originalValue);
});
});
} catch (e) {
console.error("Cleanup export failed", e);
}
}
/**
* exportToPNG - PNG 이미지 내보내기
*/
export async function exportToPNG() {
if (!state.graph) return;
showLoading();
showToast(t('msg_export_start').replace('{format}', 'PNG'), 'info', 2000);
const backup = await prepareExport();
try {
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 100;
state.graph.toPNG((dataUri) => {
const link = document.createElement('a');
link.download = `drawNET_Topology_${Date.now()}.png`;
link.href = dataUri;
link.click();
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'PNG'), 'success');
logger.high("Exported to PNG successfully.");
}, {
padding,
backgroundColor: '#ffffff',
width: (bbox.width + padding * 2) * 2,
height: (bbox.height + padding * 2) * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'PNG'), 'error');
logger.critical("PNG Export failed", err);
}
}
/**
* exportToSVG - SVG 벡터 내보내기
*/
export async function exportToSVG() {
if (!state.graph) return;
showLoading();
const backup = await prepareExport();
try {
// 실제 셀들의 정확한 좌표 영역 계산 (줌/이동 무관한 절대 영역)
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 120;
// 여백을 포함한 최종 영역 설정
const exportArea = {
x: bbox.x - padding,
y: bbox.y - padding,
width: bbox.width + padding * 2,
height: bbox.height + padding * 2
};
state.graph.toSVG((svg) => {
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `drawNET_Topology_${Date.now()}.svg`;
link.href = url;
link.click();
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'SVG'), 'success');
URL.revokeObjectURL(url);
logger.high("Exported to SVG successfully.");
}, {
// 명시적 viewBox 지정으로 짤림 방지
viewBox: exportArea,
width: exportArea.width,
height: exportArea.height,
backgroundColor: '#ffffff',
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'SVG'), 'error');
logger.critical("SVG Export failed", err);
}
}
/**
* exportToPDF - PDF 문서 내보내기
*/
export async function exportToPDF() {
if (!state.graph) return;
showLoading();
const backup = await prepareExport();
try {
const { jsPDF } = window.jspdf;
const bbox = state.graph.getCellsBBox(state.graph.getCells());
const padding = 120;
const totalW = bbox.width + padding * 2;
const totalH = bbox.height + padding * 2;
state.graph.toPNG((dataUri) => {
const orientation = totalW > totalH ? 'l' : 'p';
const pdf = new jsPDF(orientation, 'px', [totalW, totalH]);
pdf.addImage(dataUri, 'PNG', padding, padding, bbox.width, bbox.height);
pdf.save(`drawNET_Topology_${Date.now()}.pdf`);
cleanupExport(backup);
hideLoading();
showToast(t('msg_export_success').replace('{format}', 'PDF'), 'success');
logger.high("Exported to PDF successfully.");
}, {
padding,
backgroundColor: '#ffffff',
width: totalW * 2,
height: totalH * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
} catch (err) {
if (backup) cleanupExport(backup);
hideLoading();
showToast(t('msg_export_failed').replace('{format}', 'PDF'), 'error');
logger.critical("PDF Export failed", err);
}
}
+116
View File
@@ -0,0 +1,116 @@
import { state } from '../../state.js';
import { getProjectData, restoreProjectData } from './json_handler.js';
import { exportToPPTX } from './pptx_exporter.js';
import { logger } from '../../utils/logger.js';
/**
* initGraphIO - 그래프 관련 I/O 이벤트 리스너 초기화
*/
export function initGraphIO() {
// Export JSON (.dnet)
const exportBtn = document.getElementById('export-json');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
const projectData = getProjectData();
if (!projectData) return;
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(projectData, null, 2));
const safeName = (state.projectName || "project").replace(/[^a-z0-9]/gi, '_').toLowerCase();
const dlAnchorElem = document.createElement('a');
dlAnchorElem.setAttribute("href", dataStr);
dlAnchorElem.setAttribute("download", `${safeName}_${Date.now()}.dnet`);
dlAnchorElem.click();
logger.high("Project exported (.dnet v3.0).");
});
}
// Export PPTX
const exportPptxBtn = document.getElementById('export-pptx');
if (exportPptxBtn) {
exportPptxBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
exportToPPTX();
});
}
// Export Excel
const exportExcelBtn = document.getElementById('export-excel');
if (exportExcelBtn) {
exportExcelBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
import('./excel_exporter.js').then(m => m.exportToExcel());
});
}
// Export PNG (Allowed for Trial)
const exportPngBtn = document.getElementById('export-png');
if (exportPngBtn) {
import('./image_exporter.js').then(m => {
exportPngBtn.addEventListener('click', () => m.exportToPNG());
});
}
// Export SVG
const exportSvgBtn = document.getElementById('export-svg');
if (exportSvgBtn) {
import('./image_exporter.js').then(m => {
exportSvgBtn.addEventListener('click', () => {
if (state.license.level === 'Trial') return;
m.exportToSVG();
});
});
}
// Export PDF (Allowed for Trial)
const exportPdfBtn = document.getElementById('export-pdf');
if (exportPdfBtn) {
import('./image_exporter.js').then(m => {
exportPdfBtn.addEventListener('click', () => m.exportToPDF());
});
}
// Import
const importInput = document.getElementById('import-json-input');
if (importInput) {
importInput.setAttribute('accept', '.dnet,.json');
}
const importBtn = document.getElementById('import-json');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target.result);
if (!state.graph) return;
const isDrawNET = data.header?.app === "drawNET Premium";
if (!isDrawNET) {
alert("지원하지 않는 파일 형식입니다.");
return;
}
if (data.graphJson) {
const ok = restoreProjectData(data);
if (ok) {
logger.high("Import complete (v3.0 JSON).");
}
} else {
alert("이 파일은 구버전(v2.x) 형식입니다. 현재 버전에서는 지원되지 않습니다.\n새로 작성 후 저장해 주세요.");
}
} catch (err) {
logger.critical("Failed to parse project file.", err);
alert("프로젝트 파일을 읽을 수 없습니다.");
}
};
reader.readAsText(file);
e.target.value = '';
});
}
}
// Re-export for compatibility
export { getProjectData, restoreProjectData, exportToPPTX };
+153
View File
@@ -0,0 +1,153 @@
/**
* graph/io/json_handler.js - Project serialization and restoration with auto-repair logic.
*/
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* getProjectData - 프로젝트 전체를 JSON으로 직렬화 (graph.toJSON 기반)
*/
export function getProjectData() {
if (!state.graph) return null;
const graphJson = state.graph.toJSON();
const viewport = state.graph.translate();
return {
header: {
app: "drawNET Premium",
version: "3.0.0",
export_time: new Date().toISOString(),
projectName: state.projectName || "Untitled_Project"
},
graphJson: graphJson,
viewport: {
zoom: state.graph.zoom(),
tx: viewport.tx,
ty: viewport.ty
},
settings: {
gridSpacing: state.gridSpacing,
gridStyle: state.gridStyle,
isSnapEnabled: state.isSnapEnabled,
canvasSize: state.canvasSize,
theme: document.documentElement.getAttribute('data-theme')
},
layers: state.layers,
activeLayerId: state.activeLayerId
};
}
/**
* restoreProjectData - JSON 프로젝트 데이터로 그래프 복원
*/
export function restoreProjectData(data) {
if (!state.graph || !data) return false;
try {
if (data.graphJson) {
state.graph.fromJSON(data.graphJson);
// Migrate old data to new schema (Add defaults if missing)
state.graph.getCells().forEach(cell => {
const cellData = cell.getData() || {};
let updated = false;
// 1. Recover missing position/size for nodes from data.pos
if (cell.isNode()) {
const pos = cell.getPosition();
if ((!pos || (pos.x === 0 && pos.y === 0)) && cellData.pos) {
cell.setPosition(cellData.pos.x, cellData.pos.y);
logger.info(`Recovered position for ${cell.id} from data.pos`);
updated = true;
}
const size = cell.getSize();
if (!size || (size.width === 0 && size.height === 0)) {
if (cellData.is_group) cell.setSize(200, 200);
else cell.setSize(60, 60);
updated = true;
}
}
// 2. Ensure description exists for all objects
if (cellData.description === undefined) {
cellData.description = "";
updated = true;
}
// 3. Ensure routing_offset exists for edges
if (cell.isEdge()) {
if (cellData.routing_offset === undefined) {
cellData.routing_offset = 20;
updated = true;
}
// [6개월 뒤의 나를 위한 메모]
// 하위 호환성 유지 및 데이터 무결성 강화를 위한 자가 치유(Self-healing) 로직.
// 기존 도면을 불러올 때 누락된 크로스 레이어 정보를 노드 데이터를 참조하여 실시간 복구함.
if (cellData.source_layer === undefined || cellData.target_layer === undefined) {
const srcNode = state.graph.getCellById(cellData.source);
const dstNode = state.graph.getCellById(cellData.target);
// Default to current edge's layer if node is missing (safe fallback)
const srcLayer = srcNode?.getData()?.layerId || cellData.layerId || 'l1';
const dstLayer = dstNode?.getData()?.layerId || cellData.layerId || 'l1';
cellData.source_layer = srcLayer;
cellData.target_layer = dstLayer;
cellData.is_cross_layer = (srcLayer !== dstLayer);
updated = true;
logger.info(`Auto-repaired layer metadata for edge ${cell.id}`);
}
}
// 4. Ensure label consistency (data vs attrs)
const pureLabel = cellData.label || "";
const visualLabel = cell.attr('label/text');
if (visualLabel !== pureLabel) {
cell.attr('label/text', pureLabel);
updated = true;
}
if (updated) {
cell.setData(cellData, { silent: true });
}
});
}
const vp = data.viewport || {};
if (vp.zoom !== undefined) state.graph.zoomTo(vp.zoom);
if (vp.tx !== undefined) state.graph.translate(vp.tx, vp.ty || 0);
const settings = data.settings || {};
if (settings.gridSpacing) {
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: settings.gridSpacing } }));
}
if (settings.gridStyle) {
window.dispatchEvent(new CustomEvent('gridChanged', { detail: { style: settings.gridStyle } }));
}
if (settings.theme) {
document.documentElement.setAttribute('data-theme', settings.theme);
}
// Restore Layers
if (data.layers && Array.isArray(data.layers)) {
state.layers = data.layers;
state.activeLayerId = data.activeLayerId || data.layers[0]?.id;
}
// Restore Metadata
if (data.header?.projectName) {
state.projectName = data.header.projectName;
const titleEl = document.getElementById('project-title');
if (titleEl) titleEl.innerText = state.projectName;
}
state.graph.trigger('project:restored');
return true;
} catch (e) {
logger.critical("Failed to restore project data.", e);
return false;
}
}
+238
View File
@@ -0,0 +1,238 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { applyLayerFilters } from '../layers.js';
import { prepareExport, cleanupExport } from './image_exporter.js';
/**
* PPTX로 내보내기 (PptxGenJS 활용)
* 단순 캡처가 아닌 개별 이미지 배치 및 선 객체 생성을 통한 고도화된 리포트 생성
*/
export async function exportToPPTX() {
if (!state.graph) return;
if (typeof PptxGenJS === 'undefined') {
alert(t('pptx_lib_error'));
return;
}
try {
const pptx = new PptxGenJS();
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'PPTX'), 'info', 3000));
pptx.layout = 'LAYOUT_16x9';
const projectTitle = document.getElementById('project-title')?.innerText || t('untitled_project');
const allCells = state.graph.getCells();
if (allCells.length === 0) {
alert(t('pptx_no_data'));
return;
}
// --- 1. 슬라이드 1: 요약 정보 ---
const summarySlide = pptx.addSlide();
summarySlide.addText(t('pptx_report_title').replace('{title}', projectTitle), {
x: 0.5, y: 0.3, w: '90%', h: 0.6, fontSize: 24, color: '363636', bold: true
});
const stats = {};
allCells.filter(c => c.isNode()).forEach(node => {
const type = node.getData()?.type || 'Unknown';
stats[type] = (stats[type] || 0) + 1;
});
const summaryRows = [[t('pptx_obj_type'), t('pptx_quantity')]];
Object.entries(stats).sort().forEach(([type, count]) => {
summaryRows.push([type, count.toString()]);
});
summarySlide.addTable(summaryRows, {
x: 0.5, y: 1.2, w: 4.0, colW: [2.5, 1.5],
border: { pt: 1, color: 'E2E8F0' }, fill: { color: 'F8FAFC' },
fontSize: 11, align: 'center'
});
summarySlide.addText(t('pptx_generated_at').replace('{date}', new Date().toLocaleString()), {
x: 6.0, y: 5.2, w: 3.5, h: 0.3, fontSize: 9, color: '999999', align: 'right'
});
// --- 좌표 변환 헬퍼 (Inches) ---
const MARGIN = 0.5;
const SLIDE_W = 10 - (MARGIN * 2);
const SLIDE_H = 5.625 - (MARGIN * 2);
const addGraphToSlide = (slide, cells, title) => {
if (cells.length === 0) return;
if (title) {
slide.addText(title, { x: 0.5, y: 0.2, w: 5.0, h: 0.4, fontSize: 14, bold: true, color: '4B5563' });
}
// High-Fidelity Hybrid: Capture topology as PNG
// Since toPNG is asynchronous with a callback, we use a different approach for batching or just use the current canvas state
// But for PPTX report, we want to ensure it's high quality.
// X6 toPNG is usually sync if no external images, but here we have SVGs.
// To keep it simple and stable, we'll use a placeholder or wait for the user to confirm.
// Actually, we can use the plugin's sync return if possible, or just export the whole thing.
return new Promise(async (resolve) => {
const backup = await prepareExport();
const bbox = state.graph.getContentBBox();
const ratio = bbox.width / bbox.height;
const maxW = 8.0;
const maxH = 4.5;
let imgW = maxW;
let imgH = imgW / ratio;
if (imgH > maxH) {
imgH = maxH;
imgW = imgH * ratio;
}
const imgX = 1.0 + (maxW - imgW) / 2;
const imgY = 0.8 + (maxH - imgH) / 2;
state.graph.toPNG((dataUri) => {
slide.addImage({ data: dataUri, x: imgX, y: imgY, w: imgW, h: imgH });
cleanupExport(backup);
resolve();
}, {
padding: 10,
backgroundColor: '#ffffff',
width: bbox.width * 2,
height: bbox.height * 2,
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
});
});
};
// --- 2. 슬라이드 2: 전체 조감도 ---
const mainDiagSlide = pptx.addSlide();
// Backup visibility
const originalVisibility = state.layers.map(l => ({ id: l.id, visible: l.visible }));
// Show everything for overall view
state.layers.forEach(l => l.visible = true);
await applyLayerFilters();
await addGraphToSlide(mainDiagSlide, allCells, t('pptx_overall_topo'));
// --- 3. 슬라이드 3~: 레이어별 상세도 ---
if (state.layers && state.layers.length > 1) {
for (const layer of state.layers) {
// Only show this layer
state.layers.forEach(l => l.visible = (l.id === layer.id));
await applyLayerFilters();
const layerCells = allCells.filter(c => (c.getData()?.layerId || 'l1') === layer.id);
if (layerCells.length > 0) {
const layerSlide = pptx.addSlide();
await addGraphToSlide(layerSlide, layerCells, t('pptx_layer_title').replace('{name}', layer.name));
}
}
}
// Restore original visibility
originalVisibility.forEach(orig => {
const l = state.layers.find(ly => ly.id === orig.id);
if (l) l.visible = orig.visible;
});
await applyLayerFilters();
// --- 4. 슬라이드 마지막: 상세 인벤토리 테이블 ---
const nodes = allCells.filter(c => c.isNode());
if (nodes.length > 0) {
const tableSlide = pptx.addSlide();
tableSlide.addText(t('pptx_inv_title'), { x: 0.5, y: 0.3, w: 5.0, h: 0.5, fontSize: 18, bold: true });
const tableRows = [[t('prop_label'), t('prop_type'), t('prop_parent'), t('prop_model'), t('prop_ip'), t('prop_status')]];
nodes.forEach(node => {
const d = node.getData() || {};
const parent = node.getParent();
const parentLabel = parent ? (parent.getData()?.label || parent.attr('label/text') || parent.id) : '-';
tableRows.push([
d.label || node.attr('label/text') || '',
d.type || '',
parentLabel,
d.model || '',
d.ip || '',
d.status || ''
]);
});
tableSlide.addTable(tableRows, {
x: 0.5, y: 1.0, w: 9.0,
colW: [1.5, 1.2, 1.5, 1.6, 1.6, 1.6],
border: { pt: 0.5, color: 'CCCCCC' },
fontSize: 8,
autoPage: true
});
}
// --- 5. 슬라이드: 객체별 상세 설명 (Label & Description) ---
const descObjects = allCells.filter(c => {
const d = c.getData() || {};
if (c.isNode()) {
// Include all standard nodes, but only include groups if they have a description
return !d.is_group || !!d.description;
}
// Include edges only if they have a user-defined label or description
return !!(d.label || d.description);
});
if (descObjects.length > 0) {
const descSlide = pptx.addSlide();
descSlide.addText(t('pptx_desc_table_title'), {
x: 0.5, y: 0.3, w: 8.0, h: 0.5, fontSize: 18, bold: true, color: '2D3748'
});
const descRows = [[t('prop_label'), t('prop_type'), t('prop_tags'), t('prop_description')]];
descObjects.forEach(cell => {
const d = cell.getData() || {};
let label = '';
if (cell.isNode()) {
label = d.label || cell.attr('label/text') || `Node (${cell.id.substring(0,8)})`;
} else {
// Optimized Edge Label: Use d.label if available, else Source -> Target name
if (d.label) {
label = d.label;
} else {
const src = state.graph.getCellById(cell.getSource().cell);
const dst = state.graph.getCellById(cell.getTarget().cell);
const srcName = src?.getData()?.label || src?.attr('label/text') || 'Unknown';
const dstName = dst?.getData()?.label || dst?.attr('label/text') || 'Unknown';
label = `${srcName} -> ${dstName}`;
}
}
const type = cell.isNode() ? (d.type || 'Node') : `Edge (${d.routing || 'manhattan'})`;
const tags = (d.tags || []).join(', ');
descRows.push([
label,
type,
tags || '-',
d.description || '-'
]);
});
descSlide.addTable(descRows, {
x: 0.5, y: 1.0, w: 9.0,
colW: [1.5, 1.2, 1.8, 4.5],
border: { pt: 0.5, color: 'CCCCCC' },
fill: { color: 'FFFFFF' },
fontSize: 9,
autoPage: true,
newSlideProps: { margin: [0.5, 0.5, 0.5, 0.5] }
});
}
pptx.writeFile({ fileName: `drawNET_Report_${Date.now()}.pptx` });
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'PPTX'), 'success'));
} catch (err) {
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_failed').replace('{format}', 'PPTX'), 'error'));
logger.critical("PPTX export failed.", err);
alert(t('pptx_export_error').replace('{message}', err.message));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { state } from '../state.js';
/**
* calculateCellZIndex - Logic to determine the Z-Index of a cell based on its layer and type.
* Hierarchy: Nodes(50) > Edges(30) > Groups(1).
*/
export function calculateCellZIndex(data, ancestorsCount, isNode) {
const layerId = data.layerId || 'l1';
const layerIndex = state.layers.findIndex(l => l.id === layerId);
const layerOffset = layerIndex !== -1 ? (state.layers.length - layerIndex) * 100 : 0;
let baseZ = 0;
if (isNode) {
if (data.is_group) {
baseZ = 1 + ancestorsCount;
} else {
baseZ = 50 + ancestorsCount;
}
} else {
baseZ = 30; // Edges: Always above groups
}
return layerOffset + baseZ;
}
/**
* applyLayerFilters - Updates the visibility of all cells in the graph based on their layer.
* Also enforces a strict Z-Index hierarchy: Nodes(50) > Edges(30) > Groups(1).
* Auto-Repairs existing edges to the latest styling standards.
*/
export async function applyLayerFilters() {
if (!state.graph) return;
// Pre-import style mapping for efficiency
const { getX6EdgeConfig, getLabelAttributes } = await import('./styles.js');
const cells = state.graph.getCells();
const visibleLayerIds = new Set(
state.layers
.filter(l => l.visible)
.map(l => l.id)
);
state.graph.batchUpdate(() => {
cells.forEach(cell => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
const isActive = (cellLayerId === state.activeLayerId);
const ancestorsCount = cell.getAncestors().length;
const zIndex = calculateCellZIndex(data, ancestorsCount, cell.isNode());
cell.setZIndex(zIndex);
const isVisible = visibleLayerIds.has(cellLayerId);
if (isVisible) {
cell.show();
const opacity = isActive ? 1 : (state.inactiveLayerOpacity || 0.3);
cell.attr('body/opacity', opacity);
cell.attr('image/opacity', opacity);
cell.attr('label/opacity', opacity);
cell.attr('line/opacity', opacity);
// Re-enable pointer events to allow "Ghost Snapping" (Connecting to other layers)
// We rely on Selection Plugin Filter and interacting rules for isolation.
cell.attr('root/style/pointer-events', 'auto');
const layerIndex = state.layers.findIndex(l => l.id === cellLayerId);
const isTrialLocked = state.license.level === 'Trial' && layerIndex >= 3;
const isManuallyLocked = data.locked === true || isTrialLocked;
const shouldBeInteractable = isActive && !isManuallyLocked;
cell.setProp('movable', shouldBeInteractable);
cell.setProp('deletable', shouldBeInteractable);
if (cell.isNode()) cell.setProp('rotatable', shouldBeInteractable);
} else {
cell.hide();
}
});
});
}
+67
View File
@@ -0,0 +1,67 @@
/**
* graph/plugins.js - X6 Plugin initialization
*/
export function initGraphPlugins(graph) {
if (window.X6PluginSelection) {
graph.use(new window.X6PluginSelection.Selection({
enabled: true,
multiple: true,
modifiers: 'shift',
rubberband: true,
showNodeSelectionBox: true,
showEdgeSelectionBox: false,
pointerEvents: 'none',
filter: (cell) => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
return cellLayerId === state.activeLayerId;
}
}));
}
if (window.X6PluginSnapline) {
graph.use(new window.X6PluginSnapline.Snapline({
enabled: true,
sharp: true,
}));
}
/* Disable X6 Keyboard plugin to prevent interception of global hotkeys */
/*
if (window.X6PluginKeyboard) {
graph.use(new window.X6PluginKeyboard.Keyboard({
enabled: true,
global: false,
}));
}
*/
if (window.X6PluginClipboard) {
graph.use(new window.X6PluginClipboard.Clipboard({
enabled: true,
}));
}
if (window.X6PluginHistory) {
graph.use(new window.X6PluginHistory.History({
enabled: true,
}));
}
if (window.X6PluginTransform) {
graph.use(new window.X6PluginTransform.Transform({
resizing: { enabled: true, orthographic: false },
rotating: { enabled: true },
// 선(Edge) 제외 및 현재 활성 레이어만 허용
filter: (cell) => {
if (!cell.isNode()) return false;
const data = cell.getData() || {};
return (data.layerId || 'l1') === state.activeLayerId;
},
}));
}
if (window.X6PluginExport) {
graph.use(new window.X6PluginExport.Export());
}
}
+99
View File
@@ -0,0 +1,99 @@
import { state } from '../state.js';
/**
* initGlobalSearch - Initializes the canvas-wide search and highlight functionality
*/
export function initGlobalSearch() {
const searchInput = document.getElementById('global-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
if (!state.graph) return;
const allNodes = state.graph.getNodes();
if (query === '') {
allNodes.forEach(node => {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
node.removeTools();
});
return;
}
allNodes.forEach(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const id = node.id.toLowerCase();
// PM 전용 표준 속성 검색 대상 포함
const searchFields = [
id, label,
(data.type || '').toLowerCase(),
(data.ip || '').toLowerCase(),
(data.model || '').toLowerCase(),
(data.vendor || '').toLowerCase(),
(data.asset_tag || '').toLowerCase(),
(data.serial || '').toLowerCase(),
(data.project || '').toLowerCase(),
(data.tags || []).join(' ').toLowerCase()
];
const isMatch = searchFields.some(field => field.includes(query));
if (isMatch) {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
// 검색 강조 툴 추가
node.addTools({
name: 'boundary',
args: {
padding: 5,
attrs: {
fill: 'none',
stroke: '#3b82f6',
strokeWidth: 3,
strokeDasharray: '0',
},
},
});
} else {
node.attr('body/opacity', 0.1);
node.attr('image/opacity', 0.1);
node.attr('label/opacity', 0.1);
node.removeTools();
}
});
});
// 엔터 키 입력 시 검색된 첫 번째 항목으로 포커싱
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const query = e.target.value.toLowerCase().trim();
if (!query || !state.graph) return;
const matches = state.graph.getNodes().filter(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const searchFields = [
node.id.toLowerCase(), label,
(data.ip || ''), (data.asset_tag || ''), (data.model || '')
];
return searchFields.some(f => f.toLowerCase().includes(query));
});
if (matches.length > 0) {
// 첫 번째 매치된 항목으로 줌인
state.graph.zoomToCell(matches[0], {
padding: 100,
animation: { duration: 600 }
});
state.graph.select(matches[0]);
}
}
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* graph/styles.js - Main Entry for Graph Styling (Plugin-based)
*/
import { shapeRegistry } from './styles/registry.js';
import { registerNodes } from './styles/node_shapes.js';
import { getX6NodeConfig, getX6EdgeConfig } from './styles/mapping/index.js';
import { getLabelAttributes } from './styles/utils.js';
/**
* registerCustomShapes - Initializes and registers all shape plugins
*/
export function registerCustomShapes() {
// 1. Initialize Registry (Load plugins)
registerNodes();
// 2. Install to X6
if (typeof X6 !== 'undefined') {
shapeRegistry.install(X6);
}
}
/**
* Re-exporting functions for compatibility with other modules
*/
export {
getX6NodeConfig,
getX6EdgeConfig,
getLabelAttributes,
shapeRegistry
};
// Default Theme Update
export function updateGraphTheme() {}
+52
View File
@@ -0,0 +1,52 @@
export function getEdgeConfig(data) {
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
const routerArgs = {
step: 5, // Finer step for better pathfinding
padding: 10, // Reduced padding to avoid "unreachable" errors in tight spaces
};
const directions = ['top', 'bottom', 'left', 'right'];
if (directions.includes(srcAnchor)) routerArgs.startDirections = [srcAnchor];
if (directions.includes(dstAnchor)) routerArgs.endDirections = [dstAnchor];
// For U-shape, we need a bit more padding but not too much to fail
if (data.routing === 'u-shape') routerArgs.padding = 15;
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
return {
id: data.id || `e_${data.source}_${data.target}`,
source: { cell: data.source, anchor: srcAnchor, connectionPoint: 'boundary' },
target: { cell: data.target, anchor: dstAnchor, connectionPoint: 'boundary' },
router: routerConfigs[routingType] || routerConfigs.manhattan,
connector: { name: 'jumpover', args: { type: 'arc', size: 5, radius: 12 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || 2),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : '0')),
targetMarker: (data.direction === 'forward' || data.direction === 'both') ? 'block' : null,
sourceMarker: (data.direction === 'backward' || data.direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: data.is_tunnel ? [{
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 4, ry: 4 }
}
}] : [],
data: data
};
}
@@ -0,0 +1,208 @@
import { state } from '../../../state.js';
import { logger } from '../../../utils/logger.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6EdgeConfig - Maps edge data to X6 connecting properties
*/
export function getX6EdgeConfig(data) {
if (!data || !data.source || !data.target) {
return {
source: data?.source,
target: data?.target,
router: null,
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: 'block'
}
}
};
}
// 1. Resolve Routing Type (Explicit choice > Adaptive)
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
// Adaptive Routing: Only force straight if NO explicit choice was made or if it's currently manhattan
if (state.graph && (routingType === 'manhattan' || !data.routing)) {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const b1 = srcNode.getBBox();
const b2 = dstNode.getBBox();
const p1 = { x: b1.x + b1.width / 2, y: b1.y + b1.height / 2 };
const p2 = { x: b2.x + b2.width / 2, y: b2.y + b2.height / 2 };
const dist = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
if (dist < 100) {
routingType = 'straight';
}
}
}
const isStraight = (routingType === 'straight');
// 2. Resolve Anchors & Router Args
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
// Manhattan Router Args (Enhanced for topological diagrams)
const routerArgs = {
padding: 10,
step: 5, // [TWEAK] Increase precision (10 -> 5) to reduce redundant bends
offset: data.routing_offset || 20,
exclude(cell) {
return cell && typeof cell.getData === 'function' && cell.getData()?.is_group;
}
};
// Sync Manhattan directions with manual anchors to force the router to respect the side
const validSides = ['top', 'bottom', 'left', 'right'];
if (srcAnchor && validSides.includes(srcAnchor)) {
routerArgs.startDirections = [srcAnchor];
}
if (dstAnchor && validSides.includes(dstAnchor)) {
routerArgs.endDirections = [dstAnchor];
}
if (routingType === 'manhattan-u') {
routerArgs.startDirections = ['top'];
routerArgs.endDirections = ['top'];
}
// 3. Intelligent Port Matching (Shortest Path)
// If ports are not explicitly provided (e.g. via Auto-connect Shift+A),
// we find the closest port pair to ensure the most direct connection.
let resolvedSourcePort = data.source_port;
let resolvedTargetPort = data.target_port;
if (state.graph && !resolvedSourcePort && !resolvedTargetPort && srcAnchor === 'orth' && dstAnchor === 'orth') {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const bestPorts = findClosestPorts(srcNode, dstNode);
if (bestPorts) {
resolvedSourcePort = bestPorts.source;
resolvedTargetPort = bestPorts.target;
}
}
}
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
const isStraightFinal = (routingType === 'straight');
const direction = data.direction || 'none';
// Resolve source/target configuration
// Rule: If a port is explicitly provided AND the anchor is set to 'orth' (Auto),
// we keep the port. If a manual anchor (top, left, etc.) is set, we clear the port.
const sourceConfig = {
cell: data.source,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedSourcePort && srcAnchor === 'orth') {
sourceConfig.port = resolvedSourcePort;
} else {
// [FIX] In Straight mode, directional anchors (top, left etc) force a bend.
// We use 'center' but rely on 'boundary' connection point for a perfect straight diagonal.
sourceConfig.anchor = isStraight ? { name: 'center' } : { name: srcAnchor };
}
const targetConfig = {
cell: data.target,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedTargetPort && dstAnchor === 'orth') {
targetConfig.port = resolvedTargetPort;
} else {
targetConfig.anchor = isStraight ? { name: 'center' } : { name: dstAnchor };
}
const config = {
id: data.id || `e_${data.source}_${data.target}`,
source: sourceConfig,
target: targetConfig,
router: isStraight ? null : (routerConfigs[routingType] || routerConfigs.manhattan),
connector: isStraight ? { name: 'normal' } : { name: 'jumpover', args: { type: 'arc', size: 6 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || (data.style === 'double' ? 4 : 2)),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : (data.style === 'dotted' ? '2,2' : '0'))),
targetMarker: (direction === 'forward' || direction === 'both') ? 'block' : null,
sourceMarker: (direction === 'backward' || direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: (() => {
const lbls = [];
if (data.label) {
lbls.push({
attrs: {
text: { text: data.label, fill: data.color || '#64748b', fontSize: 11, fontWeight: '600' },
rect: { fill: 'rgba(255,255,255,0.8)', rx: 4, ry: 4, strokeWidth: 0 }
}
});
}
if (data.is_tunnel) {
lbls.push({
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 3, ry: 3, stroke: '#ef4444', strokeWidth: 1 }
},
position: { distance: 0.25 }
});
}
return lbls;
})(),
zIndex: data.zIndex || calculateCellZIndex(data, 0, false), // Edges default to 30 (Above groups)
movable: !data.locked,
deletable: !data.locked,
data: data
};
return config;
}
/**
* findClosestPorts - Finds the pair of ports with the minimum distance between two nodes.
*/
function findClosestPorts(srcNode, dstNode) {
const srcPorts = srcNode.getPorts();
const dstPorts = dstNode.getPorts();
if (srcPorts.length === 0 || dstPorts.length === 0) return null;
let minParams = null;
let minDistance = Infinity;
srcPorts.forEach(sp => {
dstPorts.forEach(dp => {
const p1 = srcNode.getPortProp(sp.id, 'args/point') || { x: 0, y: 0 };
const p2 = dstNode.getPortProp(dp.id, 'args/point') || { x: 0, y: 0 };
// Convert to absolute coordinates
const pos1 = srcNode.getPosition();
const pos2 = dstNode.getPosition();
const abs1 = { x: pos1.x + p1.x, y: pos1.y + p1.y };
const abs2 = { x: pos2.x + p2.x, y: pos2.y + p2.y };
const dist = Math.sqrt(Math.pow(abs1.x - abs2.x, 2) + Math.pow(abs1.y - abs2.y, 2));
if (dist < minDistance) {
minDistance = dist;
minParams = { source: sp.id, target: dp.id };
}
});
});
return minParams;
}
@@ -0,0 +1,2 @@
export { getX6NodeConfig } from './node.js';
export { getX6EdgeConfig } from './edge.js';
@@ -0,0 +1,138 @@
import { state } from '../../../state.js';
import { shapeRegistry } from '../registry.js';
import { getLabelAttributes, generateRackUnitsPath } from '../utils.js';
import { DEFAULTS } from '../../../constants.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6NodeConfig - Maps data properties to X6 node attributes using ShapeRegistry
*/
export function getX6NodeConfig(data) {
const nodeType = (data.type || '').toLowerCase();
// 1. Find the plugin in the registry
let plugin = shapeRegistry.get(nodeType);
// Fallback for generic types if not found by direct name
if (!plugin) {
if (nodeType === 'rectangle') plugin = shapeRegistry.get('rect');
else if (nodeType === 'blank') plugin = shapeRegistry.get('dummy');
}
// Default to 'default' plugin if still not found
if (!plugin) plugin = shapeRegistry.get('default');
// 2. Resolve asset path (Skip for groups and primitives!)
const primitives = ['rect', 'circle', 'ellipse', 'rounded-rect', 'text-box', 'label', 'dot', 'dummy', 'rack', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input', 'table', 'user', 'admin', 'hourglass', 'polyline'];
let assetPath = `/static/assets/${DEFAULTS.DEFAULT_ICON}`;
if (data.is_group || primitives.includes(nodeType)) {
assetPath = undefined;
} else if (plugin.shape === 'drawnet-node' || plugin.type === 'default' || !plugin.icon) {
const currentPath = data.assetPath || data.asset_path;
if (currentPath) {
assetPath = currentPath.startsWith('/static/assets/') ? currentPath : `/static/assets/${currentPath}`;
} else {
const matchedAsset = state.assetsData?.find(a => {
const searchType = (a.type || '').toLowerCase();
const searchId = (a.id || '').toLowerCase();
return nodeType === searchType || nodeType === searchId ||
nodeType.includes(searchType) || nodeType.includes(searchId);
});
if (matchedAsset) {
const iconFileName = matchedAsset.path || (matchedAsset.views && matchedAsset.views.icon) || DEFAULTS.DEFAULT_ICON;
assetPath = `/static/assets/${iconFileName}`;
data.assetPath = iconFileName;
}
}
}
// 3. Build Base Configuration
let config = {
id: data.id,
shape: plugin.shape,
position: data.pos ? { x: data.pos.x, y: data.pos.y } : { x: 100, y: 100 },
zIndex: data.zIndex || calculateCellZIndex(data, 0, true),
movable: !data.locked,
deletable: !data.locked,
data: data,
attrs: {
label: {
text: data.label || '',
fill: data.color || data['text-color'] || '#64748b',
...getLabelAttributes(data.label_pos || data['label-pos'], nodeType, data.is_group)
}
}
};
// 4. Plugin Specific Overrides
if (data.is_group) {
config.attrs.body = {
fill: data.background || '#f1f5f9',
stroke: data['border-color'] || '#3b82f6'
};
} else if (plugin.shape === 'drawnet-icon' && plugin.icon) {
config.attrs.icon = {
text: plugin.icon,
fill: data.color || '#64748b'
};
} else if (plugin.shape === 'drawnet-node') {
config.attrs.image = { 'xlink:href': assetPath };
}
// 5. Primitive Styling Persistence
if (primitives.includes(nodeType)) {
if (!config.attrs.body) config.attrs.body = {};
if (data.fill) config.attrs.body.fill = data.fill;
if (data.border) config.attrs.body.stroke = data.border;
}
// Rack specific handling
if (nodeType === 'rack') {
const slots = data.slots || 42;
const height = data.height || 600;
config.data.is_group = true;
config.attrs = {
...config.attrs,
units: { d: generateRackUnitsPath(height, slots) },
label: { text: data.label || `Rack (${slots}U)` }
};
}
// Rich Card specific handling
if (nodeType === 'rich-card' || plugin.shape === 'drawnet-rich-card') {
config.data.headerText = data.headerText || data.label || 'RICH INFO CARD';
config.data.content = data.content || '1. Content goes here...\n2. Support multiple lines';
config.data.cardType = data.cardType || 'numbered';
config.data.headerColor = data.headerColor || '#3b82f6';
config.data.headerAlign = data.headerAlign || 'left';
config.data.contentAlign = data.contentAlign || 'left';
const hAnchor = config.data.headerAlign === 'center' ? 'middle' : (config.data.headerAlign === 'right' ? 'end' : 'start');
const hX = config.data.headerAlign === 'center' ? 0.5 : (config.data.headerAlign === 'right' ? '100%' : 10);
const hX2 = config.data.headerAlign === 'right' ? -10 : 0;
config.attrs = {
...config.attrs,
headerLabel: {
text: config.data.headerText,
textAnchor: hAnchor,
refX: hX,
refX2: hX2
},
header: { fill: config.data.headerColor },
body: { stroke: config.data.headerColor }
};
// Initial render trigger (Hack: X6 might not be ready, so we defer)
setTimeout(() => {
const cell = state.graph.getCellById(config.id);
if (cell) {
import('../utils.js').then(m => m.renderRichContent(cell));
}
}, 50);
}
return config;
}
@@ -0,0 +1,338 @@
/**
* graph/styles/node_shapes.js - Registration of individual shape plugins
*/
import { shapeRegistry } from './registry.js';
import { commonPorts } from './ports.js';
import { generateRackUnitsPath, getLabelAttributes } from './utils.js';
import { DEFAULTS } from '../../constants.js';
/**
* Initialize all shape plugins into the registry
*/
export function registerNodes() {
// 1. Default Node
shapeRegistry.register('default', {
shape: 'drawnet-node',
definition: {
inherit: 'rect',
width: 60, height: 60,
attrs: {
body: { strokeWidth: 0, fill: 'transparent', rx: 4, ry: 4 },
image: { 'xlink:href': `/static/assets/${DEFAULTS.DEFAULT_ICON || 'router.svg'}`, width: 48, height: 48, refX: 6, refY: 6 },
label: {
text: '', fill: '#64748b', fontSize: 12, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'top', refX: 0.5, refY: '100%', refY2: 10,
},
},
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'image', selector: 'image' },
{ tagName: 'text', selector: 'label' },
],
ports: { ...commonPorts }
}
});
// 2. Group Node
shapeRegistry.register('group', {
shape: 'drawnet-group',
definition: {
inherit: 'rect',
width: 200, height: 200,
attrs: {
body: { strokeWidth: 2, stroke: '#3b82f6', fill: '#f1f5f9', fillOpacity: 0.2, rx: 8, ry: 8 },
label: {
text: '', fill: '#1e293b', fontSize: 14, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'bottom', refX: 0.5, refY: -15,
},
},
ports: { ...commonPorts }
}
});
// 3. Dummy/Blank Node
shapeRegistry.register('dummy', {
shape: 'drawnet-dummy',
definition: {
inherit: 'circle',
width: 8, height: 8,
attrs: {
body: { strokeWidth: 1, stroke: 'rgba(148, 163, 184, 0.2)', fill: 'rgba(255, 255, 255, 0.1)' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 4. Dot Node
shapeRegistry.register('dot', {
shape: 'drawnet-dot',
definition: {
inherit: 'circle',
width: 6, height: 6,
attrs: {
body: { strokeWidth: 1, stroke: '#64748b', fill: '#64748b' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 3, magnet: true, stroke: '#31d0c6', strokeWidth: 1, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 5. Basic Shapes (Primitives)
const basicAttrs = {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' }
};
shapeRegistry.register('rect', {
shape: 'drawnet-rect',
definition: { inherit: 'rect', width: 100, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('rounded-rect', {
shape: 'drawnet-rounded-rect',
definition: {
inherit: 'rect', width: 100, height: 60,
attrs: { ...basicAttrs, body: { ...basicAttrs.body, rx: 12, ry: 12 } },
ports: commonPorts
}
});
shapeRegistry.register('circle', {
shape: 'drawnet-circle',
definition: { inherit: 'circle', width: 60, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('ellipse', {
shape: 'drawnet-ellipse',
definition: { inherit: 'ellipse', width: 80, height: 50, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('text-box', {
shape: 'drawnet-text',
definition: {
inherit: 'rect', width: 100, height: 40,
attrs: { body: { strokeWidth: 0, fill: 'transparent' }, label: { ...basicAttrs.label, text: 'Text', fill: '#1e293b', fontSize: 14 } }
}
});
shapeRegistry.register('label', {
shape: 'drawnet-label',
definition: {
inherit: 'rect', width: 80, height: 30,
attrs: {
...basicAttrs,
body: { ...basicAttrs.body, rx: 4, ry: 4, strokeWidth: 1 },
label: { ...basicAttrs.label, fontSize: 13, fill: '#1e293b' }
},
ports: commonPorts
}
});
// 6. Practical Shapes
shapeRegistry.register('browser', {
shape: 'drawnet-browser',
definition: {
inherit: 'rect', width: 120, height: 80,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'circle', selector: 'btn1' },
{ tagName: 'circle', selector: 'btn2' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff', rx: 4, ry: 4 },
header: { strokeWidth: 2, stroke: '#94a3b8', fill: '#f1f5f9', height: 18, refWidth: '100%', rx: 4, ry: 4 },
btn1: { r: 3, fill: '#cbd5e1', refX: 10, refY: 9 },
btn2: { r: 3, fill: '#cbd5e1', refX: 20, refY: 9 },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.6, textAnchor: 'middle', textVerticalAnchor: 'middle' }
},
ports: commonPorts
}
});
shapeRegistry.register('triangle', {
shape: 'drawnet-triangle',
definition: {
inherit: 'polygon', width: 60, height: 60,
attrs: {
body: { refPoints: '0,10 5,0 10,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { ...basicAttrs.label, refY: 0.7 }
},
ports: commonPorts
}
});
shapeRegistry.register('diamond', {
shape: 'drawnet-diamond',
definition: {
inherit: 'polygon', width: 80, height: 80,
attrs: { body: { refPoints: '0,5 5,0 10,5 5,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('parallelogram', {
shape: 'drawnet-parallelogram',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '2,0 10,0 8,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('cylinder', {
shape: 'drawnet-cylinder',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'top' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 10 L 0 70 C 0 80 60 80 60 70 L 60 10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
top: { d: 'M 0 10 C 0 0 60 0 60 10 C 60 20 0 20 0 10 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('document', {
shape: 'drawnet-document',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'fold' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 0 L 45 0 L 60 15 L 60 80 L 0 80 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
fold: { d: 'M 45 0 L 45 15 L 60 15', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('manual-input', {
shape: 'drawnet-manual-input',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '0,3 10,0 10,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('table', {
// ... (previous table registration)
});
// --- 랙(Rack) 컨테이너 플러그인 ---
shapeRegistry.register('rack', {
shape: 'drawnet-rack',
definition: {
inherit: 'rect',
width: 160, height: 600,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'path', selector: 'units' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 3, stroke: '#334155', fill: '#f8fafc', rx: 4, ry: 4 },
header: { refWidth: '100%', height: 30, fill: '#1e293b', stroke: '#334155', strokeWidth: 2, rx: 4, ry: 4 },
units: {
d: generateRackUnitsPath(600, 42),
stroke: '#94a3b8', strokeWidth: 1
},
label: {
text: 'Rack', fill: '#ffffff', fontSize: 14, fontWeight: 'bold',
refX: 0.5, refY: 15, textAnchor: 'middle', textVerticalAnchor: 'middle'
}
},
ports: commonPorts
}
});
// 8. Rich Text Card (Header + Listing)
shapeRegistry.register('rich-card', {
shape: 'drawnet-rich-card',
definition: {
inherit: 'rect',
width: 280, height: 180,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'text', selector: 'headerLabel' },
{
tagName: 'foreignObject',
selector: 'fo',
children: [
{
tagName: 'div',
ns: 'http://www.w3.org/1999/xhtml',
selector: 'content',
style: {
width: '100%',
height: '100%',
padding: '10px',
fontSize: '12px',
color: '#334155',
overflow: 'hidden',
boxSizing: 'border-box',
lineHeight: '1.4'
}
}
]
}
],
attrs: {
body: { fill: '#ffffff', stroke: '#3b82f6', strokeWidth: 1, rx: 4, ry: 4 },
header: { refWidth: '100%', height: 32, fill: '#3b82f6', rx: 4, ry: 4 },
headerLabel: {
text: 'TITLE', fill: '#ffffff', fontSize: 13, fontWeight: 'bold',
refX: 10, refY: 16, textVerticalAnchor: 'middle'
},
fo: { refX: 0, refY: 32, refWidth: '100%', refHeight: 'calc(100% - 32)' }
},
ports: commonPorts
}
});
// 7. Dynamic Icon-based Plugins
const icons = {
'user': '\uf007', 'admin': '\uf505', 'hourglass': '\uf252',
'table': '\uf0ce', 'tag': '\uf02b', 'polyline': '\uf201',
'arrow-up': '\uf062', 'arrow-down': '\uf063', 'arrow-left': '\uf060', 'arrow-right': '\uf061'
};
Object.keys(icons).forEach(type => {
shapeRegistry.register(type, {
shape: 'drawnet-icon',
icon: icons[type],
definition: {
inherit: 'rect', width: 60, height: 60,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'text', selector: 'icon' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 0, fill: 'transparent' },
icon: { text: icons[type], fontFamily: 'FontAwesome', fontSize: 40, fill: '#64748b', refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: '100%', refY2: 12, textAnchor: 'middle', textVerticalAnchor: 'top' }
},
ports: commonPorts
}
});
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* graph/styles/ports.js - Core port configurations for X6 nodes
*/
export const commonPorts = {
groups: {
top: {
position: 'top',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
bottom: {
position: 'bottom',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
left: {
position: 'left',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
right: {
position: 'right',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
center: {
position: 'absolute',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
}
},
items: [
{ id: 'top', group: 'top' },
{ id: 'bottom', group: 'bottom' },
{ id: 'left', group: 'left' },
{ id: 'right', group: 'right' }
],
};
export const centerPort = {
...commonPorts,
items: [{ id: 'center', group: 'center' }]
};
@@ -0,0 +1,54 @@
import { logger } from '../../utils/logger.js';
/**
* graph/styles/registry.js - Central registry for custom X6 shapes (Plugin System)
*/
class ShapeRegistry {
constructor() {
this.shapes = new Map();
}
/**
* Registers a new shape plugin
* @param {string} type - Node type name (e.g., 'browser', 'triangle')
* @param {Object} options - Shape configuration
* @param {string} options.shape - The X6 shape name to register (e.g., 'drawnet-browser')
* @param {Object} options.definition - The X6 registration object (inherit, markup, attrs, etc.)
* @param {Object} options.mapping - Custom mapping logic or attribute overrides
* @param {string} options.icon - Default FontAwesome icon (if applicable)
*/
register(type, options) {
this.shapes.set(type.toLowerCase(), {
type,
...options
});
}
get(type) {
return this.shapes.get(type.toLowerCase());
}
getAll() {
return Array.from(this.shapes.values());
}
/**
* Internal X6 registration helper
*/
install(X6) {
if (!X6) return;
const registeredShapes = new Set();
this.shapes.forEach((plugin) => {
if (plugin.shape && plugin.definition && !registeredShapes.has(plugin.shape)) {
try {
X6.Graph.registerNode(plugin.shape, plugin.definition);
registeredShapes.add(plugin.shape);
} catch (e) {
logger.high(`Shape [${plugin.shape}] registration failed:`, e);
}
}
});
}
}
export const shapeRegistry = new ShapeRegistry();
+135
View File
@@ -0,0 +1,135 @@
/**
* graph/styles/utils.js - Utility functions for graph styling
*/
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* getLabelAttributes - Returns X6 label attributes based on position and node type
*/
export function getLabelAttributes(pos, nodeType, is_group) {
const defaultPos = is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label', 'browser', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input'].includes(nodeType) ? 'center' : 'bottom');
const actualPos = pos || defaultPos;
switch (actualPos) {
case 'top':
return { refX: 0.5, refY: 0, textAnchor: 'middle', textVerticalAnchor: 'bottom', refY2: -8, refX2: 0 };
case 'bottom':
return { refX: 0.5, refY: '100%', textAnchor: 'middle', textVerticalAnchor: 'top', refY2: 8, refX2: 0 };
case 'left':
return { refX: 0, refY: 0.5, textAnchor: 'end', textVerticalAnchor: 'middle', refY2: 0, refX2: -12 };
case 'right':
return { refX: '100%', refY: 0.5, textAnchor: 'start', textVerticalAnchor: 'middle', refY2: 0, refX2: 12 };
case 'center':
return { refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle', refY2: 0, refX2: 0 };
default:
return { refX: 0.5, refY: '100%', textAnchor: 'middle', textVerticalAnchor: 'top', refY2: 8, refX2: 0 };
}
}
/**
* doctorEdge - Debug assistant for checking cell data and attributes
*/
window.doctorEdge = function (id) {
if (!state.graph) return logger.critical("X6 Graph not initialized.");
const cell = state.graph.getCellById(id);
if (!cell) return logger.critical(`Cell [${id}] not found.`);
logger.info(`--- drawNET STYLE DOCTOR (X6) for [${id}] ---`);
logger.info("Data Properties:", cell.getData());
logger.info("Cell Attributes:", cell.getAttrs());
if (cell.isEdge()) {
logger.info("Router:", cell.getRouter());
logger.info("Connector:", cell.getConnector());
logger.info("Source/Target:", {
source: cell.getSource(),
target: cell.getTarget()
});
}
};
/**
* Rack의 높이와 슬롯 수에 따라 U-Unit 눈금 패스(SVG Path)를 생성합니다.
*/
export function generateRackUnitsPath(height, slots = 42) {
let path = '';
const headerHeight = 30; // node_shapes.js 고정값과 동기화
const rackBodyHeight = height - headerHeight;
const unitHeight = rackBodyHeight / slots;
// 단순화된 눈금 (좌우 레일에 작은 선)
for (let i = 0; i < slots; i++) {
const y = headerHeight + (i * unitHeight);
path += `M 5 ${y} L 15 ${y} M 145 ${y} L 155 ${y} `;
}
return path;
}
/**
* renderRichContent - Translates plain text to stylized HTML for the Rich Card
*/
export function renderRichContent(cell, updates) {
if (!state.graph) return;
const view = cell.findView(state.graph);
if (!view) return; // View might not be rendered yet
const container = view.container;
const contentDiv = container.querySelector('div[selector="content"]');
if (!contentDiv) return;
const data = updates || cell.getData() || {};
const content = data.content || '';
const cardType = data.cardType || 'standard';
const headerColor = data.headerColor || '#3b82f6';
const contentAlign = data.contentAlign || 'left';
contentDiv.style.textAlign = contentAlign;
const lines = content.split('\n').filter(line => line.trim() !== '');
let html = '';
if (cardType === 'numbered') {
html = lines.map((line, idx) => `
<div style="display: flex; margin-bottom: 8px; align-items: flex-start;">
<div style="background: ${headerColor}; color: white; width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: bold; border-radius: 2px; margin-right: 8px; flex-shrink: 0;">
${idx + 1}
</div>
<div style="font-size: 11px;">${line}</div>
</div>
`).join('');
} else if (cardType === 'bullet') {
html = lines.map(line => `
<div style="display: flex; margin-bottom: 5px; align-items: flex-start;">
<div style="color: ${headerColor}; margin-right: 8px; flex-shrink: 0;">●</div>
<div style="font-size: 11px;">${line}</div>
</div>
`).join('');
} else if (cardType === 'legend') {
html = lines.map(line => {
const match = line.match(/^-\s*\((.*?):(.*?)\)\s*(.*)/);
if (match) {
const [, shape, color, text] = match;
const iconHtml = shape === 'line'
? `<div style="width: 16px; height: 2px; background: ${color}; margin-top: 8px;"></div>`
: `<div style="width: 12px; height: 12px; background: ${color}; border-radius: ${shape === 'circle' ? '50%' : '2px'}; margin-top: 2px;"></div>`;
return `
<div style="display: flex; margin-bottom: 8px; align-items: flex-start;">
<div style="width: 20px; margin-right: 8px; display: flex; justify-content: center; flex-shrink: 0;">
${iconHtml}
</div>
<div style="font-size: 11px;">${text}</div>
</div>
`;
}
return `<div style="margin-bottom: 5px; font-size: 11px;">${line}</div>`;
}).join('');
} else {
html = lines.map(line => `<div style="margin-bottom: 5px;">${line}</div>`).join('');
}
contentDiv.innerHTML = html;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Snap a position to the nearest grid interval, accounting for the object's dimension
* to ensure its edges or center align properly with the grid.
*/
export function snapPosition(pos, dimension, spacing) {
const offset = (dimension / 2) % spacing;
return Math.round((pos - offset) / spacing) * spacing + offset;
}
+14
View File
@@ -0,0 +1,14 @@
import { graphHandlers } from './handlers/graph.js';
import { uiHandlers } from './handlers/ui.js';
import { clipboardHandlers } from './handlers/clipboard.js';
import { ioHandlers } from './handlers/io.js';
/**
* actionHandlers - 단축키 동작을 기능별 모듈에서 취합하여 제공하는 매니페스트 객체입니다.
*/
export const actionHandlers = {
...graphHandlers,
...uiHandlers,
...clipboardHandlers,
...ioHandlers
};
@@ -0,0 +1,190 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
const generateShortId = () => Math.random().toString(36).substring(2, 6);
export const clipboardHandlers = {
copyAttributes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
const cell = selected[0];
const data = cell.getData() || {};
if (cell.isNode()) {
const whitelistedData = {};
const dataKeys = ['vendor', 'model', 'status', 'project', 'env', 'tags', 'color', 'asset_path'];
dataKeys.forEach(key => {
if (data[key] !== undefined) whitelistedData[key] = data[key];
});
const whitelistedAttrs = {
label: {
fill: cell.attr('label/fill'),
fontSize: cell.attr('label/fontSize'),
},
body: {
fill: cell.attr('body/fill'),
stroke: cell.attr('body/stroke'),
strokeWidth: cell.attr('body/strokeWidth'),
}
};
state.clipboardNodeData = whitelistedData;
state.clipboardNodeAttrs = whitelistedAttrs;
state.clipboardEdgeData = null; // Clear edge clipboard
logger.info(`Node attributes copied from ${cell.id}`);
} else if (cell.isEdge()) {
const edgeKeys = [
'color', 'routing', 'style', 'source_anchor', 'target_anchor',
'direction', 'is_tunnel', 'routing_offset', 'width', 'flow'
];
const edgeData = {};
edgeKeys.forEach(key => {
if (data[key] !== undefined) edgeData[key] = data[key];
});
state.clipboardEdgeData = edgeData;
state.clipboardNodeData = null; // Clear node clipboard
logger.info(`Edge attributes (Style) copied from ${cell.id}`);
}
},
pasteAttributes: async () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
const hasNodeData = !!state.clipboardNodeData;
const hasEdgeData = !!state.clipboardEdgeData;
if (!hasNodeData && !hasEdgeData) return;
const { handleEdgeUpdate } = await import('../../properties_sidebar/handlers/edge.js');
state.graph.batchUpdate(async () => {
for (const cell of selected) {
if (cell.isNode() && hasNodeData) {
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...state.clipboardNodeData });
if (state.clipboardNodeAttrs) {
const attrs = state.clipboardNodeAttrs;
if (attrs.label) {
if (attrs.label.fill) cell.attr('label/fill', attrs.label.fill);
if (attrs.label.fontSize) cell.attr('label/fontSize', attrs.label.fontSize);
}
if (attrs.body) {
if (attrs.body.fill) cell.attr('body/fill', attrs.body.fill);
if (attrs.body.stroke) cell.attr('body/stroke', attrs.body.stroke);
if (attrs.body.strokeWidth) cell.attr('body/strokeWidth', attrs.body.strokeWidth);
}
}
} else if (cell.isEdge() && hasEdgeData) {
// Apply Edge Formatting
// We use handleEdgeUpdate to ensure physical routers/anchors are updated
await handleEdgeUpdate(cell, state.clipboardEdgeData);
}
}
});
logger.high(`Format Painter applied to ${selected.length} elements.`);
import('../../persistence.js').then(m => m.markDirty());
},
copyNodes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.copy(selected);
logger.high("Nodes copied to clipboard (X6).");
},
pasteNodes: () => {
if (!state.graph || state.graph.isClipboardEmpty()) return;
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const isLogical = activeLayer && activeLayer.type === 'logical';
let pasted = state.graph.paste({ offset: 20 });
if (pasted && pasted.length > 0) {
if (isLogical) {
pasted.forEach(cell => { if (cell.isNode()) cell.remove(); });
pasted = pasted.filter(c => !c.isNode());
if (pasted.length === 0) {
import('/static/js/modules/i18n.js').then(({ t }) => {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
});
}
}
state.graph.batchUpdate(() => {
pasted.forEach(cell => {
if (cell.isNode()) {
const currentData = cell.getData() || {};
const currentLabel = currentData.label || cell.attr('label/text');
if (currentLabel) {
const newLabel = `${currentLabel}_${generateShortId()}`;
cell.setData({ ...currentData, label: newLabel, id: cell.id });
cell.attr('label/text', newLabel);
} else {
cell.setData({ ...currentData, id: cell.id });
}
}
});
});
state.graph.cleanSelection();
state.graph.select(pasted);
}
logger.high("Nodes pasted and randomized (Logical Filter applied).");
import('../../persistence.js').then(m => m.markDirty());
},
duplicateNodes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.copy(selected);
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const isLogical = activeLayer && activeLayer.type === 'logical';
let cloned = state.graph.paste({ offset: 30 });
if (cloned && cloned.length > 0) {
if (isLogical) {
cloned.forEach(cell => { if (cell.isNode()) cell.remove(); });
cloned = cloned.filter(c => !c.isNode());
if (cloned.length === 0) {
import('/static/js/modules/i18n.js').then(({ t }) => {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
});
}
}
state.graph.batchUpdate(() => {
cloned.forEach(cell => {
if (cell.isNode()) {
const currentData = cell.getData() || {};
const currentLabel = currentData.label || cell.attr('label/text');
if (currentLabel) {
const newLabel = `${currentLabel}_${generateShortId()}`;
cell.setData({ ...currentData, label: newLabel, id: cell.id });
cell.attr('label/text', newLabel);
} else {
cell.setData({ ...currentData, id: cell.id });
}
}
});
});
state.graph.cleanSelection();
state.graph.select(cloned);
}
logger.high("Nodes duplicated and randomized (Logical Filter applied).");
import('../../persistence.js').then(m => m.markDirty());
}
};
+111
View File
@@ -0,0 +1,111 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { alignNodes, moveNodes, distributeNodes } from '../../graph/alignment.js';
import { handleMenuAction } from '../../ui/context_menu/handlers.js';
export const graphHandlers = {
// ... cleanup ...
deleteSelected: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.removeCells(selected);
import('../../persistence.js').then(m => m.markDirty());
},
fitScreen: () => {
if (state.graph) {
state.graph.zoomToFit({ padding: 50 });
logger.high("Graph fit to screen (X6).");
}
},
zoomIn: () => state.graph?.zoom(0.2),
zoomOut: () => state.graph?.zoom(-0.2),
connectNodes: () => handleMenuAction('connect-solid'),
connectStraight: () => handleMenuAction('connect-straight'),
disconnectNodes: () => handleMenuAction('disconnect'),
undo: () => {
if (!state.graph) return;
try {
if (typeof state.graph.undo === 'function') {
state.graph.undo();
logger.info("Undo performed via graph.undo()");
} else {
const history = state.graph.getPlugin('history');
if (history) history.undo();
logger.info("Undo performed via history plugin");
}
} catch (e) {
logger.critical("Undo failed", e);
}
},
redo: () => {
if (!state.graph) return;
try {
if (typeof state.graph.redo === 'function') {
state.graph.redo();
logger.info("Redo performed via graph.redo()");
} else {
const history = state.graph.getPlugin('history');
if (history) history.redo();
logger.info("Redo performed via history plugin");
}
} catch (e) {
logger.critical("Redo failed", e);
}
},
arrangeLayout: () => {
logger.high("Auto-layout is currently disabled (Refactoring).");
},
bringToFront: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
selected.forEach(cell => cell.toFront({ deep: true }));
import('../../persistence.js').then(m => m.markDirty());
},
sendToBack: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
selected.forEach(cell => cell.toBack({ deep: true }));
import('../../persistence.js').then(m => m.markDirty());
},
toggleLock: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
selected.forEach(cell => {
const data = cell.getData() || {};
const isLocked = !data.locked;
cell.setData({ ...data, locked: isLocked });
cell.setProp('movable', !isLocked);
cell.setProp('deletable', !isLocked);
cell.setProp('rotatable', !isLocked);
if (isLocked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
});
import('../../persistence.js').then(m => m.markDirty());
logger.high(`Toggled lock for ${selected.length} elements.`);
},
alignTop: () => alignNodes('top'),
alignBottom: () => alignNodes('bottom'),
alignLeft: () => alignNodes('left'),
alignRight: () => alignNodes('right'),
alignMiddle: () => alignNodes('middle'),
alignCenter: () => alignNodes('center'),
distributeHorizontal: () => distributeNodes('horizontal'),
distributeVertical: () => distributeNodes('vertical'),
groupNodes: () => handleMenuAction('group'),
moveUp: () => moveNodes(0, -5),
moveDown: () => moveNodes(0, 5),
moveLeft: () => moveNodes(-5, 0),
moveRight: () => moveNodes(5, 0),
moveUpLarge: () => moveNodes(0, -(state.gridSpacing || 20)),
moveDownLarge: () => moveNodes(0, (state.gridSpacing || 20)),
moveLeftLarge: () => moveNodes(-(state.gridSpacing || 20), 0),
moveRightLarge: () => moveNodes((state.gridSpacing || 20), 0)
};
+10
View File
@@ -0,0 +1,10 @@
export const ioHandlers = {
exportJson: () => {
const exportBtn = document.getElementById('export-json');
if (exportBtn) exportBtn.click();
},
importJson: () => {
const importBtn = document.getElementById('import-json');
if (importBtn) importBtn.click();
}
};
+34
View File
@@ -0,0 +1,34 @@
import { state } from '../../state.js';
export const uiHandlers = {
toggleInventory: () => {
import('../../graph/analysis.js').then(m => m.toggleInventory());
},
toggleProperties: () => {
import('../../properties_sidebar/index.js').then(m => m.toggleSidebar());
},
toggleLayers: () => {
import('../../ui/layer_panel.js').then(m => m.toggleLayerPanel());
},
editLabel: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
import('../../properties_sidebar/index.js').then(m => {
m.toggleSidebar(true);
setTimeout(() => {
const labelInput = document.getElementById('prop-label');
if (labelInput) {
labelInput.focus();
labelInput.select();
}
}, 50);
});
},
cancel: () => {
if (!state.graph) return;
state.graph.cleanSelection();
import('../../properties_sidebar/index.js').then(m => m.toggleSidebar(false));
}
};
+94
View File
@@ -0,0 +1,94 @@
import { state } from '../state.js';
import { actionHandlers } from './handlers.js';
import { logger } from '../utils/logger.js';
/**
* initHotkeys - Initializes the keyboard shortcut system.
*/
export async function initHotkeys() {
if (window.__drawNET_Hotkeys_Initialized) {
logger.high("Hotkeys already initialized. Skipping.");
return;
}
window.__drawNET_Hotkeys_Initialized = true;
try {
const response = await fetch('/static/hotkeys.json');
const config = await response.json();
const hotkeys = config.hotkeys;
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const editableElements = ['TEXTAREA', 'INPUT'];
const isTyping = editableElements.includes(e.target.tagName) && e.target.id !== 'dark-mode-toggle';
const keyPressed = e.key.toLowerCase();
const keyCode = e.code;
for (const hk of hotkeys) {
// Match by physical code (preferred) or key string
const matchCode = hk.code && hk.code === keyCode;
const matchKey = hk.key && hk.key.toLowerCase() === keyPressed;
if (!(matchCode || matchKey)) continue;
// Match modifiers strictly
const matchCtrl = !!hk.ctrl === e.ctrlKey;
const matchAlt = !!hk.alt === e.altKey;
const matchShift = !!hk.shift === e.shiftKey;
if (matchCtrl && matchAlt && matchShift) {
const allowWhileTyping = hk.alwaysEnabled === true;
if (isTyping && !allowWhileTyping) break;
logger.info(`Hotkey Match - ${hk.action} (${hk.key})`);
if (allowWhileTyping || !isTyping) {
e.preventDefault();
}
const handler = actionHandlers[hk.action];
if (handler) {
handler();
}
return;
}
}
});
logger.high("Hotkeys module initialized (X6).");
} catch (err) {
logger.critical("Failed to load hotkeys configuration (X6).", err);
}
// Ctrl + Wheel: Zoom handling
window.addEventListener('wheel', (e) => {
if (e.ctrlKey) {
e.preventDefault();
if (!state.graph) return;
const delta = e.deltaY > 0 ? -0.1 : 0.1;
state.graph.zoom(delta, { center: { x: e.clientX, y: e.clientY } });
}
}, { passive: false });
// Global Key State Tracking (for Ctrl+Drag copy)
// Extra guard: Check e.ctrlKey/metaKey on mouse events to auto-heal mismatched state
const syncCtrlState = (e) => {
const isModifier = e.ctrlKey || e.metaKey;
if (state.isCtrlPressed !== isModifier) {
state.isCtrlPressed = isModifier;
}
};
window.addEventListener('mousedown', syncCtrlState, true);
window.addEventListener('mousemove', syncCtrlState, true);
window.addEventListener('keydown', (e) => {
if (e.key === 'Control' || e.key === 'Meta') state.isCtrlPressed = true;
}, true);
window.addEventListener('keyup', (e) => {
if (e.key === 'Control' || e.key === 'Meta') state.isCtrlPressed = false;
}, true);
window.addEventListener('blur', () => {
state.isCtrlPressed = false; // Reset on window blur to avoid stuck state
});
}
+50
View File
@@ -0,0 +1,50 @@
import { state } from './state.js';
import { logger } from './utils/logger.js';
export async function initI18n() {
const lang = state.language;
try {
const response = await fetch(`/static/locales/${lang}.json`);
const translations = await response.json();
const enResponse = await fetch('/static/locales/en.json');
const enTranslations = await enResponse.json();
state.i18n = { ...enTranslations, ...translations };
applyTranslations();
logger.high(`i18n initialized for [${lang}]`);
} catch (err) {
logger.critical("Failed to load locales", err);
}
}
export function t(key, params = {}) {
let translation = (state.i18n && state.i18n[key]) || key;
// Support for placeholders like {count} or {date}
for (const [pKey, pVal] of Object.entries(params)) {
translation = translation.replace(`{${pKey}}`, pVal);
}
return translation;
}
export function applyTranslations() {
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = t(key);
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.placeholder = translation;
} else if (el.title) {
el.title = translation;
} else {
el.innerText = translation;
}
});
// Handle Title specifically if needed
const searchInput = document.getElementById('asset-search');
if (searchInput) searchInput.placeholder = t('search_placeholder');
}
+102
View File
@@ -0,0 +1,102 @@
import { state } from './state.js';
import { getProjectData, restoreProjectData } from './graph/io/index.js';
import { getSettings } from './settings/store.js';
import { STORAGE_KEYS } from './constants.js';
import { logger } from './utils/logger.js';
import { t } from './i18n.js';
import { showToast } from './ui/toast.js';
const AUTOSAVE_KEY = STORAGE_KEYS.AUTOSAVE;
let isDirty = false;
/**
* initPersistence - 자동 저장 및 세션 복구 초기화
*/
export function initPersistence() {
// 1. 페이지 이탈 가드
window.addEventListener('beforeunload', (e) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
});
// 2. 30초마다 자동 저장 (변경사항 있을 때만)
setInterval(() => {
if (isDirty) {
saveToLocal();
}
}, 30000);
// 3. X6 변경 감지
if (state.graph) {
state.graph.on('cell:added cell:removed cell:changed', () => {
markDirty();
});
}
// 4. 세션 복구 확인
checkRecovery();
}
export function markDirty() {
isDirty = true;
}
export function clearDirty() {
isDirty = false;
}
export function clearLastState() {
localStorage.removeItem(AUTOSAVE_KEY);
isDirty = false;
logger.info("Local auto-save session cleared.");
}
function saveToLocal() {
const data = getProjectData();
if (data) {
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
clearDirty();
showToast(t('msg_autosaved'), 'success', 2000);
logger.info("Progress auto-saved to local storage (X6).");
}
}
async function checkRecovery() {
const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return;
try {
const data = JSON.parse(saved);
// v3.0 포맷 확인 (graphJson 필드 존재 여부)
const isV3 = data.header?.app === "drawNET Premium" && data.graphJson;
if (!isV3) {
// 구버전 자동저장 데이터는 무효화하고 삭제
logger.high("Legacy auto-save format detected. Clearing.");
localStorage.removeItem(AUTOSAVE_KEY);
return;
}
const nodeCount = (data.graphJson?.cells || []).filter(c => c.shape !== 'edge').length;
if (nodeCount === 0) return;
const settings = getSettings();
setTimeout(() => {
if (settings.autoLoadLast) {
logger.high("Auto-loading last session per user settings.");
showToast(t('msg_restoring'), 'info', 3000);
restoreProjectData(data);
} else if (confirm(t('confirm_recovery'))) {
restoreProjectData(data);
} else {
localStorage.removeItem(AUTOSAVE_KEY);
}
}, 1200);
} catch (e) {
logger.critical("Failed to parse auto-save data.", e);
localStorage.removeItem(AUTOSAVE_KEY);
}
}
@@ -0,0 +1,88 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* handleEdgeUpdate - Synchronizes property sidebar changes to the X6 Edge cell.
* Ensures data, attributes (markers, colors), anchors, and routers are all updated.
*/
export async function handleEdgeUpdate(cell, data) {
if (cell.isNode()) return;
const updates = {
id: cell.id,
source: cell.getSource().cell,
target: cell.getTarget().cell,
source_port: cell.getSource().port,
target_port: cell.getTarget().port,
// Priority: DOM Element > Provided Data (Format Painter) > Default/Old Data
color: document.getElementById('prop-color')?.value || data?.color,
style: document.getElementById('prop-style')?.value || data?.style,
routing: document.getElementById('prop-routing')?.value || data?.routing,
source_anchor: document.getElementById('prop-src-anchor')?.value || data?.source_anchor || 'orth',
target_anchor: document.getElementById('prop-dst-anchor')?.value || data?.target_anchor || 'orth',
direction: document.getElementById('prop-direction')?.value || data?.direction || 'none',
is_tunnel: document.getElementById('prop-is-tunnel') ? !!document.getElementById('prop-is-tunnel').checked : !!data?.is_tunnel,
width: document.getElementById('prop-width') ? parseFloat(document.getElementById('prop-width').value) : (parseFloat(data?.width) || 2),
routing_offset: document.getElementById('prop-routing-offset') ? parseInt(document.getElementById('prop-routing-offset').value) : (parseInt(data?.routing_offset) || 20),
description: document.getElementById('prop-description') ? document.getElementById('prop-description').value : data?.description,
tags: document.getElementById('prop-tags') ? document.getElementById('prop-tags').value.split(',').map(t => t.trim()).filter(t => t) : (data?.tags || []),
label: document.getElementById('prop-label') ? document.getElementById('prop-label').value : data?.label,
locked: document.getElementById('prop-locked') ? !!document.getElementById('prop-locked').checked : !!data?.locked
};
// 1. Update Cell Data (Single Source of Truth)
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...updates });
// 2. Sync Visual Attributes (Arrows, Colors, Styles)
// We reuse the central mapping logic from styles.js for consistency
const { getX6EdgeConfig } = await import('/static/js/modules/graph/styles.js');
const edgeConfig = getX6EdgeConfig(updates);
// Apply Attributes (Markers, Stroke, Dasharray)
// Note: setAttrs merges by default, but we use it to apply the calculated style object
if (edgeConfig.attrs) {
cell.setAttrs(edgeConfig.attrs);
}
// Apply Labels
if (edgeConfig.labels) {
cell.setLabels(edgeConfig.labels);
}
// 3. Anchor & Router Updates (Physical Pathing)
if (edgeConfig.source) cell.setSource(edgeConfig.source);
if (edgeConfig.target) cell.setTarget(edgeConfig.target);
// Apply Router and Connector (Physical Pathing)
// Clear everything first to prevent old state persistence
cell.setVertices([]);
cell.setRouter(null);
cell.setConnector('normal');
if (edgeConfig.router) {
cell.setRouter(edgeConfig.router);
}
if (edgeConfig.connector) {
cell.setConnector(edgeConfig.connector);
}
// 4. Mark Project as Dirty and Apply Locking/Selection Tools
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
// Selection Tool Preservation: If currently selected, ensure vertices are visible
const isSelected = state.graph.isSelected(cell);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
if (isSelected) {
cell.addTools(['vertices']);
}
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
@@ -0,0 +1,136 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
export async function handleNodeUpdate(cell, data) {
const isNode = cell.isNode();
if (!isNode) return;
const updates = {
// Priority: DOM Element > Provided Data (Format Painter) > Default/Old Data
type: document.getElementById('prop-type')?.value || data?.type,
label: document.getElementById('prop-label') ? document.getElementById('prop-label').value : data?.label,
is_group: document.getElementById('prop-is-group') ? !!document.getElementById('prop-is-group').checked : !!data?.is_group,
padding: document.getElementById('prop-padding') ? parseInt(document.getElementById('prop-padding').value) : (data?.padding || undefined),
slots: document.getElementById('prop-slots') ? parseInt(document.getElementById('prop-slots').value) : (data?.slots || undefined),
color: document.getElementById('prop-label-color')?.value || data?.color,
fill: document.getElementById('prop-fill-color')?.value || data?.fill,
border: document.getElementById('prop-border-color')?.value || data?.border,
label_pos: document.getElementById('prop-label-pos')?.value || data?.label_pos,
// PM Standard Attributes
vendor: document.getElementById('prop-vendor') ? document.getElementById('prop-vendor').value : data?.vendor,
model: document.getElementById('prop-model') ? document.getElementById('prop-model').value : data?.model,
ip: document.getElementById('prop-ip') ? document.getElementById('prop-ip').value : data?.ip,
status: document.getElementById('prop-status')?.value || data?.status,
project: document.getElementById('prop-project') ? document.getElementById('prop-project').value : data?.project,
env: document.getElementById('prop-env')?.value || data?.env,
asset_tag: document.getElementById('prop-asset-tag') ? document.getElementById('prop-asset-tag').value : data?.asset_tag,
tags: document.getElementById('prop-tags') ? document.getElementById('prop-tags').value.split(',').map(t => t.trim()).filter(t => t) : (data?.tags || []),
description: document.getElementById('prop-description') ? document.getElementById('prop-description').value : data?.description,
locked: document.getElementById('prop-locked') ? !!document.getElementById('prop-locked').checked : !!data?.locked
};
const pureLabel = updates.label || '';
cell.attr('label/text', pureLabel);
if (updates.color) cell.attr('label/fill', updates.color);
// Apply Fill and Border to body (for primitives and groups)
if (updates.fill) {
cell.attr('body/fill', updates.fill);
if (updates.is_group) updates.background = updates.fill; // Sync for group logic
}
if (updates.border) {
cell.attr('body/stroke', updates.border);
if (updates.is_group) updates['border-color'] = updates.border; // Sync for group logic
}
if (updates.label_pos) {
const { getLabelAttributes } = await import('/static/js/modules/graph/styles.js');
const labelAttrs = getLabelAttributes(updates.label_pos, updates.type, updates.is_group);
Object.keys(labelAttrs).forEach(key => {
cell.attr(`label/${key}`, labelAttrs[key]);
});
}
// Handle group membership via selection (parentId is now a UUID)
const parentId = document.getElementById('prop-parent')?.value;
const currentParent = cell.getParent();
// Case 1: Parent added or changed
if (parentId && (!currentParent || currentParent.id !== parentId)) {
const parent = state.graph.getCellById(parentId);
if (parent) {
parent.addChild(cell);
updates.parent = parentId;
saveUpdates();
return;
}
}
// Case 2: Parent removed (Ungrouping)
if (!parentId && currentParent) {
// 부모 해제 시 경고 설정 확인
const triggerUngroup = async () => {
const { getSettings } = await import('../../settings/store.js');
const settings = getSettings();
if (settings.confirmUngroup !== false) {
const { showConfirmModal } = await import('../../ui/utils.js');
const { t } = await import('../../i18n.js');
showConfirmModal({
title: t('confirm_ungroup_title') || 'Disconnect from Group',
message: t('confirm_ungroup_msg') || 'Are you sure you want to remove this object from its parent group?',
showDontAskAgain: true,
checkboxKey: 'confirmUngroup',
onConfirm: () => {
currentParent.removeChild(cell);
updates.parent = null;
saveUpdates();
},
onCancel: () => {
import('../index.js').then(m => m.renderProperties());
}
});
} else {
currentParent.removeChild(cell);
updates.parent = null;
saveUpdates();
}
};
triggerUngroup();
return;
}
// Case 3: Parent unchanged or no parent (Regular update)
saveUpdates();
function saveUpdates() {
// 최종 데이터 병합 및 저장 (id, layerId 등 기존 메타데이터 보존)
const currentData = cell.getData() || {};
const finalData = { ...currentData, ...updates };
cell.setData(finalData);
// [Instance #2245] Recursive Propagation:
// If this is a group, "poke" all descendants to trigger their change events,
// ensuring the sidebar for any selected child reflects the new parent name.
if (updates.is_group) {
const descendants = cell.getDescendants();
descendants.forEach(child => {
const childData = child.getData() || {};
const syncTime = Date.now();
// We add a timestamp to child data to force X6 to fire 'change:data'
child.setData({ ...childData, _parent_updated: syncTime }, { silent: false });
});
}
// Apply Locking Constraints
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
cell.setProp('rotatable', !updates.locked);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
}
}
@@ -0,0 +1,50 @@
import { state } from '../../state.js';
import { renderRichContent } from '../../graph/styles/utils.js';
export async function handleRichCardUpdate(cell, data) {
const isNode = cell.isNode();
if (!isNode) return;
const updates = {
headerText: document.getElementById('prop-header-text')?.value,
headerColor: document.getElementById('prop-header-color')?.value,
headerAlign: document.getElementById('prop-header-align')?.value || 'left',
cardType: document.getElementById('prop-card-type')?.value,
contentAlign: document.getElementById('prop-content-align')?.value || 'left',
content: document.getElementById('prop-card-content')?.value,
locked: !!document.getElementById('prop-locked')?.checked
};
// 1. Sync to X6 Attributes
cell.attr('headerLabel/text', updates.headerText);
cell.attr('header/fill', updates.headerColor);
cell.attr('body/stroke', updates.headerColor);
// Apply header alignment
const anchor = updates.headerAlign === 'center' ? 'middle' : (updates.headerAlign === 'right' ? 'end' : 'start');
const x = updates.headerAlign === 'center' ? 0.5 : (updates.headerAlign === 'right' ? '100%' : 10);
const x2 = updates.headerAlign === 'right' ? -10 : 0;
cell.attr('headerLabel/textAnchor', anchor);
cell.attr('headerLabel/refX', x);
cell.attr('headerLabel/refX2', x2);
// 2. Render Content via HTML inside ForeignObject
renderRichContent(cell, updates);
// 3. Update Cell Data
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...updates });
// 4. Handle Lock State
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
@@ -0,0 +1,237 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
import { getNodeTemplate, getEdgeTemplate, getMultiSelectTemplate } from './templates.js';
import { getRichCardTemplate } from './templates/rich_card.js';
import { handleNodeUpdate } from './handlers/node.js';
import { handleEdgeUpdate } from './handlers/edge.js';
import { handleRichCardUpdate } from './handlers/rich_card.js';
// --- Utility: Simple Debounce to prevent excessive updates ---
function debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
let sidebarElement = null;
export function initPropertiesSidebar() {
sidebarElement = document.getElementById('properties-sidebar');
if (!sidebarElement) return;
const closeBtn = sidebarElement.querySelector('.close-sidebar');
const footerBtn = sidebarElement.querySelector('#sidebar-apply');
const content = sidebarElement.querySelector('.sidebar-content');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
unhighlight();
toggleSidebar(false);
});
}
if (footerBtn) {
footerBtn.addEventListener('click', () => {
unhighlight();
applyChangesGlobal();
toggleSidebar(false);
});
}
// Event Delegation: Register once at initialization to prevent memory leaks and redundant listeners
if (content) {
content.addEventListener('input', (e) => {
const input = e.target;
if (!input.classList.contains('prop-input')) return;
});
content.addEventListener('change', (e) => {
const input = e.target;
if (!input.classList.contains('prop-input')) return;
const isText = (input.type === 'text' || input.tagName === 'TEXTAREA' || input.type === 'number');
if (!isText) {
applyChangesGlobal();
}
});
content.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
applyChangesGlobal();
toggleSidebar(false);
}
});
}
if (state.graph) {
state.graph.on('selection:changed', ({ selected }) => {
unhighlight();
renderProperties();
if (selected && selected.length > 0) toggleSidebar(true);
});
const safeRender = () => {
const active = document.activeElement;
const isEditing = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
if (sidebarElement && sidebarElement.contains(active) && isEditing) {
return;
}
// Use setTimeout to ensure X6 data sync is complete before re-rendering
setTimeout(() => renderProperties(), 0);
};
state.graph.on('cell:change:data', ({ options }) => {
if (options && options.silent) return;
safeRender();
});
state.graph.on('cell:change:attrs', ({ options }) => {
if (options && options.silent) return;
safeRender();
});
}
}
const applyChangesGlobal = async () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length !== 1) return;
const cell = selected[0];
const data = cell.getData() || {};
if (cell.shape === 'drawnet-rich-card') {
await handleRichCardUpdate(cell, data);
} else if (cell.isNode()) {
await handleNodeUpdate(cell, data);
} else {
await handleEdgeUpdate(cell, data);
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
};
const debouncedApplyGlobal = debounce(applyChangesGlobal, 300);
export function toggleSidebar(open) {
if (!sidebarElement) return;
if (open === undefined) {
sidebarElement.classList.toggle('open');
} else if (open) {
sidebarElement.classList.add('open');
} else {
sidebarElement.classList.remove('open');
}
}
export function renderProperties() {
if (!state.graph || !sidebarElement) return;
const selected = state.graph.getSelectedCells();
const content = sidebarElement.querySelector('.sidebar-content');
if (!content) return;
logger.info(`[Sidebar] renderProperties start`, {
selectedCount: selected.length,
firstId: selected[0]?.id
});
if (!selected || selected.length === 0) {
content.innerHTML = `<div style="color: #64748b; text-align: center; margin-top: 40px;">Select an object to edit properties</div>`;
return;
}
if (selected.length > 1) {
content.innerHTML = getMultiSelectTemplate(selected.length);
content.querySelectorAll('.align-btn').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-action');
if (action) import('/static/js/modules/ui/context_menu/handlers.js').then(m => m.handleMenuAction(action));
});
});
const lockToggle = content.querySelector('#prop-locked');
if (lockToggle) {
lockToggle.addEventListener('change', () => {
const isLocked = lockToggle.checked;
selected.forEach(cell => {
const data = cell.getData() || {};
cell.setData({ ...data, locked: isLocked });
cell.setProp('movable', !isLocked);
cell.setProp('deletable', !isLocked);
if (cell.isNode()) cell.setProp('rotatable', !isLocked);
if (isLocked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
});
import('/static/js/modules/persistence.js').then(m => m.markDirty());
});
}
return;
}
const cell = selected[0];
const data = cell.getData() || {};
const isNode = cell.isNode();
if (isNode) {
const allGroups = state.graph.getNodes().filter(n => n.getData()?.is_group && n.id !== cell.id);
const liveParent = cell.getParent();
const uiData = { ...data, id: cell.id, parent: liveParent ? liveParent.id : null };
if (cell.shape === 'drawnet-rich-card') {
content.innerHTML = getRichCardTemplate(cell);
} else {
content.innerHTML = getNodeTemplate(uiData, allGroups);
}
} else {
content.innerHTML = getEdgeTemplate({ ...data, id: cell.id });
}
if (!isNode) {
initAnchorHighlighting(cell, content);
}
}
function unhighlight() {
if (!state.graph) return;
state.graph.getNodes().forEach(node => {
const data = node.getData() || {};
if (data._isHighlighting) {
node.attr('body/stroke', data._oldStroke, { silent: true });
node.attr('body/strokeWidth', data._oldWidth, { silent: true });
node.setData({ ...data, _isHighlighting: false }, { silent: true });
}
});
}
function initAnchorHighlighting(cell, container) {
const srcSelect = container.querySelector('#prop-src-anchor');
const dstSelect = container.querySelector('#prop-dst-anchor');
const highlight = (isSource) => {
const endpoint = isSource ? cell.getSource() : cell.getTarget();
if (endpoint && endpoint.cell) {
const targetCell = state.graph.getCellById(endpoint.cell);
if (targetCell && !targetCell.getData()?._isHighlighting) {
targetCell.setData({
_oldStroke: targetCell.attr('body/stroke'),
_oldWidth: targetCell.attr('body/strokeWidth'),
_isHighlighting: true
}, { silent: true });
targetCell.attr('body/stroke', isSource ? '#10b981' : '#ef4444', { silent: true });
targetCell.attr('body/strokeWidth', 6, { silent: true });
}
}
};
srcSelect?.addEventListener('mouseenter', () => highlight(true));
srcSelect?.addEventListener('mouseleave', unhighlight);
dstSelect?.addEventListener('mouseenter', () => highlight(false));
dstSelect?.addEventListener('mouseleave', unhighlight);
}
@@ -0,0 +1,84 @@
/**
* Synchronizes the changes made in the properties sidebar back to the DSL in the editor.
*/
export async function syncToDSL(ele, oldLabel) {
const editor = document.getElementById('editor');
if (!editor) return;
let dsl = editor.value;
const data = ele.getData() || {};
const isNode = ele.isNode();
if (isNode) {
if (data.is_group) {
// Update group definition: group Name { attrs }
const regex = new RegExp(`^group\\s+${oldLabel}(\\s*\\{.*?\\})?`, 'mi');
let attrParts = [];
if (data.background) attrParts.push(`background: ${data.background}`);
if (data.padding) attrParts.push(`padding: ${data.padding}`);
const attrs = attrParts.length > 0 ? `{ ${attrParts.join(', ')} }` : '';
if (regex.test(dsl)) {
dsl = dsl.replace(regex, `group ${data.label} ${attrs}`);
} else {
dsl += `\ngroup ${data.label} ${attrs}\n`;
}
// If label changed, update all 'in' lines
if (oldLabel !== data.label) {
const memberRegex = new RegExp(`\\s+in\\s+${oldLabel}\\b`, 'gi');
dsl = dsl.replace(memberRegex, ` in ${data.label}`);
}
} else {
// Update node definition: Label [Type] { attrs }
const regex = new RegExp(`^\\s*${oldLabel}(\\s*\\[.*?\\])?(\\s*\\{.*?\\})?\\s*(?!->)$`, 'm');
const typeStr = data.type && data.type !== 'default' ? ` [${data.type}]` : '';
const parentStr = data.parent ? ` { parent: ${data.parent} }` : '';
if (regex.test(dsl)) {
dsl = dsl.replace(regex, `${data.label}${typeStr}${parentStr}`);
} else {
dsl += `\n${data.label}${typeStr}${parentStr}\n`;
}
}
} else {
// Update edge definition: Source -> Target { attrs }
const sourceNode = ele.getSourceNode();
const targetNode = ele.getTargetNode();
const sourceLabel = sourceNode?.getData()?.label || sourceNode?.id;
const targetLabel = targetNode?.getData()?.label || targetNode?.id;
if (sourceLabel && targetLabel) {
const escSrc = sourceLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escDst = targetLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match the whole line for the edge
const edgeRegex = new RegExp(`^\\s*${escSrc}(?:\\s*\\[.*?\\])?(?:\\s*:[a-z]+)?\\s*->\\s*${escDst}(?:\\s*\\[.*?\\])?(?:\\s*:[a-z]+)?\\s*(\\{.*?\\})?\\s*$`, 'mi');
let attrParts = [];
if (data.color) attrParts.push(`color: ${data.color}`);
if (data.style) attrParts.push(`style: ${data.style}`);
if (data.source_anchor) attrParts.push(`source_anchor: ${data.source_anchor}`);
if (data.target_anchor) attrParts.push(`target_anchor: ${data.target_anchor}`);
if (data.direction && data.direction !== 'forward') attrParts.push(`direction: ${data.direction}`);
if (data.is_tunnel) attrParts.push(`is_tunnel: true`);
if (data.label) attrParts.push(`label: ${data.label}`);
const attrs = attrParts.length > 0 ? ` { ${attrParts.join(', ')} }` : '';
if (edgeRegex.test(dsl)) {
dsl = dsl.replace(edgeRegex, (match) => {
const baseMatch = match.split('{')[0].trim();
return `${baseMatch}${attrs}`;
});
} else {
dsl += `\n${sourceLabel} -> ${targetLabel}${attrs}\n`;
}
}
}
editor.value = dsl;
// Trigger analyze with force=true to avoid race
const editorModule = await import('../editor.js');
editorModule.analyzeTopology(true);
}
@@ -0,0 +1,5 @@
import { getNodeTemplate } from './templates/node.js';
import { getEdgeTemplate } from './templates/edge.js';
import { getMultiSelectTemplate } from './templates/multi.js';
export { getNodeTemplate, getEdgeTemplate, getMultiSelectTemplate };
@@ -0,0 +1,116 @@
import { t } from '../../i18n.js';
/**
* Generates the HTML for edge properties.
*/
export function getEdgeTemplate(data) {
return `
<div class="prop-group horizontal">
<label>${t('prop_id')}</label>
<input type="text" class="prop-input" value="${data.id}" readonly style="opacity: 0.5; font-size: 11px;">
</div>
<div class="prop-group">
<label>${t('prop_label')}</label>
<input type="text" class="prop-input" id="prop-label" value="${data.label || ''}">
</div>
<div class="prop-group">
<label>${t('prop_tags')}</label>
<input type="text" class="prop-input" id="prop-tags" value="${(data.tags || []).join(', ')}">
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_color')}</label>
<input type="color" class="prop-input" id="prop-color" value="${data.color || '#94a3b8'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_style')}</label>
<select class="prop-input" id="prop-style">
<option value="solid" ${data.style === 'solid' ? 'selected' : ''}>${t('solid')}</option>
<option value="dashed" ${data.style === 'dashed' ? 'selected' : ''}>${t('dashed')}</option>
<option value="dotted" ${data.style === 'dotted' ? 'selected' : ''}>${t('dotted')}</option>
<option value="double" ${data.style === 'double' ? 'selected' : ''}>${t('double')}</option>
</select>
</div>
</div>
<div class="prop-group horizontal">
<label>${t('prop_width') || 'Width'}</label>
<input type="number" class="prop-input" id="prop-width" value="${data.width || 2}" min="1" max="10" step="0.5">
</div>
<div class="prop-group">
<label>${t('prop_routing')}</label>
<select class="prop-input" id="prop-routing">
<option value="manhattan" ${data.routing === 'manhattan' || !data.routing ? 'selected' : ''}>${t('manhattan')}</option>
<option value="u-shape" ${data.routing === 'u-shape' ? 'selected' : ''}>${t('u_shape')}</option>
<option value="orthogonal" ${data.routing === 'orthogonal' ? 'selected' : ''}>${t('orthogonal')}</option>
<option value="straight" ${data.routing === 'straight' ? 'selected' : ''}>${t('straight')}</option>
<option value="metro" ${data.routing === 'metro' ? 'selected' : ''}>${t('metro')}</option>
</select>
</div>
<div class="prop-row">
<div class="prop-group">
<label style="color: #10b981; font-weight: bold;">● START</label>
<select class="prop-input" id="prop-src-anchor" style="border-left: 3px solid #10b981;">
<option value="orth" ${data.source_anchor === 'orth' || !data.source_anchor ? 'selected' : ''}>${t('orth')}</option>
<option value="center" ${data.source_anchor === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="left" ${data.source_anchor === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${data.source_anchor === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="top" ${data.source_anchor === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${data.source_anchor === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
</select>
</div>
<div class="prop-group">
<label style="color: #ef4444; font-weight: bold;">● TARGET</label>
<select class="prop-input" id="prop-dst-anchor" style="border-left: 3px solid #ef4444;">
<option value="orth" ${data.target_anchor === 'orth' || !data.target_anchor ? 'selected' : ''}>${t('orth')}</option>
<option value="center" ${data.target_anchor === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="left" ${data.target_anchor === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${data.target_anchor === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="top" ${data.target_anchor === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${data.target_anchor === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
</select>
</div>
</div>
<div class="prop-group horizontal">
<label>${t('prop_direction')}</label>
<select class="prop-input" id="prop-direction">
<option value="forward" ${data.direction === 'forward' ? 'selected' : ''}>${t('forward')}</option>
<option value="backward" ${data.direction === 'backward' ? 'selected' : ''}>${t('backward')}</option>
<option value="both" ${data.direction === 'both' ? 'selected' : ''}>${t('both')}</option>
<option value="none" ${data.direction === 'none' || !data.direction ? 'selected' : ''}>${t('none')}</option>
</select>
</div>
<div class="prop-row">
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch" style="padding: 10px;">
<span style="font-size: 11px;">TUNNEL</span>
<input type="checkbox" id="prop-is-tunnel" class="prop-input" ${data.is_tunnel ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px;">
<span style="color: #ef4444; font-size: 11px;"><i class="fas fa-lock"></i> LOCK</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border);">
<div class="prop-group horizontal">
<label>${t('prop_routing_offset')}</label>
<input type="number" class="prop-input" id="prop-routing-offset" value="${data.routing_offset || 20}" min="0" max="200" step="5">
</div>
<div class="prop-group">
<label>${t('prop_description')}</label>
<textarea class="prop-input" id="prop-description" rows="2" style="resize: vertical; min-height: 48px; font-size: 12px;">${data.description || ''}</textarea>
</div>
</div>
`;
}
@@ -0,0 +1,50 @@
import { state } from '../../state.js';
import { t } from '../../i18n.js';
export function getMultiSelectTemplate(count) {
const t_title = (state.i18n && state.i18n['multi_select_title']) || 'Multiple Selected';
const t_align = (state.i18n && state.i18n['alignment']) || 'Alignment';
const t_dist = (state.i18n && state.i18n['distribution']) || 'Distribution';
return `
<div class="multi-select-header">
<i class="fas fa-layer-group"></i>
<span>${count} ${t_title}</span>
</div>
<div class="prop-group">
<label>${t_align}</label>
<div class="alignment-grid">
<button class="align-btn" data-action="alignTop" title="${t('align_top')} (Shift+1)"><i class="fas fa-align-left fa-rotate-90"></i></button>
<button class="align-btn" data-action="alignBottom" title="${t('align_bottom')} (Shift+2)"><i class="fas fa-align-right fa-rotate-90"></i></button>
<button class="align-btn" data-action="alignLeft" title="${t('align_left')} (Shift+3)"><i class="fas fa-align-left"></i></button>
<button class="align-btn" data-action="alignRight" title="${t('align_right')} (Shift+4)"><i class="fas fa-align-right"></i></button>
<button class="align-btn" data-action="alignMiddle" title="${t('align_middle')} (Shift+5)"><i class="fas fa-grip-lines"></i></button>
<button class="align-btn" data-action="alignCenter" title="${t('align_center')} (Shift+6)"><i class="fas fa-grip-lines-vertical"></i></button>
</div>
</div>
<div class="prop-group">
<label>${t_dist}</label>
<div class="alignment-grid">
<button class="align-btn" data-action="distributeHorizontal" title="${t('distribute_horizontal')} (Shift+7)"><i class="fas fa-arrows-alt-h"></i></button>
<button class="align-btn" data-action="distributeVertical" title="${t('distribute_vertical')} (Shift+8)"><i class="fas fa-arrows-alt-v"></i></button>
</div>
</div>
<div class="toggle-group" style="margin-top: 16px; border-top: 1px solid var(--panel-border); padding-top: 16px;">
<label class="toggle-switch danger">
<span style="color: #ef4444; font-weight: 800;"><i class="fas fa-lock"></i> ${t('prop_locked')} (Bulk)</span>
<input type="checkbox" id="prop-locked" class="prop-input">
<div class="switch-slider"></div>
</label>
</div>
<div style="margin-top: 20px; padding: 12px; background: rgba(59, 130, 246, 0.1); border-radius: 8px; border: 1px dashed rgba(59, 130, 246, 0.3);">
<p style="font-size: 11px; color: #94a3b8; margin: 0; line-height: 1.4;">
<i class="fas fa-lightbulb" style="color: #fbbf24; margin-right: 4px;"></i>
Tip: Use <b>Shift + 1~8</b> to quickly <b>align and distribute</b> selected nodes without opening the sidebar.
</p>
</div>
`;
}
@@ -0,0 +1,163 @@
import { t } from '../../i18n.js';
/**
* Generates the HTML for node properties.
*/
export function getNodeTemplate(data, allGroups) {
return `
<div class="prop-group horizontal">
<label>${t('prop_id')}</label>
<input type="text" class="prop-input" value="${data.id}" readonly style="opacity: 0.5; font-size: 11px;">
</div>
<div class="prop-group">
<label>${t('prop_label')}</label>
<input type="text" class="prop-input" id="prop-label" value="${data.label || ''}">
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_type')}</label>
<input type="text" class="prop-input" id="prop-type" value="${data.type || ''}">
</div>
<div class="prop-group">
<label>${t('prop_parent')}</label>
<select class="prop-input" id="prop-parent">
<option value="">${t('none')}</option>
${allGroups.map(g => {
const gData = g.getData() || {};
const label = gData.label || g.id;
// Debug log to confirm what the template sees
// console.log(`[Template:Node] Parent Option: ID=${g.id}, Label=${label}`);
return `<option value="${g.id}" ${data.parent === g.id ? 'selected' : ''}>${label}</option>`;
}).join('')}
</select>
</div>
</div>
<div class="prop-row" style="margin-bottom: 8px;">
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch" style="padding: 10px;">
<span style="font-size: 11px;">GROUP</span>
<input type="checkbox" id="prop-is-group" class="prop-input" ${data.is_group ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px;">
<span style="color: #ef4444; font-size: 11px;"><i class="fas fa-lock"></i> LOCK</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_label_color')}</label>
<input type="color" class="prop-input" id="prop-label-color" value="${data.color || data['text-color'] || '#64748b'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_label_pos')}</label>
<select class="prop-input" id="prop-label-pos">
${(() => {
const nodeType = (data.type || '').toLowerCase();
const defaultPos = data.is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label'].includes(nodeType) ? 'center' : 'bottom');
const currentPos = data.label_pos || defaultPos;
return `
<option value="top" ${currentPos === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${currentPos === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
<option value="left" ${currentPos === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${currentPos === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="center" ${currentPos === 'center' ? 'selected' : ''}>${t('center')}</option>
`;
})()}
</select>
</div>
</div>
${(() => {
const nodeType = (data.type || '').toLowerCase();
const primitives = ['rect', 'circle', 'rounded-rect', 'text-box', 'label', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input', 'rack'];
if (data.is_group || primitives.includes(nodeType)) {
return `
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_fill_color')}</label>
<input type="color" class="prop-input" id="prop-fill-color" value="${data.fill || data.background || '#ffffff'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_border_color')}</label>
<input type="color" class="prop-input" id="prop-border-color" value="${data.border || data['border-color'] || '#94a3b8'}" style="height: 34px; padding: 2px;">
</div>
</div>
`;
}
return '';
})()}
<div class="prop-row">
${data.is_group ? `
<div class="prop-group">
<label>${t('prop_padding')}</label>
<input type="number" class="prop-input" id="prop-padding" value="${data.padding || 40}" min="0" max="200">
</div>
` : ''}
${data.type === 'rack' ? `
<div class="prop-group">
<label>${t('prop_slots')}</label>
<input type="number" class="prop-input" id="prop-slots" value="${data.slots || 42}" min="1" max="100">
</div>
` : ''}
</div>
<!-- PM Standard Attributes Section -->
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border);">
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_vendor')}</label>
<input type="text" class="prop-input" id="prop-vendor" value="${data.vendor || ''}" placeholder="Cisco">
</div>
<div class="prop-group">
<label>${t('prop_model')}</label>
<input type="text" class="prop-input" id="prop-model" value="${data.model || ''}" placeholder="C9300">
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_ip')}</label>
<input type="text" class="prop-input" id="prop-ip" value="${data.ip || ''}" placeholder="192.168.1.1">
</div>
<div class="prop-group">
<label>${t('prop_status')}</label>
<select class="prop-input" id="prop-status">
<option value="planning" ${data.status === 'planning' ? 'selected' : ''}>Plan</option>
<option value="installed" ${data.status === 'installed' ? 'selected' : ''}>Live</option>
<option value="retired" ${data.status === 'retired' ? 'selected' : ''}>None</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_project')}</label>
<input type="text" class="prop-input" id="prop-project" value="${data.project || ''}">
</div>
<div class="prop-group">
<label>${t('prop_env')}</label>
<select class="prop-input" id="prop-env">
<option value="prod" ${data.env === 'prod' ? 'selected' : ''}>PROD</option>
<option value="staging" ${data.env === 'staging' ? 'selected' : ''}>STG</option>
<option value="dev" ${data.env === 'dev' ? 'selected' : ''}>DEV</option>
</select>
</div>
</div>
<div class="prop-group">
<label>${t('prop_tags')}</label>
<input type="text" class="prop-input" id="prop-tags" value="${(data.tags || []).join(', ')}">
</div>
<div class="prop-group">
<label>${t('prop_description')}</label>
<textarea class="prop-input" id="prop-description" rows="2" style="resize: vertical; min-height: 48px; font-size: 12px;">${data.description || ''}</textarea>
</div>
</div>
`;
}
@@ -0,0 +1,74 @@
import { t } from '../../i18n.js';
export function getRichCardTemplate(cell) {
const data = cell.getData() || {};
const headerText = data.headerText || 'TITLE';
const content = data.content || '';
const cardType = data.cardType || 'standard';
const headerColor = data.headerColor || '#3b82f6';
const headerAlign = data.headerAlign || 'left';
const contentAlign = data.contentAlign || 'left';
return `
<div class="prop-section">
<h4 class="section-title"><i class="fas fa-file-alt"></i> ${t('rich_card')}</h4>
<div class="prop-group">
<label>${t('prop_header_text')}</label>
<div class="input-with-color">
<input type="text" id="prop-header-text" class="prop-input" value="${headerText}">
<input type="color" id="prop-header-color" class="prop-input" value="${headerColor}">
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_header_align')}</label>
<select id="prop-header-align" class="prop-input">
<option value="left" ${headerAlign === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="center" ${headerAlign === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="right" ${headerAlign === 'right' ? 'selected' : ''}>${t('right')}</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_card_type')}</label>
<select id="prop-card-type" class="prop-input">
<option value="standard" ${cardType === 'standard' ? 'selected' : ''}>${t('standard')}</option>
<option value="numbered" ${cardType === 'numbered' ? 'selected' : ''}>${t('numbered')}</option>
<option value="bullet" ${cardType === 'bullet' ? 'selected' : ''}>${t('bullet')}</option>
<option value="legend" ${cardType === 'legend' ? 'selected' : ''}>${t('legend')}</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_content_align')}</label>
<select id="prop-content-align" class="prop-input">
<option value="left" ${contentAlign === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="center" ${contentAlign === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="right" ${contentAlign === 'right' ? 'selected' : ''}>${t('right')}</option>
</select>
</div>
</div>
<div class="prop-group">
<label>${t('prop_card_content')}</label>
<textarea id="prop-card-content" class="prop-input" rows="8" style="resize: vertical; min-height: 120px; font-size: 13px;" placeholder="Enter list items...">${content}</textarea>
</div>
<div class="prop-row" style="margin-top: 10px;">
<div class="toggle-group" style="padding: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px 14px;">
<span style="font-size: 12px;"><i class="fas fa-lock" style="margin-right: 4px;"></i>${t('prop_locked')}</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
</div>
`;
}
+78
View File
@@ -0,0 +1,78 @@
import { STORAGE_KEYS } from '../constants.js';
/**
* settings/store.js - Persistence logic for app settings
*/
export function getSettings() {
return JSON.parse(localStorage.getItem(STORAGE_KEYS.SETTINGS) || '{}');
}
export function updateSetting(key, value) {
const settings = getSettings();
settings[key] = value;
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
}
export function getTheme() {
return localStorage.getItem(STORAGE_KEYS.THEME) || 'light';
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEYS.THEME, theme);
window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme } }));
}
export function applySavedTheme() {
const theme = getTheme();
document.documentElement.setAttribute('data-theme', theme);
}
export function applySavedSettings() {
const settings = getSettings();
if (settings.bgColor) {
document.documentElement.style.setProperty('--bg-color', settings.bgColor);
window.dispatchEvent(new CustomEvent('backgroundChanged', { detail: { color: settings.bgColor } }));
}
if (settings.gridStyle) {
window.dispatchEvent(new CustomEvent('gridChanged', {
detail: {
style: settings.gridStyle,
color: settings.gridColor,
thickness: settings.gridThickness,
showMajor: settings.showMajorGrid,
majorColor: settings.majorGridColor,
majorInterval: settings.majorGridInterval
}
}));
}
if (settings.gridSpacing) {
document.documentElement.style.setProperty('--grid-size', `${settings.gridSpacing}px`);
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: settings.gridSpacing } }));
}
if (settings.canvasPreset) {
const val = settings.canvasPreset;
const orient = settings.canvasOrientation || 'portrait';
let w = '100%', h = '100%';
if (val === 'A4') {
w = orient === 'portrait' ? 794 : 1123;
h = orient === 'portrait' ? 1123 : 794;
}
else if (val === 'A3') {
w = orient === 'portrait' ? 1123 : 1587;
h = orient === 'portrait' ? 1587 : 1123;
}
else if (val === 'custom') {
w = settings.canvasWidth;
h = settings.canvasHeight;
}
window.dispatchEvent(new CustomEvent('canvasResize', {
detail: { width: w, height: h }
}));
}
}
+25
View File
@@ -0,0 +1,25 @@
import { t } from '../../i18n.js';
export function renderAbout(container) {
container.innerHTML = `
<h3>${t('about')} drawNET</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">Version</span>
<span class="desc">Premium Edition v2.1.0</span>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">Engine</span>
<span class="desc">AntV X6 + drawNET Proprietary Parser</span>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('identity')}</span>
<span class="desc">Developed for High-Stakes Engineering</span>
</div>
</div>
`;
}
+48
View File
@@ -0,0 +1,48 @@
import { t } from '../../i18n.js';
import { getSettings, updateSetting } from '../store.js';
export function renderEngine(container) {
const settings = getSettings();
container.innerHTML = `
<h3>${t('engine') || 'Engine'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('auto_load_last')}</span>
<span class="desc">${t('auto_load_last_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" id="auto-load-toggle" ${settings.autoLoadLast ? 'checked' : ''}>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('sync_mode')}</span>
<span class="desc">${t('sync_mode_desc')}</span>
</div>
<div class="setting-ctrl">
<select disabled>
<option selected>Manual (Pro)</option>
<option>Real-time (Legacy)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('auto_save')}</span>
<span class="desc">${t('auto_save_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" checked disabled>
</div>
</div>
`;
}
export function bindEngineEvents(container) {
const autoLoadToggle = container.querySelector('#auto-load-toggle');
if (autoLoadToggle) {
autoLoadToggle.addEventListener('change', (e) => {
updateSetting('autoLoadLast', e.target.checked);
});
}
}
@@ -0,0 +1,73 @@
/**
* settings/tabs/general.js - General Settings Tab
*/
import { setTheme, getSettings } from '../store.js';
import { t } from '../../i18n.js';
export function renderGeneral(container) {
container.innerHTML = `
<h3>${t('identity') || 'Identity'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('dark_mode')}</span>
<span class="desc">${t('dark_mode_desc')}</span>
</div>
<div class="setting-ctrl">
<label class="toggle-switch">
<input type="checkbox" id="dark-mode-toggle" ${document.documentElement.getAttribute('data-theme') === 'dark' ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<h3 style="margin-top: 24px;">${t('editor_behavior') || 'Editor Behavior'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('confirm_ungroup') || 'Confirm before Un-grouping'}</span>
<span class="desc">${t('confirm_ungroup_desc') || 'Show a confirmation dialog when removing an object from a group.'}</span>
</div>
<div class="setting-ctrl">
<label class="toggle-switch">
<input type="checkbox" id="confirm-ungroup-toggle" ${getSettings().confirmUngroup !== false ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<h3 style="margin-top: 24px;">${t('language') || 'Language'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('language')}</span>
<span class="desc">${t('select_lang_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="lang-select" class="prop-input" style="width: 120px;">
<option value="ko" ${localStorage.getItem('drawNET_lang') === 'ko' ? 'selected' : ''}>한국어</option>
<option value="en" ${localStorage.getItem('drawNET_lang') === 'en' ? 'selected' : ''}>English</option>
</select>
</div>
</div>
`;
}
export function bindGeneralEvents(container) {
const darkModeToggle = container.querySelector('#dark-mode-toggle');
if (darkModeToggle) {
darkModeToggle.addEventListener('change', (e) => setTheme(e.target.checked ? 'dark' : 'light'));
}
const ungroupToggle = container.querySelector('#confirm-ungroup-toggle');
if (ungroupToggle) {
ungroupToggle.addEventListener('change', (e) => {
import('../store.js').then(m => m.updateSetting('confirmUngroup', e.target.checked));
});
}
const langSelect = container.querySelector('#lang-select');
if (langSelect) {
langSelect.addEventListener('change', (e) => {
localStorage.setItem('drawNET_lang', e.target.value);
location.reload();
});
}
}
+103
View File
@@ -0,0 +1,103 @@
import { t } from '../../i18n.js';
import { checkLicenseStatus } from '../../ui/license.js';
export async function renderLicense(container) {
const status = await checkLicenseStatus();
container.innerHTML = `
<h3><i class="fas fa-certificate" style="color: #3b82f6; margin-right: 8px;"></i> ${t('license_management')}</h3>
<p class="settings-intro">${t('license_intro')}</p>
<div class="license-status-card">
<div class="status-header">
<div class="edition-info">
<span class="edition-label">${t('current_edition')}</span>
<h2 class="edition-name">${status.valid ? status.data.level : t('trial_community')}</h2>
</div>
<div class="status-icon ${status.valid ? 'valid' : 'expired'}">
<i class="fas ${status.valid ? 'fa-crown' : 'fa-info-circle'}"></i>
</div>
</div>
<div class="hwid-section">
<div class="hwid-label-row">
<span>${t('machine_id')}</span>
<button class="text-link-btn" id="copy-hwid-btn"><i class="fas fa-copy"></i> ${t('copy')}</button>
</div>
<div class="hwid-value-box">
<code>${status.hwid}</code>
</div>
</div>
</div>
${status.valid ? `
<div class="setting-row" style="border-bottom: none;">
<div class="setting-info">
<span class="label">${t('expiry_info')}</span>
<span class="desc">${t('valid_until', { date: `<strong>${status.data.expiry}</strong>` })}</span>
</div>
</div>
` : ''}
<div class="divider"></div>
<div class="license-action-section">
<h4 style="margin-bottom: 12px;">${t('activate_license_title')}</h4>
<div class="key-input-wrapper">
<textarea id="license-key-input" placeholder="${t('license_key_placeholder')}"></textarea>
</div>
<button id="activate-license-btn" class="primary-btn-premium">
<i class="fas fa-key"></i> ${t('activate_license_btn')}
</button>
</div>
`;
bindLicenseEvents(container);
}
function bindLicenseEvents(container) {
const copyBtn = container.querySelector('#copy-hwid-btn');
if (copyBtn) {
copyBtn.onclick = () => {
const hwid = container.querySelector('code').textContent;
navigator.clipboard.writeText(hwid);
// Simple visual feedback
const icon = copyBtn.querySelector('i');
icon.className = 'fas fa-check';
setTimeout(() => icon.className = 'fas fa-copy', 2000);
};
}
const activateBtn = container.querySelector('#activate-license-btn');
if (activateBtn) {
activateBtn.onclick = async () => {
const key = container.querySelector('#license-key-input').value.trim();
if (!key) {
alert(t('enter_license_key'));
return;
}
activateBtn.disabled = true;
activateBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('verifying')}`;
try {
const res = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
const result = await res.json();
if (result.success) {
alert(t('license_activated_success'));
location.reload();
} else {
alert(t('activation_failed') + result.message);
}
} catch (err) {
alert(t('connection_error') + err.message);
} finally {
activateBtn.disabled = false;
activateBtn.innerHTML = `<i class="fas fa-key"></i> ${t('activate_license_btn')}`;
}
};
}
}
@@ -0,0 +1,220 @@
/**
* settings/tabs/workspace.js - Workspace (Canvas/Grid) Settings Tab
*/
import { updateSetting } from '../store.js';
import { t } from '../../i18n.js';
export function renderWorkspace(container, settings) {
container.innerHTML = `
<h3>${t('canvas_background') || 'Workspace'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_style')}</span>
<span class="desc">${t('grid_style_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="grid-style-select">
<option value="none" ${settings.gridStyle === 'none' ? 'selected' : ''}>${t('grid_none')}</option>
<option value="solid" ${settings.gridStyle === 'solid' ? 'selected' : ''}>${t('grid_solid')}</option>
<option value="dashed" ${settings.gridStyle === 'dashed' ? 'selected' : ''}>${t('grid_dashed')}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_spacing')}</span>
<span class="desc">${t('grid_spacing_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="grid-spacing-input" value="${settings.gridSpacing || 20}" min="10" max="100" step="5">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('bg_color')}</span>
<span class="desc">${t('bg_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="bg-color-picker" value="${settings.bgColor || '#f8fafc'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_color')}</span>
<span class="desc">${t('grid_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="grid-color-picker" value="${settings.gridColor || '#e2e8f0'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_thickness')}</span>
<span class="desc">${t('grid_thickness_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="grid-thickness-input" value="${settings.gridThickness || 1}" min="0.5" max="5" step="0.5">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('show_major_grid')}</span>
<span class="desc">${t('show_major_grid_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" id="major-grid-toggle" ${settings.showMajorGrid ? 'checked' : ''}>
</div>
</div>
<div id="major-grid-options" style="display: ${settings.showMajorGrid ? 'block' : 'none'}">
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('major_grid_color')}</span>
<span class="desc">${t('major_grid_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="major-grid-color-picker" value="${settings.majorGridColor || '#cbd5e1'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('major_grid_interval')}</span>
<span class="desc">${t('major_grid_interval_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="major-grid-interval-input" value="${settings.majorGridInterval || 5}" min="2" max="20">
</div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('canvas_preset')}</span>
<span class="desc">${t('canvas_preset_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="canvas-preset">
<option value="full" ${settings.canvasPreset === 'full' ? 'selected' : ''}>${t('canvas_full')}</option>
<option value="A4" ${settings.canvasPreset === 'A4' ? 'selected' : ''}>A4</option>
<option value="A3" ${settings.canvasPreset === 'A3' ? 'selected' : ''}>A3</option>
<option value="custom" ${settings.canvasPreset === 'custom' ? 'selected' : ''}>Custom</option>
</select>
</div>
</div>
<div id="orientation-container" class="setting-row" style="display: ${settings.canvasPreset === 'full' ? 'none' : 'flex'}">
<div class="setting-info">
<span class="label">${t('orientation')}</span>
<span class="desc">${t('orientation_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="canvas-orientation">
<option value="portrait" ${settings.canvasOrientation === 'portrait' ? 'selected' : ''}>${t('portrait')}</option>
<option value="landscape" ${settings.canvasOrientation === 'landscape' ? 'selected' : ''}>${t('landscape')}</option>
</select>
</div>
</div>
<div id="custom-size-inputs" class="setting-row" style="display: ${settings.canvasPreset === 'custom' ? 'flex' : 'none'}; gap: 10px;">
<input type="number" id="canvas-width" value="${settings.canvasWidth || 800}" placeholder="W" style="width: 70px;">
<input type="number" id="canvas-height" value="${settings.canvasHeight || 600}" placeholder="H" style="width: 70px;">
<button id="apply-custom-size" class="footer-btn mini">${t('apply')}</button>
</div>
`;
}
export function bindWorkspaceEvents(container) {
const getGridParams = () => ({
style: container.querySelector('#grid-style-select').value,
color: container.querySelector('#grid-color-picker').value,
thickness: parseFloat(container.querySelector('#grid-thickness-input').value) || 1,
showMajor: container.querySelector('#major-grid-toggle').checked,
majorColor: container.querySelector('#major-grid-color-picker').value,
majorInterval: parseInt(container.querySelector('#major-grid-interval-input').value) || 5
});
const dispatchGridUpdate = () => {
const params = getGridParams();
updateSetting('gridStyle', params.style);
updateSetting('gridColor', params.color);
updateSetting('gridThickness', params.thickness);
updateSetting('showMajorGrid', params.showMajor);
updateSetting('majorGridColor', params.majorColor);
updateSetting('majorGridInterval', params.majorInterval);
const majorOpts = container.querySelector('#major-grid-options');
if (majorOpts) majorOpts.style.display = params.showMajor ? 'block' : 'none';
window.dispatchEvent(new CustomEvent('gridChanged', { detail: params }));
};
const gridStyleSelect = container.querySelector('#grid-style-select');
gridStyleSelect.addEventListener('change', dispatchGridUpdate);
const gridColorPicker = container.querySelector('#grid-color-picker');
gridColorPicker.addEventListener('input', dispatchGridUpdate);
const gridThicknessInput = container.querySelector('#grid-thickness-input');
gridThicknessInput.addEventListener('input', dispatchGridUpdate);
const majorGridToggle = container.querySelector('#major-grid-toggle');
majorGridToggle.addEventListener('change', dispatchGridUpdate);
const majorGridColorPicker = container.querySelector('#major-grid-color-picker');
majorGridColorPicker.addEventListener('input', dispatchGridUpdate);
const majorGridIntervalInput = container.querySelector('#major-grid-interval-input');
majorGridIntervalInput.addEventListener('input', dispatchGridUpdate);
const gridSpacingInput = container.querySelector('#grid-spacing-input');
gridSpacingInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value) || 20;
updateSetting('gridSpacing', val);
document.documentElement.style.setProperty('--grid-size', `${val}px`);
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: val } }));
});
const bgColorPicker = container.querySelector('#bg-color-picker');
bgColorPicker.addEventListener('input', (e) => {
updateSetting('bgColor', e.target.value);
document.documentElement.style.setProperty('--bg-color', e.target.value);
});
const canvasPreset = container.querySelector('#canvas-preset');
const orientationContainer = container.querySelector('#orientation-container');
const customSizeDiv = container.querySelector('#custom-size-inputs');
const updateCanvas = () => {
const val = canvasPreset.value;
const orientationSelect = container.querySelector('#canvas-orientation');
const orient = orientationSelect ? orientationSelect.value : 'portrait';
updateSetting('canvasPreset', val);
updateSetting('canvasOrientation', orient);
if (customSizeDiv) customSizeDiv.style.display = val === 'custom' ? 'flex' : 'none';
if (orientationContainer) orientationContainer.style.display = val === 'full' ? 'none' : 'flex';
let w = '100%', h = '100%';
if (val === 'A4') {
w = orient === 'portrait' ? 794 : 1123;
h = orient === 'portrait' ? 1123 : 794;
} else if (val === 'A3') {
w = orient === 'portrait' ? 1123 : 1587;
h = orient === 'portrait' ? 1587 : 1123;
} else if (val === 'custom') {
w = parseInt(container.querySelector('#canvas-width').value) || 800;
h = parseInt(container.querySelector('#canvas-height').value) || 600;
updateSetting('canvasWidth', w);
updateSetting('canvasHeight', h);
}
window.dispatchEvent(new CustomEvent('canvasResize', {
detail: { width: w, height: h }
}));
};
canvasPreset.addEventListener('change', updateCanvas);
const orientationSelect = container.querySelector('#canvas-orientation');
if (orientationSelect) orientationSelect.addEventListener('change', updateCanvas);
const applyCustomBtn = container.querySelector('#apply-custom-size');
if (applyCustomBtn) applyCustomBtn.addEventListener('click', updateCanvas);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* settings/ui.js - Professional Settings Panel UI (Modular)
*/
import { getSettings } from './store.js';
import { renderGeneral, bindGeneralEvents } from './tabs/general.js';
import { renderWorkspace, bindWorkspaceEvents } from './tabs/workspace.js';
import { renderEngine, bindEngineEvents } from './tabs/engine.js';
import { renderLicense } from './tabs/license.js';
import { renderAbout } from './tabs/about.js';
let activeTab = 'general';
export function initSettings() {
const settingsBtn = document.getElementById('settings-btn');
const closeBtn = document.getElementById('close-settings-btn');
const panel = document.getElementById('settings-panel');
const tabs = document.querySelectorAll('.settings-tab');
if (settingsBtn && panel) {
settingsBtn.addEventListener('click', () => {
panel.classList.toggle('active');
if (panel.classList.contains('active')) renderTabContent(activeTab);
});
}
if (closeBtn && panel) {
closeBtn.addEventListener('click', () => panel.classList.remove('active'));
}
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activeTab = tab.getAttribute('data-tab');
renderTabContent(activeTab);
});
});
document.addEventListener('mousedown', (e) => {
if (panel?.classList.contains('active')) {
if (!panel.contains(e.target) && !settingsBtn.contains(e.target)) {
panel.classList.remove('active');
}
}
});
}
function renderTabContent(tabName) {
const body = document.getElementById('settings-body');
if (!body) return;
const settings = getSettings();
body.innerHTML = '';
const section = document.createElement('div');
section.className = 'settings-section';
switch (tabName) {
case 'general':
renderGeneral(section);
bindGeneralEvents(section);
break;
case 'workspace':
renderWorkspace(section, settings);
bindWorkspaceEvents(section);
break;
case 'engine':
renderEngine(section);
bindEngineEvents(section);
break;
case 'about':
renderAbout(section);
break;
case 'license':
renderLicense(section);
break;
}
body.appendChild(section);
}
+49
View File
@@ -0,0 +1,49 @@
import { DEFAULTS } from './constants.js';
// Shared state across modules
export const state = {
assetMap: {},
assetsData: [],
graph: null,
// Grid & Interaction State
gridSpacing: DEFAULTS.GRID_SPACING,
gridStyle: 'none', // 'none', 'solid', or 'dashed'
isSnapEnabled: false,
canvasSize: { width: '100%', height: '100%' },
// Attribute Copy/Paste Clipboard
clipboardNodeData: null,
clipboardNodeAttrs: null,
clipboardEdgeData: null,
// Layer Management
layers: [
{ id: 'l1', name: 'Main Layer', visible: true, locked: false, color: '#3b82f6', type: 'standard' }
],
activeLayerId: 'l1',
inactiveLayerOpacity: 0.3,
// Interaction State
isRightDragging: false,
isCtrlPressed: false, // Global tracking for Ctrl+Drag copy
// Selection Order Tracking
selectionOrder: [],
// Asset Management
selectedPackIds: JSON.parse(localStorage.getItem('selectedPackIds') || '[]'),
language: localStorage.getItem('drawNET_lang') || (navigator.language.startsWith('ko') ? 'ko' : 'en'),
// Global Application Config (from config.json)
appConfig: {},
// License State
license: {
level: 'Trial',
valid: false,
data: {}
},
// Project Metadata
projectName: "" // Will be set to t('untitled_project') on init
};
// Expose to window for browser debugging
if (typeof window !== 'undefined') {
window.state = state;
}
// For browser console debugging
window.state = state;
+141
View File
@@ -0,0 +1,141 @@
import { FloatingWindow } from './ui/window.js';
import { logger } from './utils/logger.js';
class ObjectStudioPlugin {
constructor() {
this.win = null;
this.uploadedPath = '';
}
open() {
if (this.win) {
this.win.bringToFront();
return;
}
this.win = new FloatingWindow('studio', t('object_studio'), {
width: 700,
height: 500,
icon: 'fas fa-magic'
});
const content = `
<div class="studio-container" style="display: flex; gap: 30px; height: 100%;">
<!-- Preview Area -->
<div class="preview-area" style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--item-bg); border-radius: 20px; border: 2px dashed var(--panel-border);">
<div id="studio-preview-icon" style="width: 120px; height: 120px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-image" style="font-size: 50px; color: var(--sub-text);"></i>
</div>
<p style="font-size: 11px; color: var(--sub-text); margin-top: 15px;">${t('preview')}</p>
</div>
<!-- Form Area -->
<div class="form-area" style="flex: 1.5; display: flex; flex-direction: column; justify-content: space-between;">
<div class="settings-group">
<h3 style="margin-bottom: 20px; font-size: 14px; border-bottom: 1px solid var(--panel-border); padding-bottom: 8px;">${t('basic_info')}</h3>
<div class="setting-item">
<span class="setting-label">${t('id_label')}</span>
<input type="text" id="studio-id" placeholder="${t('id_placeholder')}" style="width: 200px;">
</div>
<div class="setting-item">
<span class="setting-label">${t('display_name')}</span>
<input type="text" id="studio-label" placeholder="${t('name_placeholder')}" style="width: 200px;">
</div>
<div class="setting-item">
<span class="setting-label">${t('category')}</span>
<select id="studio-category" style="width: 200px;">
<option value="Network">${t('Network')}</option>
<option value="Security">${t('Security')}</option>
<option value="Compute">${t('Compute')}</option>
<option value="External">${t('External')}</option>
<option value="Other">${t('Other')}</option>
</select>
</div>
</div>
<div class="settings-group">
<h3 style="margin-bottom: 20px; font-size: 14px; border-bottom: 1px solid var(--panel-border); padding-bottom: 8px;">${t('asset_attachment')}</h3>
<div class="setting-item" style="flex-direction: column; align-items: flex-start; gap: 10px;">
<span class="setting-label">${t('icon_image')}</span>
<div style="display: flex; gap: 10px; width: 100%;">
<input type="file" id="studio-file" accept=".svg,.png,.jpg,.jpeg" style="font-size: 11px; flex: 1;">
<button id="studio-upload-btn" class="btn-secondary" style="padding: 5px 15px; font-size: 10px;">${t('upload') || 'Upload'}</button>
</div>
</div>
</div>
<div class="studio-footer" style="margin-top: 30px; display: flex; justify-content: flex-end; gap: 12px;">
<button id="studio-cancel" class="btn-secondary">${t('close_btn')}</button>
<button id="studio-save" class="btn-primary" style="padding: 10px 40px;">${t('save_device')}</button>
</div>
</div>
</div>
`;
this.win.render(content);
this.initEventListeners();
}
initEventListeners() {
const el = this.win.element;
const fileInput = el.querySelector('#studio-file');
const uploadBtn = el.querySelector('#studio-upload-btn');
const saveBtn = el.querySelector('#studio-save');
const cancelBtn = el.querySelector('#studio-cancel');
cancelBtn.addEventListener('click', () => this.win.destroy());
this.win.onClose = () => { this.win = null; };
uploadBtn.addEventListener('click', () => this.handleUpload(fileInput));
saveBtn.addEventListener('click', () => this.handleSave());
}
async handleUpload(fileInput) {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/studio/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
this.uploadedPath = data.path;
const preview = this.win.element.querySelector('#studio-preview-icon');
preview.innerHTML = `<img src="/static/assets/${this.uploadedPath}" style="max-width: 100%; max-height: 100%; transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);">`;
preview.querySelector('img').style.transform = 'scale(0.8)';
setTimeout(() => { preview.querySelector('img').style.transform = 'scale(1)'; }, 10);
}
} catch (err) { logger.critical(err); }
}
async handleSave() {
const id = document.getElementById('studio-id').value.trim();
const label = document.getElementById('studio-label').value.trim();
const category = document.getElementById('studio-category').value;
if (!id || !label || !this.uploadedPath) {
alert("Please fill all required fields.");
return;
}
const newAsset = { id, type: label, label, category, path: this.uploadedPath, format: this.uploadedPath.split('.').pop() };
try {
const res = await fetch('/api/studio/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAsset)
});
const data = await res.json();
if (data.success) {
initAssets();
this.win.destroy();
}
} catch (err) { logger.critical(err); }
}
}
export const studioPlugin = new ObjectStudioPlugin();
export const openStudioModal = () => studioPlugin.open();
+141
View File
@@ -0,0 +1,141 @@
import { studioState } from './state.js';
import * as processor from './processor.js';
import { renderStudioUI } from './renderer.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
/**
* Handle individual asset vectorization
*/
export async function startVectorization(id) {
const src = studioState.sources.find(s => s.id === id);
if (!src || src.status === 'processing') return;
src.status = 'processing';
renderStudioUI();
try {
const img = new Image();
img.src = src.previewUrl;
await new Promise(r => img.onload = r);
const pathData = await processor.traceImage(img, {
threshold: src.threshold || 128,
turdsize: 2
});
const svgData = processor.wrapAsSVG(pathData);
studioState.updateSource(id, {
svgData,
status: 'done',
choice: 'svg'
});
} catch (err) {
logger.critical("Trace Error:", err);
src.status = 'error';
}
renderStudioUI();
}
/**
* Bulk update properties for selected assets
*/
export function bulkUpdateProperties(sources, updates) {
sources.forEach(src => {
if (updates.category) src.category = updates.category;
if (updates.vendor) src.vendor = updates.vendor;
});
}
/**
* Final Build and Publish to Server
*/
export async function buildPackage() {
const packIdElement = document.getElementById('pack-id');
const packNameElement = document.getElementById('pack-name');
if (!packIdElement) return;
const packId = packIdElement.value.trim();
const packName = packNameElement ? packNameElement.value.trim() : packId;
if (!packId || studioState.sources.length === 0) {
alert(t('please_enter_pack_id'));
return;
}
const buildBtn = document.getElementById('build-btn');
const originalText = buildBtn.innerHTML;
buildBtn.disabled = true;
buildBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('building')}`;
try {
const formData = new FormData();
formData.append('packId', packId);
formData.append('packName', packName);
const assetsMetadata = studioState.sources.map(src => ({
id: src.id,
label: src.label,
category: src.category,
choice: src.choice,
svgData: src.choice === 'svg' ? src.svgData : null
}));
formData.append('assets', JSON.stringify(assetsMetadata));
studioState.sources.forEach(src => {
if (src.choice === 'png' || (src.type === 'svg' && src.choice === 'svg' && !src.svgData)) {
formData.append(`file_${src.id}`, src.file);
}
});
const response = await fetch('/api/studio/save-pack', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert(result.message || t('build_success'));
window.location.href = '/';
} else {
throw new Error(result.message || t('build_failed').replace('{message}', ''));
}
} catch (err) {
alert(t('build_failed').replace('{message}', err.message));
buildBtn.disabled = false;
buildBtn.innerHTML = originalText;
}
}
/**
* Load an existing package from the server
*/
export async function loadExistingPack(packId) {
if (!packId) return;
try {
const response = await fetch(`/api/studio/load-pack/${packId}`);
const result = await response.json();
if (result.success) {
studioState.loadFromMetadata(result.data);
// UI에 Pack ID와 Name 입력값 동기화
const packIdInput = document.getElementById('pack-id');
const packNameInput = document.getElementById('pack-name');
const vendorInput = document.getElementById('pack-vendor');
if (packIdInput) packIdInput.value = studioState.packInfo.id;
if (packNameInput) packNameInput.value = studioState.packInfo.name;
if (vendorInput) vendorInput.value = studioState.packInfo.vendor;
renderStudioUI();
logger.info(`drawNET Studio: Package "${packId}" loaded.`);
} else {
alert(t('load_failed').replace('{message}', result.message));
}
} catch (err) {
alert(t('load_error').replace('{message}', err.message));
}
}
+29
View File
@@ -0,0 +1,29 @@
import { initIngester } from './ingester.js';
import { renderStudioUI } from './renderer.js';
import { buildPackage } from './actions.js';
import { logger } from '../utils/logger.js';
/**
* Object Studio Main Entry Point
*/
document.addEventListener('DOMContentLoaded', () => {
initStudio();
});
function initStudio() {
logger.info("DrawNET Studio Initializing...");
// Initialize file ingestion module
initIngester();
// Listen for state updates to trigger re-renders
window.addEventListener('studio-sources-updated', renderStudioUI);
window.addEventListener('studio-selection-updated', renderStudioUI);
// Bind Global Actions
const buildBtn = document.getElementById('build-btn');
if (buildBtn) buildBtn.onclick = buildPackage;
// Initial render
renderStudioUI();
}
+52
View File
@@ -0,0 +1,52 @@
import { studioState } from './state.js';
export function initIngester() {
const dropZone = document.getElementById('drop-zone');
const folderInput = document.getElementById('folder-input');
const fileInput = document.getElementById('file-input');
if (!dropZone || !folderInput) return;
// Handle clicks
dropZone.addEventListener('click', () => folderInput.click());
// Handle folder selection
folderInput.addEventListener('change', (e) => handleFiles(e.target.files));
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
// Handle drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#38bdf8';
dropZone.style.background = 'rgba(56, 189, 248, 0.05)';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
handleFiles(e.dataTransfer.files);
});
}
function handleFiles(files) {
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml'];
let addedCount = 0;
Array.from(files).forEach(file => {
if (validTypes.includes(file.type)) {
studioState.addSource(file);
addedCount++;
}
});
if (addedCount > 0) {
// Trigger UI update (to be implemented in index.js)
window.dispatchEvent(new CustomEvent('studio-sources-updated'));
}
}
+78
View File
@@ -0,0 +1,78 @@
import { potrace, init } from 'https://cdn.jsdelivr.net/npm/esm-potrace-wasm@0.4.1/dist/index.js';
import { logger } from '../utils/logger.js';
let isWasmInitialized = false;
/**
* Image Tracing using WASM Potrace (High performance & quality)
* @param {HTMLImageElement} imageElement
* @param {Object} options
*/
export async function traceImage(imageElement, options = {}) {
if (!isWasmInitialized) {
await init();
isWasmInitialized = true;
}
// Ensure image is loaded
if (imageElement instanceof HTMLImageElement && !imageElement.complete) {
await new Promise(r => imageElement.onload = r);
}
// --- Pre-process with Canvas for Thresholding ---
const canvas = document.createElement('canvas');
canvas.width = imageElement.naturalWidth || imageElement.width;
canvas.height = imageElement.naturalHeight || imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
const threshold = options.threshold !== undefined ? options.threshold : 128;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Luminance calculation
const brightness = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
const v = brightness < threshold ? 0 : 255;
data[i] = data[i+1] = data[i+2] = v;
// Keep alpha as is or flatten to white?
// For icons, if alpha is low, treat as white
if (data[i+3] < 128) {
data[i] = data[i+1] = data[i+2] = 255;
data[i+3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
// Potrace Options (WASM version)
const potraceOptions = {
turdsize: options.turdsize || 2,
turnpolicy: options.turnpolicy || 4,
alphamax: options.alphamax !== undefined ? options.alphamax : 1,
opticurve: options.opticurve !== undefined ? options.opticurve : 1,
opttolerance: options.opttolerance || 0.2
};
try {
// Pass the thresholded canvas instead of the original image
const svgStr = await potrace(canvas, potraceOptions);
return svgStr;
} catch (err) {
logger.critical("WASM Potrace Error:", err);
throw err;
}
}
/**
* Creates a clean SVG string from path data
* (Maintained for API compatibility, though WASM potrace returns full SVG)
*/
export function wrapAsSVG(svgData, width = 64, height = 64) {
if (typeof svgData === 'string' && svgData.trim().startsWith('<svg')) {
return svgData;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${svgData}
</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
import { studioState } from './state.js';
import * as actions from './actions.js';
import { t } from '../i18n.js';
/**
* Main Studio UI rendering orchestrator
*/
export function renderStudioUI() {
renderSourceList();
renderAssetGrid();
renderPropertyPanel();
}
/**
* Left Panel: Source File List
*/
function renderSourceList() {
const list = document.getElementById('source-list');
const loadArea = document.getElementById('load-package-area');
if (!list) return;
// 1. Render Load Area (only once or update if needed)
if (loadArea && loadArea.innerHTML === "") {
fetch('/assets').then(res => res.json()).then(data => {
const packs = data.packs || [];
loadArea.innerHTML = `
<div style="padding: 15px; border-bottom: 1px solid rgba(255,255,255,0.05); background: rgba(56, 189, 248, 0.03);">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 8px;">${t('studio_edit_existing')}</label>
<div style="display: flex; gap: 8px;">
<select id="exist-pack-select" style="flex: 1; background: #0f172a; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<option value="">${t('studio_select_package')}</option>
${packs.map(p => `<option value="${p.id}">${p.name}</option>`).join('')}
</select>
<button id="load-pack-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 0 10px; border-radius: 4px; cursor: pointer; font-weight: 800; font-size: 11px;">${t('studio_open')}</button>
</div>
</div>
`;
document.getElementById('load-pack-btn').onclick = () => {
const packId = document.getElementById('exist-pack-select').value;
if (packId && confirm(t('confirm_load_package').replace('{id}', packId))) {
actions.loadExistingPack(packId);
}
};
});
}
// 2. Render Source List
list.innerHTML = studioState.sources.map(src => `
<li class="source-item ${studioState.selectedIds.has(src.id) ? 'active' : ''}" data-id="${src.id}">
<i class="fas ${src.type === 'svg' ? 'fa-vector-square' : 'fa-image'}"></i>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${src.name}</span>
<i class="fas fa-times delete-btn" style="font-size: 10px; cursor: pointer;" title="${t('remove')}"></i>
</li>
`).join('');
// Add click listeners
list.querySelectorAll('.source-item').forEach(item => {
const id = item.dataset.id;
item.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
studioState.removeSource(id);
renderStudioUI();
} else {
studioState.toggleSelection(id, e.ctrlKey || e.metaKey);
renderStudioUI();
}
});
});
}
/**
* Center Panel: Asset Preview Grid
*/
function renderAssetGrid() {
const grid = document.getElementById('asset-grid');
if (!grid) return;
if (studioState.sources.length === 0) {
grid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; color: #475569; margin-top: 100px;">
<i class="fas fa-layer-group" style="font-size: 40px; margin-bottom: 20px; display: block;"></i>
<p>${t('studio_no_assets')}</p>
</div>
`;
return;
}
grid.innerHTML = studioState.sources.map(src => `
<div class="asset-card ${studioState.selectedIds.has(src.id) ? 'selected' : ''}" data-id="${src.id}">
<div class="asset-preview">
<img src="${src.previewUrl}" alt="${src.name}">
</div>
<div class="asset-name">${src.label}</div>
<div style="font-size: 9px; color: #64748b; margin-top: 4px;">${src.choice.toUpperCase()}</div>
</div>
`).join('');
// Add click listeners
grid.querySelectorAll('.asset-card').forEach(card => {
card.addEventListener('click', (e) => {
studioState.toggleSelection(card.dataset.id, e.ctrlKey || e.metaKey);
renderStudioUI();
});
});
}
/**
* Right Panel: Property Settings & Comparison
*/
function renderPropertyPanel() {
const panel = document.querySelector('.property-panel .panel-content');
if (!panel) return;
const selectedSources = studioState.sources.filter(s => studioState.selectedIds.has(s.id));
if (selectedSources.length === 0) {
panel.innerHTML = `
<div style="text-align: center; color: #475569; margin-top: 50px;">
<i class="fas fa-info-circle" style="font-size: 30px; margin-bottom: 15px; display: block;"></i>
<p style="font-size: 13px;">${t('studio_select_prompt')}</p>
</div>
`;
return;
}
if (selectedSources.length === 1) {
const src = selectedSources[0];
panel.innerHTML = renderSingleAssetUI(src);
setupSingleAssetListeners(panel, src);
} else {
panel.innerHTML = renderBulkEditUI(selectedSources.length);
setupBulkEditListeners(panel, selectedSources);
}
}
function renderSingleAssetUI(src) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #38bdf8; margin-bottom: 15px;">${t('studio_asset_detail')}</h3>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('display_name_label')}</label>
<input type="text" id="prop-label" value="${src.label}" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
</div>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('category')}</label>
<div style="display: flex; gap: 8px;">
<select id="prop-category" style="flex: 1; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
<button id="add-cat-btn" style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #38bdf8; width: 32px; border-radius: 4px; cursor: pointer;" title="${t('add_category_tooltip')}">+</button>
</div>
<!-- Hidden Add Form -->
<div id="new-cat-area" style="display: none; margin-top: 8px; gap: 8px; align-items: center;">
<input type="text" id="new-cat-name" placeholder="${t('new_category_placeholder')}" style="flex: 1; background: #0f172a; border: 1px solid #38bdf8; color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<button id="save-cat-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 6px 10px; border-radius: 4px; font-size: 11px; font-weight: 700;">${t('add')}</button>
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="font-size: 10px; color: #64748b;">${t('trace_threshold')}</label>
<span id="threshold-val" style="font-size: 10px; color: #38bdf8; font-weight: 800;">${src.threshold || 128}</span>
</div>
<input type="range" id="prop-threshold" min="0" max="255" value="${src.threshold || 128}" style="width: 100%; height: 4px; background: #1e293b; border-radius: 2px; cursor: pointer;">
<p style="font-size: 9px; color: #475569; margin-top: 6px;">${t('trace_hint')}</p>
</div>
</div>
<div class="comparison-view">
<div class="comp-box">
<span class="comp-label">${t('original')}</span>
<div class="comp-preview">
<img src="${src.previewUrl}">
</div>
<button class="choice-btn ${src.choice === 'png' ? 'active' : ''}" data-choice="png">${t('keep_png')}</button>
</div>
<div class="comp-box">
<span class="comp-label">${t('vector_svg')}</span>
<div class="comp-preview" id="svg-preview">
${src.svgData ? src.svgData : '<i class="fas fa-magic" style="opacity:0.2"></i>'}
</div>
<button class="choice-btn ${src.choice === 'svg' ? 'active' : ''}"
data-choice="svg"
${!src.svgData ? 'disabled style="opacity:0.3"' : ''}>
${t('use_svg')}
</button>
</div>
</div>
<div style="margin-top: 20px;">
<button id="convert-btn" class="btn-primary" style="width: 100%; font-size: 11px; background: #6366f1;" ${src.status === 'processing' ? 'disabled' : ''}>
${src.status === 'processing' ? `<i class="fas fa-spinner fa-spin"></i> ${t('tracing')}` : t('convert_to_svg')}
</button>
</div>
`;
}
let thresholdTimer = null;
function setupSingleAssetListeners(panel, src) {
panel.querySelector('#prop-label').oninput = (e) => src.label = e.target.value;
panel.querySelector('#prop-category').onchange = (e) => src.category = e.target.value;
panel.querySelector('#prop-category').value = src.category;
panel.querySelector('#add-cat-btn').onclick = () => {
const area = panel.querySelector('#new-cat-area');
area.style.display = area.style.display === 'none' ? 'flex' : 'none';
if (area.style.display === 'flex') panel.querySelector('#new-cat-name').focus();
};
panel.querySelector('#save-cat-btn').onclick = () => {
const input = panel.querySelector('#new-cat-name');
const name = input.value.trim();
if (studioState.addCategory(name)) {
src.category = name;
renderStudioUI();
} else {
panel.querySelector('#new-cat-area').style.display = 'none';
}
};
const thresholdInput = panel.querySelector('#prop-threshold');
const thresholdVal = panel.querySelector('#threshold-val');
thresholdInput.oninput = (e) => {
const val = parseInt(e.target.value);
src.threshold = val;
thresholdVal.textContent = val;
// Debounced Auto-retrace
clearTimeout(thresholdTimer);
thresholdTimer = setTimeout(() => {
actions.startVectorization(src.id);
}, 400);
};
panel.querySelectorAll('.choice-btn').forEach(btn => {
btn.onclick = () => {
if (btn.disabled) return;
studioState.setChoice(src.id, btn.dataset.choice);
renderStudioUI();
};
});
panel.querySelector('#convert-btn').onclick = () => actions.startVectorization(src.id);
}
function renderBulkEditUI(count) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #fbbf24; margin-bottom: 20px;">${t('bulk_edit').replace('{count}', count)}</h3>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 5px;">${t('common_category')}</label>
<select id="bulk-category" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
<option value="">${t('no_change')}</option>
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
</div>
</div>
<button id="bulk-apply" class="btn-primary" style="width: 100%; font-size: 11px;">${t('apply_to_selected')}</button>
`;
}
function setupBulkEditListeners(panel, selectedSources) {
panel.querySelector('#bulk-apply').onclick = () => {
const cat = panel.querySelector('#bulk-category').value;
actions.bulkUpdateProperties(selectedSources, { category: cat });
renderStudioUI();
};
}
+118
View File
@@ -0,0 +1,118 @@
export const studioState = {
sources: [], // Original files [{file, id, previewUrl, type}]
selectedIds: new Set(),
packInfo: {
id: '',
name: '',
vendor: '',
version: '1.0.0'
},
categories: ['Network', 'Security', 'Compute', 'Cloud', 'Device', 'Other'],
addCategory(name) {
if (name && !this.categories.includes(name)) {
this.categories.push(name);
return true;
}
return false;
},
addSource(file) {
const baseId = file.name.replace(/\.[^/.]+$/, "").toLowerCase().replace(/[^a-z0-9]/g, '_');
let id = baseId;
let counter = 1;
// Ensure ID is unique
while (this.sources.some(s => s.id === id)) {
id = `${baseId}_${counter++}`;
}
const previewUrl = URL.createObjectURL(file);
this.sources.push({
id,
file,
name: file.name,
label: id.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
previewUrl,
category: 'Other',
type: file.type.includes('svg') ? 'svg' : 'png',
choice: file.type.includes('svg') ? 'svg' : 'png', // png, svg
svgData: null,
threshold: 128,
status: 'pending' // pending, processing, done, error
});
},
updateSource(id, data) {
const src = this.sources.find(s => s.id === id);
if (src) Object.assign(src, data);
},
setChoice(id, choice) {
const src = this.sources.find(s => s.id === id);
if (src) src.choice = choice;
},
removeSource(id) {
const index = this.sources.findIndex(s => s.id === id);
if (index > -1) {
URL.revokeObjectURL(this.sources[index].previewUrl);
this.sources.splice(index, 1);
this.selectedIds.delete(id);
}
},
clearSources() {
this.sources.forEach(s => URL.revokeObjectURL(s.previewUrl));
this.sources = [];
this.selectedIds.clear();
},
toggleSelection(id, multi = false) {
if (!multi) {
const wasSelected = this.selectedIds.has(id);
this.selectedIds.clear();
if (!wasSelected) this.selectedIds.add(id);
} else {
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
else this.selectedIds.add(id);
}
},
loadFromMetadata(metadata) {
this.clearSources();
this.packInfo = {
id: metadata.id || '',
name: metadata.name || metadata.id || '',
vendor: metadata.vendor || '',
version: metadata.version || '1.0.0'
};
if (metadata.assets) {
metadata.assets.forEach(asset => {
const choice = asset.views && asset.views.icon && asset.views.icon.endsWith('.svg') ? 'svg' : 'png';
const filename = asset.views ? asset.views.icon : '';
const previewUrl = `/static/assets/packs/${metadata.id}/${filename}`;
this.sources.push({
id: asset.id,
file: null, // No local file object for loaded assets
name: filename,
label: asset.label || asset.id,
previewUrl,
category: asset.category || 'Other',
type: choice,
choice: choice,
svgData: null,
threshold: 128,
status: 'done'
});
// Ensure category exists in the list
if (asset.category && !this.categories.includes(asset.category)) {
this.categories.push(asset.category);
}
});
}
}
};
+13
View File
@@ -0,0 +1,13 @@
import { initSidebar } from './ui/sidebar.js';
import { logger } from './utils/logger.js';
import { initSystemMenu } from './ui/system_menu.js';
import { initHeader } from './ui/header.js';
import { initGraphIO } from './graph/io/index.js';
export function initUIToggles() {
initSidebar();
initSystemMenu();
initHeader();
logger.info("UI modules initialized.");
}
@@ -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';
}
+33
View File
@@ -0,0 +1,33 @@
import { state } from '../state.js';
import { markDirty } from '../persistence.js';
import { t } from '../i18n.js';
/**
* ui/header.js - Logic for the application header (Project Name, Search, etc.)
*/
export function initHeader() {
const titleEl = document.getElementById('project-title');
if (!titleEl) return;
// Set initial title from state
titleEl.innerText = state.projectName || t('untitled_project');
// Direct editing by clicking
titleEl.addEventListener('click', () => {
const currentName = state.projectName || titleEl.innerText;
const newName = prompt(t('rename_project_prompt'), currentName);
if (newName && newName.trim() !== "" && newName !== currentName) {
state.projectName = newName.trim();
titleEl.innerText = state.projectName;
markDirty();
// Log for context
console.log(`Project renamed to: ${state.projectName}`);
}
});
// Hover effect class (optional, but good for UX)
titleEl.style.cursor = 'pointer';
titleEl.title = t('rename_project_tooltip');
}
+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(); };
}
+403
View File
@@ -0,0 +1,403 @@
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>${t('layers_title')}</span>
<button id="add-layer-btn" title="${t('add_layer_tooltip')}"><i class="fas fa-plus"></i></button>
</div>
<div id="layer-list" class="layer-list">
${state.layers.map((layer, index) => {
const isTrialLocked = state.license.level === 'Trial' && index >= 3;
const isFinalLocked = layer.locked || isTrialLocked;
return `
<div class="layer-item ${state.activeLayerId === layer.id ? 'active' : ''} ${isTrialLocked ? 'trial-locked' : ''}" data-id="${layer.id}" draggable="true">
<i class="fas fa-grip-vertical drag-handle" ${isTrialLocked ? 'style="display:none"' : ''}></i>
<div class="layer-type-toggle" data-id="${layer.id}" title="${layer.type === 'logical' ? t('layer_type_logical') : t('layer_type_standard')}">
<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} ${isTrialLocked ? '<i class="fas fa-crown" style="color:#fbbf24; font-size:9px; margin-left:4px;"></i>' : ''}</span>
<div class="layer-actions">
<button class="toggle-lock" data-id="${layer.id}" title="${isFinalLocked ? t('unlock_layer') : t('lock_layer')}" ${isTrialLocked ? 'disabled' : ''}>
<i class="fas ${isFinalLocked ? 'fa-lock' : 'fa-lock-open'}" style="color: ${isFinalLocked ? '#ef4444' : '#64748b'}; font-size: 11px;"></i>
</button>
<button class="rename-layer" data-id="${layer.id}" title="${t('rename_layer')}" ${isFinalLocked ? '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="${t('opacity_label')}"></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="${t('trash_tooltip')}">
<i class="fas fa-trash-alt"></i>
<span>${t('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 = () => {
// Tiered License Check: Trial mode limit (Max 3 layers)
if (state.license.level === 'Trial' && state.layers.length >= 3) {
showToast(t('err_trial_layer_limit') || 'Trial 버전은 최대 3개의 레이어만 지원합니다.', 'warning');
return;
}
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');
}
+109
View File
@@ -0,0 +1,109 @@
import { t } from '../i18n.js';
import { logger } from '../utils/logger.js';
export async function initLicense() {
const status = await checkLicenseStatus();
// Update global state
state.license = {
level: status.valid ? status.data.level : t('trial_community'),
valid: status.valid,
data: status.data || {}
};
updateLicenseUI(status);
}
export async function checkLicenseStatus() {
try {
const res = await fetch('/api/license/status');
if (res.ok) {
return await res.json();
}
} catch (err) {
logger.error("Failed to check license status", err);
}
return { valid: false, hwid: 'N/A' };
}
export function updateLicenseUI(status) {
const badge = document.querySelector('.logo-area span');
if (badge) {
if (status.valid) {
badge.textContent = status.data.level.toUpperCase();
badge.style.background = status.data.level === 'Enterprise' ? '#8b5cf6' : '#10b981';
} else {
badge.textContent = 'UNLICENSED';
badge.style.background = '#ef4444';
}
}
}
export async function showLicenseModal() {
const status = await checkLicenseStatus();
const { showModal } = await import('./window.js');
const content = `
<div class="license-modal">
<div class="info-group">
<label>${t('machine_id')}</label>
<div class="hwid-box">
<code>${status.hwid}</code>
<button class="copy-btn" onclick="navigator.clipboard.writeText('${status.hwid}')"><i class="fas fa-copy"></i></button>
</div>
<p class="hint">${t('hwid_copy_hint')}</p>
</div>
<div class="status-group">
<label>${t('prop_status')}</label>
<div class="status-card ${status.valid ? 'valid' : 'invalid'}">
<i class="fas ${status.valid ? 'fa-check-circle' : 'fa-times-circle'}"></i>
<span>${status.valid ? `${status.data.level} Edition (${t('valid_until', { date: status.data.expiry })})` : t('status_invalid')}</span>
</div>
</div>
<div class="activation-group">
<label>${t('activate_license_title')}</label>
<textarea id="license-key-input" placeholder="${t('license_key_placeholder')}"></textarea>
<button id="activate-btn" class="primary-btn">${t('activate_license_btn')}</button>
</div>
</div>
`;
showModal({
title: t('license_management'),
content: content,
width: "500px",
onRender: (modalEl) => {
const btn = modalEl.querySelector('#activate-btn');
const input = modalEl.querySelector('#license-key-input');
btn.onclick = async () => {
const key = input.value.trim();
if (!key) return;
btn.disabled = true;
btn.textContent = t('verifying');
try {
const res = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
const result = await res.json();
if (result.success) {
alert(t('license_activated_success'));
location.reload();
} else {
alert(t('activation_failed') + result.message);
}
} catch (err) {
alert(t('error_occurred') + err.message);
} finally {
btn.disabled = false;
btn.textContent = t('activate_license_btn');
}
};
}
});
}
+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();
}
});
}
}
}
+97
View File
@@ -0,0 +1,97 @@
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 = t('untitled_project');
// Reset state
state.projectName = t('untitled_project');
state.layers = [
{ id: 'l1', name: 'Main Layer', visible: true, locked: false, color: '#3b82f6', type: 'standard' }
];
// Sync UI
if (projectTitle) projectTitle.innerText = state.projectName;
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');
}
});
}
// Export Format Filtering (Trial: PNG, PDF only)
const exportBtns = document.querySelectorAll('.export-btn');
exportBtns.forEach(btn => {
const format = btn.getAttribute('data-format');
if (state.license.level === 'Trial' && !['png', 'pdf', 'dnet'].includes(format?.toLowerCase())) {
btn.classList.add('trial-disabled');
btn.title = 'Premium License Required';
btn.onclick = (e) => {
e.stopPropagation();
import('./toast.js').then(m => m.showToast(t('msg_premium_only'), 'warning'));
};
}
});
}
// 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();
}
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../state.js';
/**
* logger.js - Integrated Logging System for drawNET
* Supports filtering based on config.json [log_level: info | high | critical]
*/
const LEVELS = {
info: 0,
high: 1,
critical: 2
};
function getLogLevel() {
const level = state.appConfig?.log_level || 'info';
return LEVELS[level.toLowerCase()] ?? LEVELS.info;
}
export const logger = {
/**
* info - General progress, initialization, data loading (Level: info)
*/
info: (msg, ...args) => {
if (getLogLevel() <= LEVELS.info) {
console.log(`%c[drawNET:INFO]%c ${msg}`, 'color: #38bdf8; font-weight: bold;', 'color: inherit;', ...args);
}
},
/**
* debug - Temporary debugging logs (Alias for info)
*/
debug: (msg, ...args) => {
if (getLogLevel() <= LEVELS.info) {
console.log(`%c[drawNET:DEBUG]%c ${msg}`, 'color: #94a3b8; font-style: italic;', 'color: inherit;', ...args);
}
},
/**
* high - Important events, user actions, validation warnings (Level: high)
*/
high: (msg, ...args) => {
if (getLogLevel() <= LEVELS.high) {
console.info(`%c[drawNET:HIGH]%c ${msg}`, 'color: #f59e0b; font-weight: bold;', 'color: inherit;', ...args);
}
},
/**
* critical - System failures, data corruption, critical exceptions (Level: critical)
*/
critical: (msg, ...args) => {
if (getLogLevel() <= LEVELS.critical) {
console.error(`%c[drawNET:CRITICAL]%c ${msg}`, 'background: #ef4444; color: white; padding: 2px 4px; border-radius: 4px; font-weight: bold;', 'color: #ef4444;', ...args);
}
},
/**
* warn - Standard warnings that should be visible in 'high' level
*/
warn: (msg, ...args) => {
if (getLogLevel() <= LEVELS.high) {
console.warn(`[drawNET:WARN] ${msg}`, ...args);
}
}
};