From 5bd377e16f9856115a0ba485e3da6aea743e1c75 Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Fri, 18 Sep 2026 14:35:02 +0700 Subject: [PATCH] fix(ui): stabilize config canvas interactions --- tests/test_ui_server.py | 5 + ui_server.py | 307 ++++++++++++++++++++++++++++++---------- 2 files changed, 236 insertions(+), 76 deletions(-) diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py index fcc4879..e2439da 100644 --- a/tests/test_ui_server.py +++ b/tests/test_ui_server.py @@ -70,8 +70,13 @@ class UIServerTests(unittest.TestCase): self.assertIn('initCollapsibleSections', HTML) self.assertIn('configResetLayout', HTML) self.assertIn('configRestoreToggle', HTML) + self.assertIn('configTemplateSelect', HTML) + self.assertIn('configCreateBlock', HTML) self.assertIn('config-canvas', HTML) self.assertIn('setConfigBlockState', HTML) + self.assertIn('createCustomConfigBlock', HTML) + self.assertIn('duplicateConfigBlock', HTML) + self.assertIn('positionNewConfigBlock', HTML) self.assertIn('finishConfigDrag', HTML) self.assertIn('fpv-config-layout-v1', HTML) self.assertIn('config-trash', HTML) diff --git a/ui_server.py b/ui_server.py index 2e57684..0d83984 100644 --- a/ui_server.py +++ b/ui_server.py @@ -1283,15 +1283,26 @@ HTML = """ .config-toolbar button, .config-restore-panel button { min-height: 26px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: 7px; background: var(--ui-input); color: var(--ui-muted); font: 700 10px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; transition: border-color .18s ease, color .18s ease, background .18s ease, transform .18s ease; } .config-toolbar button:hover, .config-toolbar button:focus-visible, .config-restore-panel button:hover, .config-restore-panel button:focus-visible { outline: 0; border-color: var(--ui-accent); color: var(--ui-accent); transform: translateY(-1px); } .config-layout-state { min-width: 0; color: var(--ui-muted); font-size: 10px; } - .config-restore-panel { display: grid; gap: 6px; padding: 7px 10px; border-bottom: 1px solid var(--ui-border); background: color-mix(in srgb, var(--ui-input) 45%, transparent); } + .config-restore-panel { display: grid; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--ui-border); background: color-mix(in srgb, var(--ui-input) 45%, transparent); } .config-restore-title { color: var(--ui-muted); font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } - .config-restore-list { display: flex; flex-wrap: wrap; gap: 5px; } - .config-restore-item { display: inline-flex; align-items: center; gap: 6px; padding: 3px 5px 3px 8px; border: 1px solid var(--ui-border); border-radius: 7px; color: var(--ui-text); font-size: 11px; } + .config-structure-create { display: flex; align-items: end; flex-wrap: wrap; gap: 6px; } + .config-structure-create label { display: grid; gap: 3px; min-width: 150px; color: var(--ui-muted); font-size: 10px; } + .config-structure-create label:first-child { min-width: 190px; } + .config-structure-create input, .config-structure-create select { height: 27px; min-width: 0; padding: 0 7px; border: 1px solid var(--ui-border); border-radius: 7px; background: var(--ui-input); color: var(--ui-text); font: inherit; } + .config-structure-create input:focus, .config-structure-create select:focus { outline: 0; border-color: var(--ui-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--ui-accent) 10%, transparent); } + .config-structure-hint { color: var(--ui-muted); font-size: 10px; } + .config-restore-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 5px; } + .config-restore-item { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 4px 5px 4px 8px; border: 1px solid var(--ui-border); border-radius: 7px; color: var(--ui-text); font-size: 11px; } + .config-restore-item.is-hidden { opacity: .72; } + .config-restore-item .config-item-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .config-restore-item .config-item-state { color: var(--ui-muted); font-size: 9px; text-transform: uppercase; } + .config-item-actions { display: inline-flex; gap: 3px; } .config-restore-panel[hidden], .config-trash[hidden] { display: none !important; } - .config-canvas { position: relative; display: block; min-height: 760px; padding: 8px 6px 18px; } - .config-canvas > .config-block { position: absolute; margin: 0; min-width: 240px; min-height: 110px; border: 1px solid var(--ui-border) !important; border-radius: 10px; background: var(--ui-panel) !important; box-shadow: 0 6px 18px rgba(0, 0, 0, .1); overflow: auto; transition: border-color .2s ease, box-shadow .2s ease, opacity .2s ease; } + .config-canvas { position: relative; display: block; min-height: 760px; padding: 8px 6px 18px; overflow: auto; overscroll-behavior: contain; } + .config-canvas > .config-block { position: absolute; box-sizing: border-box; margin: 0; min-width: 240px; min-height: 110px; border: 1px solid var(--ui-border) !important; border-radius: 10px; background: var(--ui-panel) !important; box-shadow: 0 6px 18px rgba(0, 0, 0, .1); overflow: auto; transition: border-color .2s ease, box-shadow .2s ease, opacity .2s ease; } .config-canvas > .config-block:hover { border-color: color-mix(in srgb, var(--ui-accent) 45%, var(--ui-border)) !important; } .config-canvas > .config-block.is-dragging, .config-canvas > .config-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); transition: none; } + .config-canvas > .config-custom-block { border-style: dashed !important; } .config-block > summary { position: sticky; top: 0; z-index: 2; background: var(--ui-panel); } .block-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-block > summary { cursor: grab; touch-action: none; } @@ -1305,8 +1316,10 @@ HTML = """ @media (max-width: 760px) { .config-toolbar { align-items: stretch; } .config-toolbar-title, .config-layout-state { flex-basis: 100%; } - .config-canvas { min-height: 0 !important; display: grid; gap: 8px; padding: 8px 0; } - .config-canvas > .config-block { position: relative !important; inset: auto !important; width: auto !important; height: auto !important; min-width: 0; max-height: none; } + .config-canvas { min-height: 900px; padding: 8px 4px 24px; } + .config-canvas > .config-block { min-width: 220px; max-width: calc(100% - 8px); } + .config-structure-create { align-items: stretch; } + .config-structure-create label, .config-structure-create label:first-child { min-width: min(100%, 220px); flex: 1 1 180px; } .config-trash { bottom: 10px; } } @keyframes form-field-in { from { opacity: .35; transform: translateY(3px); } to { opacity: 1; transform: none; } } @@ -1402,11 +1415,26 @@ HTML = """
@@ -1843,8 +1871,8 @@ HTML = """ let configCanvas = null; let configBlocks = []; let configLayout = {}; - let configManualLayout = false; let restorePanelOpen = false; + let configZIndex = 1; function readConfigLayout() { try { const saved = JSON.parse(localStorage.getItem('fpv-config-layout-v1') || '{}'); @@ -1854,19 +1882,28 @@ HTML = """ function saveConfigLayout() { try { localStorage.setItem('fpv-config-layout-v1', JSON.stringify(configLayout)); } catch (_error) {} } + function configMinWidth() { + return window.innerWidth <= 760 ? 220 : 240; + } + function configCanvasWidth() { + return Math.max(configMinWidth(), (configCanvas?.clientWidth || 0) - 12); + } function configTitle(block) { return block.querySelector(':scope > summary .block-title')?.textContent || block.dataset.section || 'Блок'; } function configBlockState(block) { return block.dataset.blockState || ''; } + function configTemplateKey(block) { + return block.dataset.custom === 'true' ? (block.dataset.template || 'source') : block.dataset.section; + } function updateConfigCanvasHeight() { if (!configCanvas) return; const bottom = configBlocks.reduce((value, block) => { if (configBlockState(block)) return value; return Math.max(value, (block.offsetTop || 0) + block.offsetHeight); }, 0); - configCanvas.style.minHeight = `${Math.max(760, bottom + 26)}px`; + configCanvas.style.minHeight = `${Math.max(window.innerWidth <= 760 ? 900 : 760, bottom + 28)}px`; } function captureConfigLayout(block) { const key = block.dataset.section; @@ -1876,45 +1913,67 @@ HTML = """ entry.top = Math.round(parseFloat(block.style.top) || block.offsetTop || 0); entry.width = Math.round(block.offsetWidth); if (block.style.height) entry.height = Math.round(block.offsetHeight); + else delete entry.height; if (block.style.zIndex) entry.z = Number(block.style.zIndex); configLayout[key] = entry; } + function placeConfigBlock(block, left, top, width) { + if (!block) return; + const canvasWidth = configCanvasWidth(); + const minWidth = configMinWidth(); + const safeLeft = Math.max(0, Math.round(Math.min(left, Math.max(0, canvasWidth - minWidth)))); + const safeWidth = Math.max(minWidth, Math.min(canvasWidth - safeLeft, Math.round(width))); + block.style.left = `${safeLeft}px`; + block.style.top = `${Math.max(0, Math.round(top))}px`; + block.style.width = `${safeWidth}px`; + } function applyStoredConfigLayout() { if (!configCanvas) return; - const width = Math.max(300, configCanvas.clientWidth - 12); - const gap = 12; - const source = configBlocks.find(block => block.dataset.section === 'source' && !configBlockState(block)); - const processing = configBlocks.find(block => block.dataset.section === 'processing' && !configBlockState(block)); - const archive = configBlocks.find(block => block.dataset.section === 'archive' && !configBlockState(block)); - const advanced = configBlocks.find(block => block.dataset.section === 'advanced' && !configBlockState(block)); - const place = (block, left, top, blockWidth) => { + const width = configCanvasWidth(); + const gap = 14; + const visible = block => block && !configBlockState(block); + const source = configBlocks.find(block => block.dataset.section === 'source' && visible(block)); + const processing = configBlocks.find(block => block.dataset.section === 'processing' && visible(block)); + const archive = configBlocks.find(block => block.dataset.section === 'archive' && visible(block)); + const advanced = configBlocks.find(block => block.dataset.section === 'advanced' && visible(block)); + const defaults = new Set(); + const placeDefault = (block, left, top, blockWidth) => { if (!block) return; - block.style.left = `${Math.max(0, Math.round(left))}px`; - block.style.top = `${Math.max(0, Math.round(top))}px`; - block.style.width = `${Math.max(240, Math.round(blockWidth))}px`; - block.style.height = ''; + const entry = configLayout[block.dataset.section] || {}; + if (!Number.isFinite(Number(entry.left)) || !Number.isFinite(Number(entry.top))) placeConfigBlock(block, left, top, blockWidth); + defaults.add(block); }; - let y = 0; - place(source, 0, y, width); + let y = 8; + placeDefault(source, 0, y, width); if (source) y += source.offsetHeight + gap; const pair = [processing, archive].filter(Boolean); if (pair.length === 2) { - const half = Math.max(240, (width - gap) / 2); - place(processing, 0, y, half); - place(archive, half + gap, y, half); + const half = Math.max(configMinWidth(), (width - gap) / 2); + placeDefault(processing, 0, y, half); + placeDefault(archive, half + gap, y, half); y += Math.max(processing.offsetHeight, archive.offsetHeight) + gap; } else if (pair.length === 1) { - place(pair[0], 0, y, width); + placeDefault(pair[0], 0, y, width); y += pair[0].offsetHeight + gap; } - place(advanced, 0, y, width); + placeDefault(advanced, 0, y, width); + if (advanced) y += advanced.offsetHeight + gap; + configBlocks.filter(block => visible(block) && !defaults.has(block)).forEach(block => { + const entry = configLayout[block.dataset.section] || {}; + if (!Number.isFinite(Number(entry.left)) || !Number.isFinite(Number(entry.top))) { + placeConfigBlock(block, 12, y, Math.min(width, Math.max(configMinWidth(), width * .46))); + y += block.offsetHeight + gap; + } + }); configBlocks.forEach(block => { const entry = configLayout[block.dataset.section]; if (!entry || configBlockState(block)) return; - const left = Number.isFinite(Number(entry.left)) ? Math.max(0, Math.min(width - 240, Number(entry.left))) : 0; - if (Number.isFinite(Number(entry.left))) block.style.left = `${left}px`; + const minWidth = configMinWidth(); + const storedLeft = Number(entry.left); + const left = Number.isFinite(storedLeft) ? Math.max(0, Math.min(width - minWidth, storedLeft)) : null; + if (left != null) block.style.left = `${left}px`; if (Number.isFinite(Number(entry.top))) block.style.top = `${Math.max(0, Number(entry.top))}px`; - if (Number.isFinite(Number(entry.width))) block.style.width = `${Math.max(240, Math.min(width - left, Number(entry.width)))}px`; + if (Number.isFinite(Number(entry.width))) block.style.width = `${Math.max(minWidth, Math.min(width - (left || 0), Number(entry.width)))}px`; if (Number.isFinite(Number(entry.height))) block.style.height = `${Math.max(110, Number(entry.height))}px`; if (Number.isFinite(Number(entry.z))) { block.style.zIndex = String(Math.max(1, Number(entry.z))); configZIndex = Math.max(configZIndex, Number(entry.z)); } }); @@ -1945,26 +2004,98 @@ HTML = """ if (!panel || !list || !toggle) return; const hidden = configBlocks.filter(block => configBlockState(block)); list.innerHTML = ''; - hidden.forEach(block => { - const item = document.createElement('span'); - item.className = 'config-restore-item'; + configBlocks.forEach(block => { + const state = configBlockState(block); + const item = document.createElement('div'); + item.className = `config-restore-item${state ? ' is-hidden' : ''}`; const label = document.createElement('span'); + label.className = 'config-item-title'; label.textContent = configTitle(block); - const restore = document.createElement('button'); - restore.type = 'button'; - restore.textContent = configBlockState(block) === 'deleted' ? 'вернуть' : 'показать'; - restore.setAttribute('aria-label', `${restore.textContent} блок ${configTitle(block)}`); - restore.addEventListener('click', () => setConfigBlockState(block, '')); - item.append(label, restore); + const stateNode = document.createElement('span'); + stateNode.className = 'config-item-state'; + 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'); + visibility.type = 'button'; + visibility.textContent = state ? 'вернуть' : 'скрыть'; + visibility.setAttribute('aria-label', `${visibility.textContent} блок ${configTitle(block)}`); + visibility.addEventListener('click', () => setConfigBlockState(block, state ? '' : 'hidden')); + const duplicate = document.createElement('button'); + duplicate.type = 'button'; + duplicate.textContent = 'дубль'; + duplicate.setAttribute('aria-label', `создать копию блока ${configTitle(block)}`); + duplicate.addEventListener('click', () => duplicateConfigBlock(block)); + actions.append(visibility, duplicate); + if (block.dataset.custom === 'true' && !state) { + const remove = document.createElement('button'); + remove.type = 'button'; + remove.textContent = 'удалить'; + remove.addEventListener('click', () => setConfigBlockState(block, 'deleted')); + actions.appendChild(remove); + } + item.append(label, stateNode, actions); list.appendChild(item); }); - toggle.disabled = !hidden.length; - toggle.setAttribute('aria-expanded', String(restorePanelOpen && !!hidden.length)); - panel.hidden = !restorePanelOpen || !hidden.length; + toggle.disabled = false; + toggle.textContent = hidden.length ? `структура · ${hidden.length} скрыто` : 'структура'; + toggle.setAttribute('aria-expanded', String(restorePanelOpen)); + panel.hidden = !restorePanelOpen; const visible = configBlocks.length - hidden.length; setText('configLayoutState', `${visible} блоков · ${hidden.length} скрыто`); } - let configZIndex = 1; + function nextCustomConfigKey() { + let index = 1; + while (configBlocks.some(block => block.dataset.section === `custom-${index}`)) index += 1; + return `custom-${index}`; + } + function createCustomConfigBlock(templateKey, title, existingKey = '') { + const template = configBlocks.find(block => !block.dataset.custom && block.dataset.section === templateKey); + if (!template || !configCanvas) return null; + const key = existingKey || nextCustomConfigKey(); + const clone = template.cloneNode(true); + clone.dataset.section = key; + clone.dataset.custom = 'true'; + clone.dataset.template = templateKey; + clone.classList.add('config-block', 'config-custom-block'); + clone.removeAttribute('id'); + clone.querySelectorAll('[id]').forEach(node => node.removeAttribute('id')); + clone.querySelectorAll('[for]').forEach(node => node.removeAttribute('for')); + const summary = clone.querySelector(':scope > summary'); + if (summary) { + summary.textContent = ''; + const titleNode = document.createElement('span'); + titleNode.className = 'block-title'; + titleNode.textContent = title || `Новый блок ${key.replace('custom-', '')}`; + summary.appendChild(titleNode); + } + configCanvas.appendChild(clone); + configBlocks.push(clone); + const entry = configLayout[key] || {}; + configLayout[key] = {...entry, custom: true, template: templateKey, title: titleNodeText(clone)}; + return clone; + } + function titleNodeText(block) { + return block.querySelector(':scope > summary .block-title')?.textContent?.trim() || 'Новый блок'; + } + function positionNewConfigBlock(block) { + const bottom = configBlocks.filter(item => item !== block && !configBlockState(item)).reduce((value, item) => Math.max(value, (item.offsetTop || 0) + item.offsetHeight), 0); + const width = configCanvasWidth(); + block.style.left = '12px'; + block.style.top = `${Math.max(12, bottom + 18)}px`; + block.style.width = `${Math.min(width, Math.max(configMinWidth(), Math.round(width * .46)))}px`; + } + function duplicateConfigBlock(block) { + const clone = createCustomConfigBlock(configTemplateKey(block), `${configTitle(block)} · копия`); + if (!clone) return; + makeConfigBlockInteractive(clone); + positionNewConfigBlock(clone); + bringConfigBlockFront(clone); + captureConfigLayout(clone); + saveConfigLayout(); + renderConfigRestorePanel(); + setText('configLayoutState', `создан блок «${configTitle(clone)}»`); + } function bringConfigBlockFront(block) { if (!block || configBlockState(block)) return; block.style.zIndex = String(++configZIndex); @@ -1973,7 +2104,7 @@ HTML = """ } function configResizeEdge(event, block) { const rect = block.getBoundingClientRect(); - const edge = 8; + const edge = 9; const left = event.clientX - rect.left <= edge; const right = rect.right - event.clientX <= edge; const top = event.clientY - rect.top <= edge; @@ -1982,7 +2113,7 @@ HTML = """ } function trashDrop(event) { const trash = $('configTrash'); - if (!trash || trash.hidden) return false; + if (!trash || trash.hidden || !event) return false; const rect = trash.getBoundingClientRect(); return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; } @@ -2009,7 +2140,8 @@ HTML = """ } function makeConfigBlockInteractive(block) { const summary = block.firstElementChild; - if (!summary || summary.tagName !== 'SUMMARY') return; + if (!summary || summary.tagName !== 'SUMMARY' || block._configInteractive) return; + block._configInteractive = true; const title = summary.textContent.trim(); summary.textContent = ''; const titleNode = document.createElement('span'); @@ -2026,9 +2158,8 @@ HTML = """ }); block.addEventListener('pointerdown', event => { bringConfigBlockFront(block); - if (window.innerWidth <= 760 || event.target.closest('summary')) return; const edge = configResizeEdge(event, block); - if (!edge) return; + if (!edge || (event.target.closest('summary') && !edge.includes('n'))) return; event.preventDefault(); event.stopPropagation(); const startX = event.clientX; @@ -2042,19 +2173,21 @@ HTML = """ const move = moveEvent => { const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; + const minWidth = configMinWidth(); + const minHeight = 110; + const canvasWidth = configCanvasWidth(); let left = startLeft; let top = startTop; let width = startWidth; let height = startHeight; - if (edge.includes('w')) { left = Math.max(0, Math.min(startLeft + dx, startLeft + startWidth - 240)); width = startWidth - (left - startLeft); } - if (edge.includes('e')) width = Math.max(240, Math.min(configCanvas.clientWidth - left - 6, startWidth + dx)); - if (edge.includes('n')) { top = Math.max(0, Math.min(startTop + dy, startTop + startHeight - 110)); height = startHeight - (top - startTop); } - if (edge.includes('s')) height = Math.max(110, startHeight + dy); + if (edge.includes('w')) { left = Math.max(0, Math.min(startLeft + dx, startLeft + startWidth - minWidth)); width = startWidth - (left - startLeft); } + if (edge.includes('e')) width = Math.max(minWidth, Math.min(canvasWidth - left, startWidth + dx)); + if (edge.includes('n')) { top = Math.max(0, Math.min(startTop + dy, startTop + startHeight - minHeight)); height = startHeight - (top - startTop); } + if (edge.includes('s')) height = Math.max(minHeight, startHeight + dy); block.style.left = `${Math.round(left)}px`; block.style.top = `${Math.round(top)}px`; block.style.width = `${Math.round(width)}px`; block.style.height = `${Math.round(height)}px`; - configManualLayout = true; updateConfigCanvasHeight(); }; const end = () => { @@ -2065,17 +2198,22 @@ HTML = """ block.classList.remove('is-resizing'); captureConfigLayout(block); saveConfigLayout(); + block._suppressSummaryClick = true; + window.setTimeout(() => { block._suppressSummaryClick = false; }, 0); }; block.addEventListener('pointermove', move); block.addEventListener('pointerup', end, {once: true}); block.addEventListener('pointercancel', end, {once: true}); }); block.addEventListener('pointermove', event => { - if (!block.classList.contains('is-resizing') && window.innerWidth > 760) block.style.cursor = configResizeEdge(event, block) ? configResizeEdge(event, block) + '-resize' : ''; + if (!block.classList.contains('is-resizing')) { + const edge = configResizeEdge(event, block); + block.style.cursor = edge ? `${edge}-resize` : ''; + } }); block.addEventListener('pointerleave', () => { if (!block.classList.contains('is-resizing')) block.style.cursor = ''; }); summary.addEventListener('pointerdown', event => { - if (event.button !== 0 || !configCanvas || window.innerWidth <= 760) return; + if (event.button !== 0 || !configCanvas) return; const startX = event.clientX; const startY = event.clientY; const startLeft = parseFloat(block.style.left) || block.offsetLeft || 0; @@ -2092,10 +2230,9 @@ HTML = """ if (trash) { trash.hidden = false; trash.classList.remove('is-over'); } } event.preventDefault(); - const maxLeft = Math.max(0, configCanvas.clientWidth - block.offsetWidth - 6); + const maxLeft = Math.max(0, configCanvasWidth() - block.offsetWidth); block.style.left = `${Math.max(0, Math.min(maxLeft, Math.round(startLeft + moveEvent.clientX - startX)))}px`; block.style.top = `${Math.max(0, Math.round(startTop + moveEvent.clientY - startY))}px`; - configManualLayout = true; setTrashHover(moveEvent); updateConfigCanvasHeight(); }; @@ -2114,19 +2251,23 @@ HTML = """ block.addEventListener('toggle', updateConfigCanvasHeight); } function resetConfigLayout() { - configLayout = {}; - configManualLayout = false; - configZIndex = 1; + const custom = {}; configBlocks.forEach(block => { + if (block.dataset.custom === 'true') custom[block.dataset.section] = {custom: true, template: configTemplateKey(block), title: configTitle(block)}; block.style.left = ''; block.style.top = ''; block.style.width = ''; block.style.height = ''; block.style.zIndex = ''; block.open = true; - setConfigBlockState(block, ''); + block.dataset.blockState = ''; + block.classList.remove('config-block-hidden', 'config-block-deleted'); + block.removeAttribute('aria-hidden'); }); - try { localStorage.removeItem('fpv-config-layout-v1'); localStorage.removeItem('fpv-open-sections'); } catch (_error) {} + configLayout = custom; + configZIndex = 1; + try { localStorage.removeItem('fpv-open-sections'); } catch (_error) {} + saveConfigLayout(); applyStoredConfigLayout(); renderConfigRestorePanel(); setText('configLayoutState', 'раскладка сброшена'); @@ -2134,13 +2275,21 @@ HTML = """ function initConfigCanvas() { configCanvas = $('controlForm'); if (!configCanvas) return; - configBlocks = Array.from(configCanvas.children).filter(node => node.matches('details[data-section]')); configLayout = readConfigLayout(); + configBlocks = Array.from(configCanvas.children).filter(node => node.matches('details[data-section]')); + Object.entries(configLayout).forEach(([key, entry]) => { + if (!entry || entry.custom !== true || configBlocks.some(block => block.dataset.section === key)) return; + createCustomConfigBlock(entry.template || 'source', entry.title || 'Новый блок', key); + }); configBlocks.forEach(block => { block.classList.add('config-block'); makeConfigBlockInteractive(block); const state = configLayout[block.dataset.section]?.state; - if (state) setConfigBlockState(block, state); + if (state) { + block.dataset.blockState = state; + block.classList.add(state === 'deleted' ? 'config-block-deleted' : 'config-block-hidden'); + block.setAttribute('aria-hidden', 'true'); + } }); applyStoredConfigLayout(); renderConfigRestorePanel(); @@ -2149,14 +2298,23 @@ HTML = """ restorePanelOpen = !restorePanelOpen; renderConfigRestorePanel(); }); - window.addEventListener('resize', () => { - if (!configManualLayout) applyStoredConfigLayout(); - else updateConfigCanvasHeight(); + $('configCreateBlock')?.addEventListener('click', () => { + const templateKey = $('configTemplateSelect')?.value || 'source'; + const title = String($('configBlockTitle')?.value || '').trim().slice(0, 48) || 'Новый блок'; + const block = createCustomConfigBlock(templateKey, title); + if (!block) return; + makeConfigBlockInteractive(block); + positionNewConfigBlock(block); + bringConfigBlockFront(block); + captureConfigLayout(block); + saveConfigLayout(); + if ($('configBlockTitle')) $('configBlockTitle').value = ''; + renderConfigRestorePanel(); + setText('configLayoutState', `создан блок «${configTitle(block)}»`); }); + window.addEventListener('resize', applyStoredConfigLayout); if (window.ResizeObserver) { - const observer = new ResizeObserver(() => { - updateConfigCanvasHeight(); - }); + const observer = new ResizeObserver(updateConfigCanvasHeight); configBlocks.forEach(block => observer.observe(block)); } } @@ -2920,10 +3078,7 @@ HTML = """ $('archiveTab').hidden = name !== 'archive'; $('settingsTab').hidden = name !== 'settings'; $('modelTab').hidden = name !== 'model'; - if (name === 'settings' && typeof applyStoredConfigLayout === 'function') requestAnimationFrame(() => { - if (!configManualLayout) applyStoredConfigLayout(); - else updateConfigCanvasHeight(); - }); + if (name === 'settings' && typeof applyStoredConfigLayout === 'function') requestAnimationFrame(applyStoredConfigLayout); if (name === 'archive') loadArchive(); if (updateHash && location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`); }