diff --git a/ui_server.py b/ui_server.py
index 90129ac..f0b5c85 100644
--- a/ui_server.py
+++ b/ui_server.py
@@ -1602,7 +1602,7 @@ HTML = """
сбросить раскладку
структура
масштаб блока 100%
- перетаскивайте за заголовок или свободное место
+ Ctrl/Cmd + клик — выбрать несколько · перетаскивайте группу
@@ -1621,7 +1621,7 @@ HTML = """
создать блок
-
Клик по строке фокусирует блок. Перетаскивайте на canvas, меняйте размер границей, удаляйте через корзину.
+
Клик фокусирует блок. Ctrl/Cmd + клик выбирает несколько. Перетаскивайте группу на canvas или в корзину.
корзина
@@ -1827,7 +1827,7 @@ HTML = """
сбросить раскладку
структура
масштаб блока 100%
-
перетаскивайте блоки за свободное место
+
Ctrl/Cmd + клик — выбрать несколько · перетаскивайте группу
@@ -2092,6 +2092,7 @@ HTML = """
let configContentScale = 1;
let configBlockObserver = null;
let selectedConfigBlock = null;
+ let selectedConfigBlocks = new Set();
function readConfigLayout() {
try {
const saved = JSON.parse(localStorage.getItem('fpv-config-layout-v1') || '{}');
@@ -2135,23 +2136,34 @@ HTML = """
saveConfigLayout();
}
function selectConfigBlock(block) {
- if (!block || configBlockState(block)) {
- selectedConfigBlock = null;
- configBlocks.forEach(other => other.classList.remove('is-selected'));
- const control = $('configContentScale');
+ selectedConfigBlocks = new Set(block && !configBlockState(block) ? [block] : []);
+ updateConfigSelection(block);
+ }
+ function updateConfigSelection(preferred = selectedConfigBlock) {
+ selectedConfigBlocks = new Set([...selectedConfigBlocks].filter(block => configBlocks.includes(block) && !configBlockState(block)));
+ selectedConfigBlock = selectedConfigBlocks.has(preferred) ? preferred : [...selectedConfigBlocks].at(-1) || null;
+ configBlocks.forEach(other => other.classList.toggle('is-selected', selectedConfigBlocks.has(other)));
+ $('configRestorePanel')?.querySelectorAll('.config-restore-item').forEach(item => item.classList.toggle('is-selected', selectedConfigBlocks.has(item._configBlock)));
+ const control = $('configContentScale');
+ if (!selectedConfigBlock) {
if (control) control.disabled = true;
return;
}
- selectedConfigBlock = block;
- configBlocks.forEach(other => other.classList.toggle('is-selected', other === block));
- const entry = configLayout[block.dataset.section] || {};
+ const entry = configLayout[selectedConfigBlock.dataset.section] || {};
const value = Math.round((Number(entry.scale) || 100));
configContentScale = Math.max(.5, Math.min(2, value / 100));
setValue('configContentScale', value);
setText('configContentScaleValue', `${value}%`);
- const control = $('configContentScale');
- if (control) control.disabled = false;
- updateConfigBlockScale(block);
+ if (control) control.disabled = selectedConfigBlocks.size !== 1;
+ updateConfigBlockScale(selectedConfigBlock);
+ if (selectedConfigBlocks.size > 1) setText('configLayoutState', `выбрано ${selectedConfigBlocks.size} блока · перетаскивайте группу`);
+ }
+ function toggleConfigBlockSelection(block, additive = false) {
+ if (!block || configBlockState(block)) return;
+ if (!additive) return selectConfigBlock(block);
+ if (selectedConfigBlocks.has(block)) selectedConfigBlocks.delete(block);
+ else selectedConfigBlocks.add(block);
+ updateConfigSelection(block);
}
function configTitle(block) {
return block.querySelector(':scope > summary .block-title')?.textContent || block.dataset.section || 'Блок';
@@ -2283,29 +2295,39 @@ HTML = """
if (fitted) saveConfigLayout();
updateConfigCanvasHeight();
}
- function setConfigBlockState(block, state) {
- if (!block) return;
- if (state && block.contains(document.activeElement)) document.activeElement.blur();
- const key = block.dataset.section;
- if (key && state) configLayout[key] = {...(configLayout[key] || {}), state};
- else if (key && configLayout[key]) {
- delete configLayout[key].state;
- if (!Object.keys(configLayout[key]).length) delete configLayout[key];
- }
- block.dataset.blockState = state || '';
- block.classList.toggle('config-block-hidden', state === 'hidden');
- block.classList.toggle('config-block-deleted', state === 'deleted');
- if (state) block.setAttribute('aria-hidden', 'true');
- else block.removeAttribute('aria-hidden');
+ function setConfigBlockStates(blocks, state) {
+ const items = [...new Set(blocks)].filter(block => block && configBlocks.includes(block));
+ if (!items.length) return;
+ items.forEach(block => {
+ if (state && block.contains(document.activeElement)) document.activeElement.blur();
+ const key = block.dataset.section;
+ if (key && state) configLayout[key] = {...(configLayout[key] || {}), state};
+ else if (key && configLayout[key]) {
+ delete configLayout[key].state;
+ if (!Object.keys(configLayout[key]).length) delete configLayout[key];
+ }
+ block.dataset.blockState = state || '';
+ block.classList.toggle('config-block-hidden', state === 'hidden');
+ block.classList.toggle('config-block-deleted', state === 'deleted');
+ if (state) block.setAttribute('aria-hidden', 'true');
+ else block.removeAttribute('aria-hidden');
+ });
saveConfigLayout();
applyStoredConfigLayout();
renderConfigRestorePanel();
selectConfigBlock(configBlocks.find(candidate => !configBlockState(candidate)) || null);
}
- function focusConfigBlock(block, item) {
+ function setConfigBlockState(block, state) { setConfigBlockStates([block], state); }
+ function focusConfigBlock(block, item, event) {
if (!block || configBlockState(block)) return;
+ if (event?.ctrlKey || event?.metaKey) {
+ toggleConfigBlockSelection(block, true);
+ item?.classList.toggle('is-selected', selectedConfigBlocks.has(block));
+ bringConfigBlockFront(block);
+ return;
+ }
configBlocks.forEach(other => other.classList.remove('is-selected'));
- document.querySelectorAll('.config-restore-item.is-selected').forEach(node => node.classList.remove('is-selected'));
+ $('configRestorePanel')?.querySelectorAll('.config-restore-item.is-selected').forEach(node => node.classList.remove('is-selected'));
selectConfigBlock(block);
item?.classList.add('is-selected');
bringConfigBlockFront(block);
@@ -2314,7 +2336,8 @@ HTML = """
function renderConfigItem(list, block, deleted = false) {
const state = configBlockState(block);
const item = document.createElement('div');
- item.className = `config-restore-item${state ? ' is-hidden' : ''}`;
+ item.className = `config-restore-item${state ? ' is-hidden' : ''}${selectedConfigBlocks.has(block) ? ' is-selected' : ''}`;
+ item._configBlock = block;
item.title = 'Нажмите, чтобы показать блок на canvas';
const label = document.createElement('span');
label.className = 'config-item-title';
@@ -2353,7 +2376,7 @@ HTML = """
actions.appendChild(remove);
}
item.append(label, stateNode, actions);
- item.addEventListener('click', () => focusConfigBlock(block, item));
+ item.addEventListener('click', event => focusConfigBlock(block, item, event));
list.appendChild(item);
}
function renderConfigRestorePanel() {
@@ -2384,7 +2407,8 @@ HTML = """
panel.hidden = !restorePanelOpen;
workspace?.classList.toggle('has-structure', restorePanelOpen);
const visible = configBlocks.length - hidden.length - deleted.length;
- setText('configLayoutState', `${visible} блоков · ${hidden.length} скрыто · ${deleted.length} в корзине`);
+ const selected = selectedConfigBlocks.size > 1 ? ` · выбрано ${selectedConfigBlocks.size}` : '';
+ setText('configLayoutState', `${visible} блоков · ${hidden.length} скрыто · ${deleted.length} в корзине${selected}`);
if (configCanvas) applyStoredConfigLayout();
}
function removeConfigBlockForever(block) {
@@ -2484,7 +2508,7 @@ HTML = """
trash.hidden = false;
trash.classList.remove('is-over');
}
- function finishConfigDrag(block, event, dragged) {
+ function finishConfigDrag(block, event, dragged, group = [block]) {
const trash = $('configTrash');
const droppedOnTrash = trashDrop(event);
if (trash) {
@@ -2492,14 +2516,16 @@ HTML = """
trash.hidden = true;
}
if (!dragged) return;
- captureConfigLayout(block);
- if (droppedOnTrash) setConfigBlockState(block, 'deleted');
+ group.forEach(item => captureConfigLayout(item));
+ if (droppedOnTrash) setConfigBlockStates(group, 'deleted');
else {
saveConfigLayout();
setText('configLayoutState', `${configBlocks.length - configBlocks.filter(item => configBlockState(item)).length} блоков · раскладка сохранена`);
}
- block._suppressSummaryClick = true;
- window.setTimeout(() => { block._suppressSummaryClick = false; }, 0);
+ group.forEach(item => {
+ item._suppressSummaryClick = true;
+ window.setTimeout(() => { item._suppressSummaryClick = false; }, 0);
+ });
}
function makeConfigBlockInteractive(block) {
const summary = block.firstElementChild;
@@ -2522,9 +2548,16 @@ HTML = """
block.open = true;
});
block.addEventListener('pointerdown', event => {
- selectConfigBlock(block);
- bringConfigBlockFront(block);
+ const additive = event.ctrlKey || event.metaKey;
const edge = configResizeEdge(event, block);
+ if (additive && !edge && !configDragBlocked(block, event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ toggleConfigBlockSelection(block, true);
+ return;
+ }
+ if (edge || !selectedConfigBlocks.has(block)) selectConfigBlock(block);
+ bringConfigBlockFront(block);
if (!edge || configDragBlocked(block, event)) return;
event.preventDefault();
event.stopPropagation();
@@ -2603,8 +2636,10 @@ HTML = """
if (event.button !== 0 || !configCanvas || configDragBlocked(block, event)) return;
const startX = event.clientX;
const startY = event.clientY;
- const startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
- const startTop = parseFloat(block.style.top) || block.offsetTop || 0;
+ const group = selectedConfigBlocks.has(block)
+ ? [...selectedConfigBlocks].filter(item => !configBlockState(item))
+ : [block];
+ const origins = group.map(item => ({item, left: parseFloat(item.style.left) || item.offsetLeft || 0, top: parseFloat(item.style.top) || item.offsetTop || 0}));
let dragged = false;
showConfigTrash();
block.setPointerCapture?.(event.pointerId);
@@ -2613,12 +2648,18 @@ HTML = """
if (!dragged) {
dragged = true;
event.preventDefault();
- block.classList.add('is-dragging');
+ group.forEach(item => item.classList.add('is-dragging'));
}
event.preventDefault();
- 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`;
+ const rawDx = moveEvent.clientX - startX;
+ const minDx = -Math.min(...origins.map(origin => origin.left));
+ const maxDx = Math.min(...origins.map(origin => configCanvasWidth() - origin.item.offsetWidth - origin.left));
+ const dx = Math.max(minDx, Math.min(maxDx, rawDx));
+ const dy = moveEvent.clientY - startY;
+ origins.forEach(origin => {
+ origin.item.style.left = `${Math.max(0, Math.round(origin.left + dx))}px`;
+ origin.item.style.top = `${Math.max(0, Math.round(origin.top + dy))}px`;
+ });
setTrashHover(moveEvent);
updateConfigCanvasHeight();
};
@@ -2627,8 +2668,8 @@ HTML = """
block.removeEventListener('pointermove', move);
block.removeEventListener('pointerup', end);
block.removeEventListener('pointercancel', end);
- block.classList.remove('is-dragging');
- finishConfigDrag(block, endEvent, dragged);
+ group.forEach(item => item.classList.remove('is-dragging'));
+ finishConfigDrag(block, endEvent, dragged, group);
};
block.addEventListener('pointermove', move);
block.addEventListener('pointerup', end, {once: true});
@@ -2734,6 +2775,7 @@ HTML = """
let networkLayout = {};
let networkZIndex = 1;
let networkSelectedBlock = null;
+ let selectedNetworkBlocks = new Set();
let networkStructureOpen = false;
let networkContentScale = 1;
function readNetworkLayout() {
@@ -2769,22 +2811,33 @@ HTML = """
block.style.setProperty('--block-scale', Math.max(.5, Math.min(2, Math.sqrt(area) * block._networkUserScale)).toFixed(3));
}
function selectNetworkBlock(block) {
- if (!block || networkState(block)) {
- networkSelectedBlock = null;
- networkBlocks.forEach(item => item.classList.remove('is-selected'));
- const control = $('networkContentScale');
+ selectedNetworkBlocks = new Set(block && !networkState(block) ? [block] : []);
+ updateNetworkSelection(block);
+ }
+ function updateNetworkSelection(preferred = networkSelectedBlock) {
+ selectedNetworkBlocks = new Set([...selectedNetworkBlocks].filter(block => networkBlocks.includes(block) && !networkState(block)));
+ networkSelectedBlock = selectedNetworkBlocks.has(preferred) ? preferred : [...selectedNetworkBlocks].at(-1) || null;
+ networkBlocks.forEach(item => item.classList.toggle('is-selected', selectedNetworkBlocks.has(item)));
+ $('networkRestorePanel')?.querySelectorAll('.config-restore-item').forEach(item => item.classList.toggle('is-selected', selectedNetworkBlocks.has(item._networkBlock)));
+ const control = $('networkContentScale');
+ if (!networkSelectedBlock) {
if (control) control.disabled = true;
return;
}
- networkSelectedBlock = block;
- networkBlocks.forEach(item => item.classList.toggle('is-selected', item === block));
- const value = Math.max(50, Math.min(200, Math.round(Number(networkLayout[block.dataset.section]?.scale) || 100)));
+ const value = Math.max(50, Math.min(200, Math.round(Number(networkLayout[networkSelectedBlock.dataset.section]?.scale) || 100)));
networkContentScale = value / 100;
setValue('networkContentScale', value);
setText('networkContentScaleValue', `${value}%`);
- const control = $('networkContentScale');
- if (control) control.disabled = false;
- updateNetworkBlockScale(block);
+ if (control) control.disabled = selectedNetworkBlocks.size !== 1;
+ updateNetworkBlockScale(networkSelectedBlock);
+ if (selectedNetworkBlocks.size > 1) setText('networkLayoutState', `выбрано ${selectedNetworkBlocks.size} блока · перетаскивайте группу`);
+ }
+ function toggleNetworkBlockSelection(block, additive = false) {
+ if (!block || networkState(block)) return;
+ if (!additive) return selectNetworkBlock(block);
+ if (selectedNetworkBlocks.has(block)) selectedNetworkBlocks.delete(block);
+ else selectedNetworkBlocks.add(block);
+ updateNetworkSelection(block);
}
function applyNetworkContentScale(value) {
networkContentScale = Math.max(.5, Math.min(2, (Number(value) || 100) / 100));
@@ -2874,7 +2927,8 @@ HTML = """
networkBlocks.forEach(block => {
const state = networkState(block);
const item = document.createElement('div');
- item.className = `config-restore-item${state ? ' is-hidden' : ''}`;
+ item.className = `config-restore-item${state ? ' is-hidden' : ''}${selectedNetworkBlocks.has(block) ? ' is-selected' : ''}`;
+ item._networkBlock = block;
item.title = 'Нажмите, чтобы выбрать блок';
const label = document.createElement('span');
label.className = 'config-item-title';
@@ -2912,9 +2966,10 @@ HTML = """
actions.appendChild(remove);
}
item.append(label, stateNode, actions);
- item.addEventListener('click', () => {
- if (!state) selectNetworkBlock(block);
- item.classList.add('is-selected');
+ item.addEventListener('click', event => {
+ if (state) return;
+ if (event.ctrlKey || event.metaKey) toggleNetworkBlockSelection(block, true);
+ else selectNetworkBlock(block);
bringNetworkBlockFront(block);
});
list.appendChild(item);
@@ -2925,26 +2980,31 @@ HTML = """
toggle.setAttribute('aria-expanded', String(networkStructureOpen));
panel.hidden = !networkStructureOpen;
workspace?.classList.toggle('has-structure', networkStructureOpen);
- setText('networkLayoutState', `${networkBlocks.length - hidden - deleted} блоков · ${hidden} скрыто · ${deleted} в корзине`);
- }
- function setNetworkBlockState(block, state) {
- if (!block) return;
- const key = block.dataset.section;
- if (key && state) networkLayout[key] = {...(networkLayout[key] || {}), state};
- else if (key && networkLayout[key]) {
- delete networkLayout[key].state;
- if (!Object.keys(networkLayout[key]).length) delete networkLayout[key];
- }
- block.dataset.blockState = state || '';
- block.classList.toggle('is-hidden', state === 'hidden');
- block.classList.toggle('is-deleted', state === 'deleted');
- if (state) block.setAttribute('aria-hidden', 'true');
- else block.removeAttribute('aria-hidden');
+ const selected = selectedNetworkBlocks.size > 1 ? ` · выбрано ${selectedNetworkBlocks.size}` : '';
+ setText('networkLayoutState', `${networkBlocks.length - hidden - deleted} блоков · ${hidden} скрыто · ${deleted} в корзине${selected}`);
+ }
+ function setNetworkBlockStates(blocks, state) {
+ const items = [...new Set(blocks)].filter(block => block && networkBlocks.includes(block));
+ if (!items.length) return;
+ items.forEach(block => {
+ const key = block.dataset.section;
+ if (key && state) networkLayout[key] = {...(networkLayout[key] || {}), state};
+ else if (key && networkLayout[key]) {
+ delete networkLayout[key].state;
+ if (!Object.keys(networkLayout[key]).length) delete networkLayout[key];
+ }
+ block.dataset.blockState = state || '';
+ block.classList.toggle('is-hidden', state === 'hidden');
+ block.classList.toggle('is-deleted', state === 'deleted');
+ if (state) block.setAttribute('aria-hidden', 'true');
+ else block.removeAttribute('aria-hidden');
+ });
saveNetworkLayout();
applyNetworkLayout();
renderNetworkStructure();
selectNetworkBlock(networkBlocks.find(candidate => !networkState(candidate)) || null);
}
+ function setNetworkBlockState(block, state) { setNetworkBlockStates([block], state); }
function createNetworkBlock(templateKey, title, existingKey = '') {
const template = networkBlocks.find(block => block.dataset.custom !== 'true' && block.dataset.section === templateKey);
if (!template || !networkCanvas) return null;
@@ -3018,33 +3078,43 @@ HTML = """
const trash = $('networkTrash');
if (trash) { trash.hidden = false; trash.classList.remove('is-over'); }
}
- function finishNetworkDrag(block, event, dragged) {
+ function finishNetworkDrag(block, event, dragged, group = [block]) {
const trash = $('networkTrash');
const droppedOnTrash = networkTrashDrop(event);
if (trash) { trash.classList.remove('is-over'); trash.hidden = true; }
if (!dragged) return;
- captureNetworkLayout(block);
- if (droppedOnTrash) setNetworkBlockState(block, 'deleted');
+ group.forEach(item => captureNetworkLayout(item));
+ if (droppedOnTrash) setNetworkBlockStates(group, 'deleted');
else { saveNetworkLayout(); renderNetworkStructure(); }
- block._networkSuppressClick = true;
- window.setTimeout(() => { block._networkSuppressClick = false; }, 0);
+ group.forEach(item => {
+ item._networkSuppressClick = true;
+ window.setTimeout(() => { item._networkSuppressClick = false; }, 0);
+ });
}
function startNetworkDrag(block, event) {
if (event.button !== 0 || !networkCanvas || networkDragBlocked(block, event)) return;
const startX = event.clientX;
const startY = event.clientY;
- const startLeft = parseFloat(block.style.left) || block.offsetLeft || 0;
- const startTop = parseFloat(block.style.top) || block.offsetTop || 0;
+ const group = selectedNetworkBlocks.has(block)
+ ? [...selectedNetworkBlocks].filter(item => !networkState(item))
+ : [block];
+ const origins = group.map(item => ({item, left: parseFloat(item.style.left) || item.offsetLeft || 0, top: parseFloat(item.style.top) || item.offsetTop || 0}));
let dragged = false;
showNetworkTrash();
block.setPointerCapture?.(event.pointerId);
const move = moveEvent => {
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < 4) return;
- if (!dragged) { dragged = true; moveEvent.preventDefault(); block.classList.add('is-dragging'); }
+ if (!dragged) { dragged = true; moveEvent.preventDefault(); group.forEach(item => item.classList.add('is-dragging')); }
moveEvent.preventDefault();
- const maxLeft = Math.max(0, networkCanvasWidth() - 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`;
+ const rawDx = moveEvent.clientX - startX;
+ const minDx = -Math.min(...origins.map(origin => origin.left));
+ const maxDx = Math.min(...origins.map(origin => networkCanvasWidth() - origin.item.offsetWidth - origin.left));
+ const dx = Math.max(minDx, Math.min(maxDx, rawDx));
+ const dy = moveEvent.clientY - startY;
+ origins.forEach(origin => {
+ origin.item.style.left = `${Math.max(0, Math.round(origin.left + dx))}px`;
+ origin.item.style.top = `${Math.max(0, Math.round(origin.top + dy))}px`;
+ });
const trash = $('networkTrash');
if (trash) trash.classList.toggle('is-over', networkTrashDrop(moveEvent));
updateNetworkCanvasHeight();
@@ -3054,8 +3124,8 @@ HTML = """
block.removeEventListener('pointermove', move);
block.removeEventListener('pointerup', end);
block.removeEventListener('pointercancel', end);
- block.classList.remove('is-dragging');
- finishNetworkDrag(block, endEvent, dragged);
+ group.forEach(item => item.classList.remove('is-dragging'));
+ finishNetworkDrag(block, endEvent, dragged, group);
};
block.addEventListener('pointermove', move);
block.addEventListener('pointerup', end, {once: true});
@@ -3065,9 +3135,16 @@ HTML = """
if (!block || block._networkInteractive) return;
block._networkInteractive = true;
block.addEventListener('pointerdown', event => {
- selectNetworkBlock(block);
- bringNetworkBlockFront(block);
+ const additive = event.ctrlKey || event.metaKey;
const edge = networkResizeEdge(event, block);
+ if (additive && !edge && !networkDragBlocked(block, event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ toggleNetworkBlockSelection(block, true);
+ return;
+ }
+ if (edge || !selectedNetworkBlocks.has(block)) selectNetworkBlock(block);
+ bringNetworkBlockFront(block);
if (!edge || networkDragBlocked(block, event)) return;
event.preventDefault();
event.stopPropagation();