From 3f30fa3eec9c2e2acf9a984ccefb9529a25e688a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 7 Jul 2026 05:50:37 -0400 Subject: feat: add insert/remove room to context menu in admin web GUI to 'push' or 'pull' map rooms --- internal/admin/static/map.js | 241 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 233 insertions(+), 8 deletions(-) (limited to 'internal/admin/static/map.js') diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 3fdd543..9044286 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -17,6 +17,11 @@ var gridDirs = [ {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'}, {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'} ]; +var oppositeDir = { + north:'south', south:'north', east:'west', west:'east', + northeast:'southwest', northwest:'southeast', + southeast:'northwest', southwest:'northeast' +}; var saveTimer = null; var upTarget = {}, downTarget = {}; @@ -1926,7 +1931,7 @@ function deleteRoom(id) { selectedRoom = null; $('#panelContent').innerHTML = '
Room deleted
'; loadMap(); - }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); + }).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); }); } function createRoom(fromID, dir, oneway) { @@ -1942,39 +1947,249 @@ function createRoom(fromID, dir, oneway) { notify('Room #' + createdId + ' created', 'success'); updateUndoBar(); loadMap().then(function() { selectRoom(createdId); }); - }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); + }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); }); }); } function showRoomContextMenu(x, y, id) { var overlay = document.createElement('div'); overlay.className = 'cm-overlay'; - overlay.onclick = function() { overlay.remove(); menu.remove(); }; + // Closing the menu also kills any open submenu and pending hide timer. + var closeAll = function() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + overlay.remove(); menu.remove(); + }; + overlay.onclick = closeAll; + overlay.addEventListener('contextmenu', function(e) { + e.preventDefault(); + e.stopPropagation(); + var cx = e.clientX, cy = e.clientY; + closeAll(); + var el = document.elementFromPoint(cx, cy); + while (el && el !== document.body && !(el.classList && el.classList.contains('rm'))) { + el = el.parentElement; + } + if (el && el.classList && el.classList.contains('rm')) { + var rid = parseInt(el.getAttribute('data-id')); + if (rid) showRoomContextMenu(cx, cy, rid); + } + }); var menu = document.createElement('div'); menu.className = 'cm-menu'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; + menu.oncontextmenu = function(e) { e.preventDefault(); }; var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; delBtn.onclick = function() { - overlay.remove(); menu.remove(); + closeAll(); deleteRoom(id); }; menu.appendChild(delBtn); + menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu)); + menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu)); + var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; selBtn.onclick = function() { - overlay.remove(); menu.remove(); + closeAll(); selectRoom(id); }; menu.appendChild(selBtn); document.body.appendChild(overlay); document.body.appendChild(menu); + + // Clamp inside the viewport so the menu never spills off-screen. + clampMenuToViewport(menu); +} + +// buildSubmenuItem constructs a parent menu row ("Insert Room"/"Remove Room") +// that carries a nested submenu of the room's horizontal exits. The submenu is +// revealed by both hover (Windows-style: mouseenter with a small dismiss delay +// on mouseleave) and click (keyboard/mobile accessibility), mirroring how +// Windows cascading menus behave. closeAll closes the whole context menu; +// overlay carries the shared hide-timer slot. +function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) { + var dirs = roomHorizontalDirs(id); + var empty = dirs.length === 0; + + var item = document.createElement('div'); + item.className = 'cm-item-has-sub' + (empty ? ' disabled' : ''); + + var btn = document.createElement('button'); + btn.textContent = label; + var arrow = document.createElement('span'); + arrow.className = 'cm-arrow'; + arrow.textContent = '\u25B6'; + item.appendChild(btn); + item.appendChild(arrow); + + if (empty) { + // Still surface the "no exits" reason on click so it's discoverable. + btn.onclick = function() { + notify(mode === 'insert' + ? 'No horizontal exits to insert into from #' + id + : 'No horizontal exits to remove from #' + id, 'error'); + }; + return item; + } + + var sub = document.createElement('div'); + sub.className = 'cm-submenu'; + var title = document.createElement('div'); + title.className = 'cm-title'; + title.textContent = mode === 'insert' ? 'Insert Room' : 'Remove Room'; + sub.appendChild(title); + dirs.forEach(function(d) { + var opt = document.createElement('button'); + opt.textContent = dirLabel(d); + (function(dir) { + opt.onclick = function(e) { + e.stopPropagation(); + closeAll(); + if (mode === 'insert') insertRoom(id, dir); + else removeRoom(id, dir); + }; + })(d); + sub.appendChild(opt); + }); + item.appendChild(sub); + + var open = false; + function showSub() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + // Only one submenu open at a time. + var sibs = menu.querySelectorAll('.cm-submenu.open'); + sibs.forEach(function(s) { + if (s !== sub) { s.classList.remove('open'); s.classList.remove('flip-left'); s.classList.remove('flip-up'); } + }); + sub.classList.add('open'); + flipSubmenu(sub); + open = true; + } + function hideSubSoon() { + if (overlay._subHideTimer) clearTimeout(overlay._subHideTimer); + overlay._subHideTimer = setTimeout(function() { + sub.classList.remove('open'); + sub.classList.remove('flip-left'); + sub.classList.remove('flip-up'); + open = false; + overlay._subHideTimer = null; + }, 250); + } + + item.addEventListener('mouseenter', showSub); + item.addEventListener('mouseleave', hideSubSoon); + // Keep the submenu alive while the cursor is inside it (crosses the gap). + sub.addEventListener('mouseenter', function() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + }); + sub.addEventListener('mouseleave', hideSubSoon); + + btn.onclick = function(e) { + e.stopPropagation(); + if (open) { + sub.classList.remove('open'); + sub.classList.remove('flip-left'); + sub.classList.remove('flip-up'); + open = false; + } else { + showSub(); + } + }; + + return item; +} + +// roomHorizontalDirs returns the room's outward horizontal exits on the current +// z-level, in gridDirs order, derived from mapData.links. Empty when the room +// has no horizontal exits to insert into / remove from. +function roomHorizontalDirs(id) { + if (!mapData || !mapData.links) return []; + var horizontal = {}; + gridDirs.forEach(function(d) { horizontal[d.dir] = true; }); + var seen = {}; + var dirs = []; + mapData.links.forEach(function(l) { + if (l.from === id && horizontal[l.dir] && !seen[l.dir]) { + seen[l.dir] = true; + dirs.push(l.dir); + } else if (l.to === id && l.bidirectional && horizontal[l.dir]) { + var opp = oppositeDir[l.dir]; + if (opp && !seen[opp]) { + seen[opp] = true; + dirs.push(opp); + } + } + }); + var ordered = []; + gridDirs.forEach(function(d) { + if (seen[d.dir]) ordered.push(d.dir); + }); + return ordered; +} + +// flipSubmenu toggles flip classes so the submenu stays on-screen when the +// parent is near the right or bottom edge of the viewport. +function flipSubmenu(sub) { + sub.classList.remove('flip-left'); + sub.classList.remove('flip-up'); + var rect = sub.getBoundingClientRect(); + if (rect.right > window.innerWidth - 4) sub.classList.add('flip-left'); + if (rect.bottom > window.innerHeight - 4) sub.classList.add('flip-up'); +} + +// clampMenuToViewport nudges a top-level context menu inside the viewport. +function clampMenuToViewport(menu) { + var rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth - 4) { + menu.style.left = Math.max(4, window.innerWidth - rect.width - 4) + 'px'; + } + if (rect.bottom > window.innerHeight - 4) { + menu.style.top = Math.max(4, window.innerHeight - rect.height - 4) + 'px'; + } +} + +// dirLabel turns a direction key ("north", "northeast", ...) into a display +// label ("North", "Northeast", ...). Falls back to title-casing the input. +function dirLabel(d) { + var labels = { + north: 'North', south: 'South', east: 'East', west: 'West', + northeast: 'Northeast', northwest: 'Northwest', + southeast: 'Southeast', southwest: 'Southwest', + up: 'Up', down: 'Down' + }; + return labels[d] || d.charAt(0).toUpperCase() + d.slice(1); +} + +// insertRoom calls the admin insert endpoint to push a new room into id's dir +// exit, then refreshes the map and selects the new room. The backend fills in +// the default name ("New Room") when none is supplied. +function insertRoom(fromID, dir) { + API.post('/api/rooms/insert', { from: fromID, dir: dir, name: '' }).then(function(r) { + var nid = r && r.room && r.room.id; + notify('Inserted room #' + nid + ' to the ' + dirLabel(dir), 'success'); + updateUndoBar(); + loadMap().then(function() { if (nid) selectRoom(nid); }); + }).catch(function(e) { notify('Insert failed: ' + extractError(e), 'error'); }); +} + +// removeRoom calls the admin remove endpoint to delete id's dir exit's room +// and pull the far room back, then refreshes the map. +function removeRoom(fromID, dir) { + if (!confirm('Remove the room to the ' + dirLabel(dir) + ' of #' + fromID + + ' and pull the far end back?')) return; + API.post('/api/rooms/remove', { from: fromID, dir: dir }).then(function(r) { + var pulled = r && r.pulled_to; + notify('Removed room; far end pulled to #' + pulled, 'success'); + updateUndoBar(); + loadMap().then(function() { selectRoom(fromID); }); + }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); }); } function mouseToGrid(e) { @@ -2256,18 +2471,28 @@ function loadDisconnectedRooms(dir) { function showDcContextMenu(e, id) { var overlay = document.createElement('div'); overlay.className = 'cm-overlay'; - overlay.onclick = function() { overlay.remove(); menu.remove(); }; + var closeAll = function() { overlay.remove(); menu.remove(); }; + overlay.onclick = closeAll; + overlay.addEventListener('contextmenu', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var cx = ev.clientX, cy = ev.clientY; + closeAll(); + var el = document.elementFromPoint(cx, cy); + if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2})); + }); var menu = document.createElement('div'); menu.className = 'cm-menu'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; + menu.oncontextmenu = function(ev) { ev.preventDefault(); }; var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; delBtn.onclick = function() { - overlay.remove(); menu.remove(); + closeAll(); deleteRoom(id); }; menu.appendChild(delBtn); @@ -2275,7 +2500,7 @@ function showDcContextMenu(e, id) { var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; selBtn.onclick = function() { - overlay.remove(); menu.remove(); + closeAll(); selectRoom(id); }; menu.appendChild(selBtn); -- cgit v1.2.3