feat(ui): move selected block groups

main
AlexsandrSnytkin 13 hours ago
parent 3e1bfc982a
commit 22b361f6a9

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

Loading…
Cancel
Save