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) {