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/admin.css | 4 +
internal/admin/static/map.js | 347 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 350 insertions(+), 1 deletion(-)
(limited to 'internal/admin/static')
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index aa80488..393fafd 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -55,6 +55,10 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f
.link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64}
#mapSvg:focus{outline:none}
#mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none}
+.selection-box{fill:rgba(100,200,255,0.1);stroke:#5cf;stroke-width:1.5;stroke-dasharray:5,5;pointer-events:none}
+.selection-box.subtract{fill:rgba(255,80,80,0.1);stroke:#f55}
+.rm.multisel{cursor:pointer}
+.bulk-swatch{background:#888!important}
.notification{position:fixed;bottom:16px;left:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
.notification.error{border-color:var(--danger)}
.notification.success{border-color:var(--success)}
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 += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+
+ if (hazardNames.length > 0) {
+ hazardNames.forEach(function(h) {
+ html += '
';
+ });
+ }
+
+ 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