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