mirror of
https://github.com/sotam0316/drawNET.git
synced 2026-04-25 03:58:37 +09:00
Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user