feat(ui): move selected block groups

main
AlexsandrSnytkin 12 hours ago
parent 3e1bfc982a
commit 22b361f6a9

@ -1602,7 +1602,7 @@ HTML = """<!doctype html>
<button id="configResetLayout" type="button">сбросить раскладку</button>
<button id="configRestoreToggle" type="button" aria-expanded="false">структура</button>
<label class="config-scale-control" title="Масштаб элементов и текста выбранного блока: 50200%"><span id="configScaleLabel">масштаб блока</span><input id="configContentScale" type="range" min="50" max="200" step="5" value="100" aria-label="Масштаб содержимого выбранного блока, от 50 до 200 процентов" disabled><output id="configContentScaleValue">100%</output></label>
<span id="configLayoutState" class="config-layout-state">перетаскивайте за заголовок или свободное место</span>
<span id="configLayoutState" class="config-layout-state">Ctrl/Cmd + клик выбрать несколько · перетаскивайте группу</span>
</div>
<div id="configWorkspace" class="config-workspace">
<div id="configRestorePanel" class="config-restore-panel" hidden>
@ -1621,7 +1621,7 @@ HTML = """<!doctype html>
</label>
<button id="configCreateBlock" type="button">создать блок</button>
</div>
<span class="config-structure-hint">Клик по строке фокусирует блок. Перетаскивайте на canvas, меняйте размер границей, удаляйте через корзину.</span>
<span class="config-structure-hint">Клик фокусирует блок. Ctrl/Cmd + клик выбирает несколько. Перетаскивайте группу на canvas или в корзину.</span>
<div id="configRestoreList" class="config-restore-list"></div>
<button id="configDeletedToggle" class="config-deleted-toggle" type="button" hidden>корзина</button>
<div id="configDeletedList" class="config-deleted-list" hidden></div>
@ -1827,7 +1827,7 @@ HTML = """<!doctype html>
<button id="networkResetLayout" type="button">сбросить раскладку</button>
<button id="networkStructureToggle" type="button" aria-expanded="false">структура</button>
<label class="config-scale-control" title="Масштаб элементов и текста выбранного сетевого блока"><span>масштаб блока</span><input id="networkContentScale" type="range" min="50" max="200" step="5" value="100" aria-label="Масштаб содержимого выбранного сетевого блока" disabled><output id="networkContentScaleValue">100%</output></label>
<span id="networkLayoutState" class="config-layout-state">перетаскивайте блоки за свободное место</span>
<span id="networkLayoutState" class="config-layout-state">Ctrl/Cmd + клик выбрать несколько · перетаскивайте группу</span>
</div>
<div id="networkWorkspace" class="network-workspace">
<aside id="networkRestorePanel" class="config-restore-panel network-restore-panel" hidden>
@ -2092,6 +2092,7 @@ HTML = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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();

Loading…
Cancel
Save