fix(ui): stabilize config canvas interactions

main
AlexsandrSnytkin 18 hours ago
parent 52a203a5a2
commit 5bd377e16f

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

@ -1283,15 +1283,26 @@ HTML = """<!doctype 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 = """<!doctype 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 = """<!doctype html>
<div class="config-toolbar" role="toolbar" aria-label="Управление блоками конфигурации">
<span class="config-toolbar-title">Блоки конфигурации</span>
<button id="configResetLayout" type="button">сбросить раскладку</button>
<button id="configRestoreToggle" type="button" aria-expanded="false">скрытые блоки</button>
<button id="configRestoreToggle" type="button" aria-expanded="false">структура</button>
<span id="configLayoutState" class="config-layout-state">перетаскивайте блоки за </span>
</div>
<div id="configRestorePanel" class="config-restore-panel" hidden>
<span class="config-restore-title">Скрытые и удалённые блоки</span>
<span class="config-restore-title">Структура canvas</span>
<div class="config-structure-create">
<label>Шаблон
<select id="configTemplateSelect">
<option value="source">Источник</option>
<option value="processing">Обработка</option>
<option value="archive">Архив</option>
<option value="advanced">Выход ошибки и калибровка</option>
</select>
</label>
<label>Название нового блока
<input id="configBlockTitle" type="text" maxlength="48" placeholder="Мой блок">
</label>
<button id="configCreateBlock" type="button">создать блок</button>
</div>
<span class="config-structure-hint">Перетаскивайте заголовок. Граница меняет размер. Клик поднимает блок. Перетащите в корзину для удаления.</span>
<div id="configRestoreList" class="config-restore-list"></div>
</div>
<form id="controlForm" class="control-form config-canvas">
@ -1843,8 +1871,8 @@ HTML = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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}`);
}

Loading…
Cancel
Save