aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static/map.js
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/static/map.js')
-rw-r--r--internal/admin/static/map.js241
1 files changed, 233 insertions, 8 deletions
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 = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
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);