feat(ui): add custom network blocks

main
AlexsandrSnytkin 13 hours ago
parent 6a3e84ec80
commit 92c81cbbf3

@ -1486,6 +1486,7 @@ HTML = """<!doctype html>
.network-block:hover { border-color: color-mix(in srgb, var(--ui-accent) 48%, var(--ui-border)) !important; transform: translateY(-2px); } .network-block:hover { border-color: color-mix(in srgb, var(--ui-accent) 48%, var(--ui-border)) !important; transform: translateY(-2px); }
.network-block.is-selected { border-color: color-mix(in srgb, var(--ui-accent) 70%, var(--ui-border)) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 8px 22px color-mix(in srgb, var(--ui-bg) 20%, transparent); } .network-block.is-selected { border-color: color-mix(in srgb, var(--ui-accent) 70%, var(--ui-border)) !important; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 12%, transparent), 0 8px 22px color-mix(in srgb, var(--ui-bg) 20%, transparent); }
.network-block.is-dragging, .network-block.is-resizing { z-index: 5; border-color: var(--ui-accent) !important; box-shadow: 0 10px 28px color-mix(in srgb, var(--ui-accent) 16%, transparent); transform: none; transition: none; user-select: none; } .network-block.is-dragging, .network-block.is-resizing { z-index: 5; border-color: var(--ui-accent) !important; box-shadow: 0 10px 28px color-mix(in srgb, var(--ui-accent) 16%, transparent); transform: none; transition: none; user-select: none; }
.network-custom-block { border-style: dashed !important; }
.network-block.is-deleted, .network-block.is-hidden { display: none !important; } .network-block.is-deleted, .network-block.is-hidden { display: none !important; }
.network-summary { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; padding-bottom: 8px; border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 80%, transparent); color: var(--ui-text); font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .network-summary { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; padding-bottom: 8px; border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 80%, transparent); color: var(--ui-text); font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.network-summary::before { content: ""; color: var(--ui-accent); font-size: 11px; } .network-summary::before { content: ""; color: var(--ui-accent); font-size: 11px; }
@ -1835,6 +1836,11 @@ HTML = """<!doctype html>
<div id="networkWorkspace" class="network-workspace"> <div id="networkWorkspace" class="network-workspace">
<aside id="networkRestorePanel" class="config-restore-panel network-restore-panel" hidden> <aside id="networkRestorePanel" class="config-restore-panel network-restore-panel" hidden>
<span class="config-restore-title">Структура сети</span> <span class="config-restore-title">Структура сети</span>
<div class="config-structure-create">
<label>Шаблон<select id="networkTemplateSelect"><option value="model">Модель</option><option value="model-params">Параметры</option><option value="netron">Netron</option></select></label>
<label>Название<input id="networkBlockTitle" type="text" maxlength="48" placeholder="Мой блок"></label>
<button id="networkCreateBlock" type="button">создать блок</button>
</div>
<div id="networkRestoreList" class="config-restore-list"></div> <div id="networkRestoreList" class="config-restore-list"></div>
</aside> </aside>
<div id="networkCanvas" class="model-grid network-canvas"> <div id="networkCanvas" class="model-grid network-canvas">
@ -2735,6 +2741,12 @@ HTML = """<!doctype html>
function networkCanvasWidth() { return Math.max(networkMinWidth(), (networkCanvas?.clientWidth || 0) - 28); } function networkCanvasWidth() { return Math.max(networkMinWidth(), (networkCanvas?.clientWidth || 0) - 28); }
function networkState(block) { return block?.dataset.blockState || ''; } function networkState(block) { return block?.dataset.blockState || ''; }
function networkTitle(block) { return block?.querySelector(':scope > .network-summary')?.textContent?.trim() || block?.dataset.section || 'Блок'; } function networkTitle(block) { return block?.querySelector(':scope > .network-summary')?.textContent?.trim() || block?.dataset.section || 'Блок'; }
function networkTemplateKey(block) { return block?.dataset.custom === 'true' ? (block.dataset.template || 'model') : block?.dataset.section; }
function nextNetworkKey() {
let index = 1;
while (networkBlocks.some(block => block.dataset.section === `network-custom-${index}`)) index += 1;
return `network-custom-${index}`;
}
function updateNetworkBlockScale(block) { function updateNetworkBlockScale(block) {
if (!block) return; if (!block) return;
const width = Math.max(networkMinWidth(), block.clientWidth || block.offsetWidth || networkMinWidth()); const width = Math.max(networkMinWidth(), block.clientWidth || block.offsetWidth || networkMinWidth());
@ -2858,7 +2870,7 @@ HTML = """<!doctype html>
label.textContent = networkTitle(block); label.textContent = networkTitle(block);
const stateNode = document.createElement('span'); const stateNode = document.createElement('span');
stateNode.className = 'config-item-state'; stateNode.className = 'config-item-state';
stateNode.textContent = state === 'deleted' ? 'в корзине' : state === 'hidden' ? 'скрыт' : 'активен'; stateNode.textContent = state === 'deleted' ? 'в корзине' : state === 'hidden' ? 'скрыт' : block.dataset.custom === 'true' ? 'свой' : 'активен';
const actions = document.createElement('span'); const actions = document.createElement('span');
actions.className = 'config-item-actions'; actions.className = 'config-item-actions';
const visibility = document.createElement('button'); const visibility = document.createElement('button');
@ -2869,6 +2881,25 @@ HTML = """<!doctype html>
setNetworkBlockState(block, state ? '' : 'hidden'); setNetworkBlockState(block, state ? '' : 'hidden');
}); });
actions.appendChild(visibility); actions.appendChild(visibility);
if (block.dataset.custom === 'true' && !state) {
const duplicate = document.createElement('button');
duplicate.type = 'button';
duplicate.textContent = 'дубль';
duplicate.addEventListener('click', event => {
event.stopPropagation();
duplicateNetworkBlock(block);
});
actions.appendChild(duplicate);
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'удалить';
remove.title = 'Удалить пользовательский блок без восстановления';
remove.addEventListener('click', event => {
event.stopPropagation();
removeNetworkBlockForever(block);
});
actions.appendChild(remove);
}
item.append(label, stateNode, actions); item.append(label, stateNode, actions);
item.addEventListener('click', () => { item.addEventListener('click', () => {
if (!state) selectNetworkBlock(block); if (!state) selectNetworkBlock(block);
@ -2903,6 +2934,55 @@ HTML = """<!doctype html>
renderNetworkStructure(); renderNetworkStructure();
selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null); selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null);
} }
function createNetworkBlock(templateKey, title, existingKey = '') {
const template = networkBlocks.find(block => block.dataset.custom !== 'true' && block.dataset.section === templateKey);
if (!template || !networkCanvas) return null;
const key = existingKey || nextNetworkKey();
const clone = template.cloneNode(true);
clone.dataset.section = key;
clone.dataset.custom = 'true';
clone.dataset.template = templateKey;
clone.classList.add('network-custom-block');
clone.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
clone.querySelectorAll('[for]').forEach(node => node.removeAttribute('for'));
const summary = clone.querySelector(':scope > .network-summary');
if (summary) summary.textContent = title || `Новый блок ${key.replace('network-custom-', '')}`;
networkCanvas.appendChild(clone);
networkBlocks.push(clone);
networkLayout[key] = {...(networkLayout[key] || {}), custom: true, template: templateKey, title: networkTitle(clone)};
makeNetworkBlockInteractive(clone);
return clone;
}
function positionNewNetworkBlock(block) {
const bottom = networkBlocks.filter(item => item !== block && !networkState(item)).reduce((value, item) => Math.max(value, (item.offsetTop || 0) + item.offsetHeight), 0);
const width = networkCanvasWidth();
block.style.left = '12px';
block.style.top = `${Math.max(12, bottom + 18)}px`;
block.style.width = `${Math.min(width, Math.max(networkMinWidth(), Math.round(width * .46)))}px`;
updateNetworkBlockScale(block);
}
function duplicateNetworkBlock(block) {
const clone = createNetworkBlock(networkTemplateKey(block), `${networkTitle(block)} · копия`);
if (!clone) return;
positionNewNetworkBlock(clone);
bringNetworkBlockFront(clone);
captureNetworkLayout(clone);
saveNetworkLayout();
renderNetworkStructure();
selectNetworkBlock(clone);
setText('networkLayoutState', `создан блок «${networkTitle(clone)}»`);
}
function removeNetworkBlockForever(block) {
if (!block || block.dataset.custom !== 'true') return;
const key = block.dataset.section;
block.remove();
networkBlocks = networkBlocks.filter(item => item !== block);
if (key) delete networkLayout[key];
saveNetworkLayout();
renderNetworkStructure();
selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null);
setText('networkLayoutState', 'пользовательский блок удалён');
}
function networkResizeEdge(event, block) { function networkResizeEdge(event, block) {
const rect = block.getBoundingClientRect(); const rect = block.getBoundingClientRect();
const edge = 9; const edge = 9;
@ -3035,7 +3115,11 @@ HTML = """<!doctype html>
}); });
} }
function resetNetworkLayout() { function resetNetworkLayout() {
networkLayout = {}; const custom = {};
networkBlocks.forEach(block => {
if (block.dataset.custom === 'true') custom[block.dataset.section] = {custom: true, template: networkTemplateKey(block), title: networkTitle(block)};
});
networkLayout = custom;
networkZIndex = 1; networkZIndex = 1;
networkBlocks.forEach(block => { networkBlocks.forEach(block => {
block.style.left = ''; block.style.left = '';
@ -3059,6 +3143,10 @@ HTML = """<!doctype html>
if (!networkCanvas) return; if (!networkCanvas) return;
networkLayout = readNetworkLayout(); networkLayout = readNetworkLayout();
networkBlocks = Array.from(networkCanvas.children).filter(node => node.classList.contains('network-block')); networkBlocks = Array.from(networkCanvas.children).filter(node => node.classList.contains('network-block'));
Object.entries(networkLayout).forEach(([key, entry]) => {
if (!entry || entry.custom !== true || networkBlocks.some(block => block.dataset.section === key)) return;
createNetworkBlock(entry.template || 'model', entry.title || 'Новый блок', key);
});
networkBlocks.forEach(block => { networkBlocks.forEach(block => {
makeNetworkBlockInteractive(block); makeNetworkBlockInteractive(block);
const state = networkLayout[block.dataset.section]?.state; const state = networkLayout[block.dataset.section]?.state;
@ -3076,6 +3164,20 @@ HTML = """<!doctype html>
networkStructureOpen = !networkStructureOpen; networkStructureOpen = !networkStructureOpen;
renderNetworkStructure(); renderNetworkStructure();
}); });
$('networkCreateBlock')?.addEventListener('click', () => {
const templateKey = $('networkTemplateSelect')?.value || 'model';
const title = String($('networkBlockTitle')?.value || '').trim().slice(0, 48) || 'Новый блок';
const block = createNetworkBlock(templateKey, title);
if (!block) return;
positionNewNetworkBlock(block);
bringNetworkBlockFront(block);
captureNetworkLayout(block);
saveNetworkLayout();
if ($('networkBlockTitle')) $('networkBlockTitle').value = '';
renderNetworkStructure();
selectNetworkBlock(block);
setText('networkLayoutState', `создан блок «${networkTitle(block)}»`);
});
$('networkContentScale')?.addEventListener('input', event => applyNetworkContentScale(event.target.value)); $('networkContentScale')?.addEventListener('input', event => applyNetworkContentScale(event.target.value));
window.addEventListener('resize', applyNetworkLayout); window.addEventListener('resize', applyNetworkLayout);
if (window.ResizeObserver) { if (window.ResizeObserver) {

Loading…
Cancel
Save