From b8c90886ef1f3afb8d908aac89028bd177836cae Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 9 Jul 2026 16:09:35 -0400 Subject: feat(admin): multi-select with shift+drag for common bulk operations --- internal/admin/static/map.js | 347 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 346 insertions(+), 1 deletion(-) (limited to 'internal/admin/static/map.js') diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index aeb2d57..1f67275 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -9,6 +9,8 @@ var ctrlHeld = false; var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; +var multiSelectedIds = new Set(); +var multiDrag = false, multiSubtract = false, multiStartSVGX = 0, multiStartSVGY = 0; var dirChangedByUser = false; var currentPanelTab = 0; var gridDirs = [ @@ -33,6 +35,10 @@ function isCtrlKey(e) { } function onMapMouseMove(e) { + if (multiDrag) { + updateSelectionBox(e); + return; + } if (!dragging) return; var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; @@ -65,6 +71,13 @@ function onMapMouseUp(e) { document.removeEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = false; + if (multiDrag) { + finalizeMultiSelect(e); + multiDrag = false; multiSubtract = false; dragging = false; dragRoom = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + return; + } + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; if (delFromId && !delDrag && !moved) { @@ -113,7 +126,9 @@ function cancelMapDrag() { clearLinkHighlight(); clearDelHighlight(); hideDragBadge(); + removeSelectionBox(); dragging = false; dragRoom = false; + multiDrag = false; multiSubtract = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); @@ -153,6 +168,7 @@ function refreshDragMode(ctrlDown) { function changeZ(dz) { if (dz === 0) currentZ = 0; else currentZ += dz; panX = 0; panY = 0; scale = 1; + clearMultiSelectSilent(); loadMap(); } @@ -161,6 +177,7 @@ function changeDir(dir) { dirChangedByUser = true; selectedRoom = null; panX = 0; panY = 0; scale = 1; + clearMultiSelectSilent(); loadMap(); } @@ -279,7 +296,12 @@ function renderMap(data) { var fill = resolveColor(r.color); var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff'; var sel = selectedRoom === r.id; - roomHTML += ''; + var multi = multiSelectedIds.has(r.id); + var stroke = '#fff', strokeW = 3; + if (sel && !multi) { stroke = '#fff'; strokeW = 3; } + else if (multi) { stroke = '#5cf'; strokeW = 3.5; } + else { stroke = fill; strokeW = 1.5; } + roomHTML += ''; var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)'; roomHTML += '#'+r.id+''; @@ -331,6 +353,7 @@ function renderMap(data) { svg.setAttribute('tabindex', '0'); svg.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } + if (e.key === 'Escape' && multiSelectedIds.size > 0) { clearMultiSelect(); e.preventDefault(); } }); svg.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } @@ -345,6 +368,10 @@ function renderMap(data) { var onUdBtn = e.target.classList.contains('ud-btn'); var onNavBtn = e.target.classList.contains('nav-btn'); + if (!e.shiftKey && multiSelectedIds.size > 0) { + clearMultiSelect(); + } + if (onNavBtn) { e.stopPropagation(); var dz = parseInt(e.target.getAttribute('data-dz')); @@ -354,6 +381,26 @@ function renderMap(data) { return; } + if (e.shiftKey && e.button === 2) { + e.preventDefault(); + multiDrag = true; + multiSubtract = true; + dragging = true; + if (svg.focus) svg.focus(); + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + var coords = screenToSVGCoords(e); + multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY; + dragRoom = true; + linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); clearDelHighlight(); + document.addEventListener('mousemove', onMapMouseMove); + document.addEventListener('mouseup', onMapMouseUp); + _mapMouseCleanup = true; + return; + } + if (e.button === 2 && onRoom) { delFromId = parseInt(e.target.getAttribute('data-id')); delDrag = false; delTargetId = null; delOneWay = false; @@ -378,6 +425,24 @@ function renderMap(data) { return; } + if (e.shiftKey) { + multiDrag = true; + dragging = true; + if (svg.focus) svg.focus(); + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + var coords = screenToSVGCoords(e); + multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY; + dragRoom = true; + linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); clearDelHighlight(); + document.addEventListener('mousemove', onMapMouseMove); + document.addEventListener('mouseup', onMapMouseUp); + _mapMouseCleanup = true; + return; + } + dragRoom = onRoom || onGhost; dragging = true; if (svg.focus) svg.focus(); @@ -450,6 +515,8 @@ function selectRoom(id) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } var prev = selectedRoom; selectedRoom = id; + multiSelectedIds.clear(); + removeSelectionBox(); if (mapData && prev !== id) { renderMap(mapData); @@ -2885,6 +2952,284 @@ function reloadRoomDirs(cb) { }); } +function screenToSVGCoords(e) { + var svgEl = $('#mapSvg'); + if (!svgEl) return null; + var rect = svgEl.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + var vb = svgEl.getAttribute('viewBox'); + if (!vb) return null; + var parts = vb.split(' '); + var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]); + var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]); + var svgX = vbX + mx * vbW / rect.width; + var svgY = vbY + my * vbH / rect.height; + return { + svgX: svgX, svgY: svgY, + gridX: Math.floor(svgX / CELL), + gridY: Math.floor(svgY / CELL) + }; +} + +function updateSelectionBox(e) { + var coords = screenToSVGCoords(e); + if (!coords) return; + + var g = document.querySelector('#mapSvg g'); + if (!g) return; + + var x1 = Math.min(multiStartSVGX, coords.svgX); + var y1 = Math.min(multiStartSVGY, coords.svgY); + var x2 = Math.max(multiStartSVGX, coords.svgX); + var y2 = Math.max(multiStartSVGY, coords.svgY); + + var box = document.getElementById('__multi_box'); + if (!box) { + var ns = 'http://www.w3.org/2000/svg'; + box = document.createElementNS(ns, 'rect'); + box.id = '__multi_box'; + box.classList.add('selection-box'); + g.appendChild(box); + } + box.setAttribute('x', x1); + box.setAttribute('y', y1); + box.setAttribute('width', x2 - x1); + box.setAttribute('height', y2 - y1); + if (multiSubtract) { + box.classList.add('subtract'); + } else { + box.classList.remove('subtract'); + } + + var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)}; + var gx1 = Math.min(startCoords.gridX, coords.gridX); + var gy1 = Math.min(startCoords.gridY, coords.gridY); + var gx2 = Math.max(startCoords.gridX, coords.gridX); + var gy2 = Math.max(startCoords.gridY, coords.gridY); + + var allRms = document.querySelectorAll('.rm'); + for (var r = 0; r < allRms.length; r++) { + var rm = allRms[r]; + var rid = parseInt(rm.getAttribute('data-id')); + if (multiSelectedIds.has(rid)) { + rm.setAttribute('stroke', '#5cf'); + rm.setAttribute('stroke-width', '3.5'); + } else { + var fill = rm.getAttribute('fill'); + rm.setAttribute('stroke', fill); + rm.setAttribute('stroke-width', '1.5'); + } + } + + for (var gx = gx1; gx <= gx2; gx++) { + for (var gy = gy1; gy <= gy2; gy++) { + var rId = occupied[gx+','+gy]; + if (rId === undefined) continue; + var rm = document.querySelector('.rm[data-id="'+rId+'"]'); + if (!rm) continue; + if (multiSubtract) { + if (multiSelectedIds.has(rId)) { + rm.setAttribute('stroke', '#f55'); + rm.setAttribute('stroke-width', '3.5'); + } + } else { + if (!multiSelectedIds.has(rId)) { + rm.setAttribute('stroke', '#5cf'); + rm.setAttribute('stroke-width', '3.5'); + } + } + } + } +} + +function removeSelectionBox() { + var box = document.getElementById('__multi_box'); + if (box) box.remove(); +} + +function finalizeMultiSelect(e) { + var coords = screenToSVGCoords(e); + removeSelectionBox(); + + if (!coords) return; + + var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)}; + var gx1 = Math.min(startCoords.gridX, coords.gridX); + var gy1 = Math.min(startCoords.gridY, coords.gridY); + var gx2 = Math.max(startCoords.gridX, coords.gridX); + var gy2 = Math.max(startCoords.gridY, coords.gridY); + + var changed = false; + for (var gx = gx1; gx <= gx2; gx++) { + for (var gy = gy1; gy <= gy2; gy++) { + var key = gx+','+gy; + if (occupied[key] !== undefined) { + if (multiSubtract) { + if (multiSelectedIds.delete(occupied[key])) changed = true; + } else { + if (!multiSelectedIds.has(occupied[key])) { + multiSelectedIds.add(occupied[key]); + changed = true; + } + } + } + } + } + + if (multiSelectedIds.size === 0) { + clearMultiSelect(); + return; + } + + selectedRoom = null; + if (mapData) renderMap(mapData); + renderBulkPanel(); +} + +function clearMultiSelect() { + multiSelectedIds.clear(); + multiSubtract = false; + removeSelectionBox(); + if (mapData) renderMap(mapData); + var panel = $('#panelContent'); + if (panel) panel.innerHTML = '

Click a room to view details

'; +} + +function clearMultiSelectSilent() { + multiSelectedIds.clear(); + multiSubtract = false; + removeSelectionBox(); + var panel = $('#panelContent'); + if (panel) panel.innerHTML = '

Click a room to view details

'; +} + +function renderBulkPanel() { + if (multiSelectedIds.size === 0) return; + var panel = $('#panelContent'); + if (!panel) return; + + var ids = Array.from(multiSelectedIds); + var rooms = ids.map(function(id) { return roomMap[id]; }).filter(Boolean); + + var colorMixed = false, firstColor = rooms.length > 0 ? (rooms[0].color || '') : ''; + var blockNone = true, blockAll = true; + var dirMixedSet = new Set(); + var hazardsByRoom = {}; + + rooms.forEach(function(r) { + if ((r.color || '') !== firstColor) colorMixed = true; + if (r.block_transport) blockNone = false; else blockAll = false; + if (r.dir) dirMixedSet.add(r.dir); + var h = r.hazard || ''; + if (h) { + if (!hazardsByRoom[h]) hazardsByRoom[h] = []; + hazardsByRoom[h].push(r.id); + } + }); + + var hazardNames = Object.keys(hazardsByRoom); + + var html = '

' + ids.length + ' rooms selected

'; + html += '
'; + + var colorVal = colorMixed ? '' : firstColor; + var swatchBg = colorVal ? resolveColor(colorVal) : '#888'; + html += '
'; + + html += '
'; + + html += '
'; + + html += ''; + + if (hazardNames.length > 0) { + hazardNames.forEach(function(h) { + html += '
' + esc(h) + '
'; + }); + } + + html += '
'; + panel.innerHTML = html; + + var preloadDirs = dirMixedSet.size === 1 ? dirMixedSet.values().next().value : null; + + setTimeout(function() { + var cf = document.querySelector('#panelContent .color-field'); + if (cf) { + bindColorField(cf); + var ci = cf.querySelector('input'); + if (ci) ci.addEventListener('blur', function() { bulkColorChanged(); }); + } + + var blockCB = document.getElementById('bulkBlockTransport'); + if (blockCB) { + if (blockNone) { blockCB.checked = false; blockCB.indeterminate = false; } + else if (blockAll) { blockCB.checked = true; blockCB.indeterminate = false; } + else { blockCB.indeterminate = true; } + } + + loadRoomDirs(function(dirs) { + var sel = $('#bulkRoomDir'); + if (!sel) return; + sel.innerHTML = ''; + var mixedOpt = document.createElement('option'); + mixedOpt.value = ''; + mixedOpt.textContent = 'Mixed\u2026'; + if (!preloadDirs) mixedOpt.selected = true; + sel.appendChild(mixedOpt); + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + if (preloadDirs && d === preloadDirs) opt.selected = true; + sel.appendChild(opt); + }); + }); + }, 50); +} + +function bulkColorChanged() { + if (window._colorPickerActive) return; + var el = $('#bulkRoomColor'); + if (!el) return; + var v = el.value; + if (v.length === 2 || v.length === 0) { + doBulkSave({color: v}); + } +} + +function bulkBlockChanged() { + var el = document.getElementById('bulkBlockTransport'); + if (!el) return; + doBulkSave({block_transport: el.checked}); +} + +function searchBulkHazards(input) { + searchAndShow(input, 'hazards', function(r) { doBulkSave({hazard: r.id}); }); +} + +function doBulkSave(patch, customIds) { + var ids = customIds || Array.from(multiSelectedIds); + if (ids.length === 0) return; + + if (patch.hasOwnProperty('hazard') && patch.hazard === null) delete patch.hazard; + if (patch.hasOwnProperty('dir') && (patch.dir === '' || patch.dir === null)) delete patch.dir; + if (!patch.hasOwnProperty('hazard') && !patch.hasOwnProperty('block_transport') && !patch.hasOwnProperty('color') && !patch.hasOwnProperty('dir')) return; + + API.post('/api/rooms/bulk-edit', {patch: patch, ids: ids}).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Updated ' + res.count + ' rooms', 'success'); + updateUndoBar(); + loadMap().then(function() { renderBulkPanel(); }); + }).catch(function(e) { + notify('Bulk update failed: ' + extractError(e), 'error'); + }); +} + document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); -- cgit v1.2.3