diff --git a/ui_server.py b/ui_server.py index 8879694..04323f2 100644 --- a/ui_server.py +++ b/ui_server.py @@ -1486,6 +1486,7 @@ HTML = """ .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-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-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; } @@ -1835,6 +1836,11 @@ HTML = """
@@ -2735,6 +2741,12 @@ HTML = """ function networkCanvasWidth() { return Math.max(networkMinWidth(), (networkCanvas?.clientWidth || 0) - 28); } function networkState(block) { return block?.dataset.blockState || ''; } 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) { if (!block) return; const width = Math.max(networkMinWidth(), block.clientWidth || block.offsetWidth || networkMinWidth()); @@ -2858,7 +2870,7 @@ HTML = """ label.textContent = networkTitle(block); const stateNode = document.createElement('span'); 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'); actions.className = 'config-item-actions'; const visibility = document.createElement('button'); @@ -2869,6 +2881,25 @@ HTML = """ setNetworkBlockState(block, state ? '' : 'hidden'); }); 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.addEventListener('click', () => { if (!state) selectNetworkBlock(block); @@ -2903,6 +2934,55 @@ HTML = """ renderNetworkStructure(); 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) { const rect = block.getBoundingClientRect(); const edge = 9; @@ -3035,7 +3115,11 @@ HTML = """ }); } 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; networkBlocks.forEach(block => { block.style.left = ''; @@ -3059,6 +3143,10 @@ HTML = """ if (!networkCanvas) return; networkLayout = readNetworkLayout(); 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 => { makeNetworkBlockInteractive(block); const state = networkLayout[block.dataset.section]?.state; @@ -3076,6 +3164,20 @@ HTML = """ networkStructureOpen = !networkStructureOpen; 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)); window.addEventListener('resize', applyNetworkLayout); if (window.ResizeObserver) {