From cd52f9dbb8c35f37a9b52e8092bf5d722d56b044 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 6 Jul 2026 19:09:42 -0400 Subject: feat: shift+drag works when shift is pressed/released mid drag --- internal/admin/api_rooms.go | 11 +++++- internal/admin/static/admin.css | 2 ++ internal/admin/static/map.js | 80 ++++++++++++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 17 deletions(-) (limited to 'internal/admin') diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index b5353c2..74143a8 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -47,6 +47,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { var linkFrom *int var linkDir *string + var linkOneWay bool if v, ok := m["link_from"]; ok { if f, ok := v.(float64); ok { lf := int(f) @@ -61,6 +62,12 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { } delete(m, "link_dir") } + if v, ok := m["link_oneway"]; ok { + if b, ok := v.(bool); ok { + linkOneWay = b + } + delete(m, "link_oneway") + } fromID := 0 if linkFrom != nil { @@ -109,7 +116,9 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { }) } - exits[string(opp)] = *linkFrom + if !linkOneWay { + exits[string(opp)] = *linkFrom + } } } diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index c620b46..82c4815 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -53,6 +53,8 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f .room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap} .link-mode-badge{position:absolute;bottom:12px;right:12px;padding:6px 12px;border-radius:4px;font-size:12px;font-family:monospace;pointer-events:none;z-index:20;background:rgba(30,30,60,0.9);color:#ccc;border:1px solid var(--border)} .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} .notification{position:fixed;bottom:16px;right: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 f89a434..47dcb27 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -6,6 +6,7 @@ var dragRoom = false; var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null; var linkDragOneWay = false; var shiftHeld = false; +var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; var dirChangedByUser = false; @@ -21,12 +22,21 @@ var upTarget = {}, downTarget = {}; var _mapMouseCleanup = false; +function isShiftKey(e) { + return e && (e.key === 'Shift' || e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.keyCode === 16); +} + function onMapMouseMove(e) { if (!dragging) return; var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; if (delFromId && moved && !delDrag) { delDrag = true; } - if (delDrag && moved) { updateDelDragTarget(e); return; } + if (delDrag && moved) { + delOneWay = e.shiftKey; + updateDelDragTarget(e); + showDelDragBadge(); + return; + } if (!dragRoom && !delDrag && !delFromId) { panX += e.clientX - prevX; @@ -37,7 +47,8 @@ function onMapMouseMove(e) { if (linkFromId && moved) { linkDrag = true; - showDragBadge(linkDragOneWay ? 'One-way link' : 'Two-way link'); + linkDragOneWay = e.shiftKey; + showLinkDragBadge(); updateLinkDragTarget(e); } } @@ -54,24 +65,28 @@ function onMapMouseUp(e) { showRoomContextMenu(startX, startY, delFromId); clearDelHighlight(); dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); return; } if (delDrag && delFromId && delTargetId) { - deleteLink(delFromId, delTargetId); + deleteLink(delFromId, delTargetId, (delOneWay || e.shiftKey) ? 'from' : ''); clearDelHighlight(); - dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; + hideDragBadge(); + dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); return; } if (linkDrag && linkFromId) { - if (linkTargetId) { createLink(linkFromId, linkTargetId, linkDragOneWay); } - else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir); } + var oneway = linkDragOneWay || e.shiftKey; + if (linkTargetId) { createLink(linkFromId, linkTargetId, oneway); } + else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir, oneway); } } else if (!moved && !delDrag) { if (e.target.classList.contains('rm')) { selectRoom(parseInt(e.target.getAttribute('data-id'))); } else if (e.target.classList.contains('ghost')) { - createRoom(selectedRoom, e.target.getAttribute('data-dir')); + createRoom(selectedRoom, e.target.getAttribute('data-dir'), e.shiftKey); } } @@ -80,7 +95,8 @@ function onMapMouseUp(e) { hideDragBadge(); dragging = false; dragRoom = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false; - delDrag = false; delFromId = null; delTargetId = null; + delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } function cancelMapDrag() { @@ -93,14 +109,15 @@ function cancelMapDrag() { hideDragBadge(); dragging = false; dragRoom = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false; - delDrag = false; delFromId = null; delTargetId = null; + delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } -function showDragBadge(text) { +function showDragBadge(text, oneway) { var badge = document.getElementById('linkModeBadge'); if (!badge) return; badge.textContent = text; - badge.className = 'link-mode-badge' + (linkDragOneWay ? ' oneway' : ''); + badge.className = 'link-mode-badge' + (oneway ? ' oneway' : ''); badge.style.display = ''; } @@ -109,6 +126,24 @@ function hideDragBadge() { if (badge) badge.style.display = 'none'; } +function showLinkDragBadge() { + var label = linkDragOneWay ? 'One way link' : 'Two-way link'; + var toLabel = linkTargetId ? linkTargetId : (linkGhostDir ? 'new' : null); + if (linkFromId && toLabel) label += ' (#' + linkFromId + ' to #' + toLabel + ')'; + showDragBadge(label, linkDragOneWay); +} + +function showDelDragBadge() { + var label = delOneWay ? 'One way remove' : 'Two-way remove'; + if (delFromId && delTargetId) label += ' (#' + delFromId + ' to #' + delTargetId + ')'; + showDragBadge(label, delOneWay); +} + +function refreshDragMode(shiftDown) { + if (linkDrag && linkFromId) { linkDragOneWay = shiftDown; showLinkDragBadge(); } + if (delDrag && delFromId) { delOneWay = shiftDown; showDelDragBadge(); } +} + function changeZ(dz) { if (dz === 0) currentZ = 0; else currentZ += dz; panX = 0; panY = 0; scale = 1; @@ -279,6 +314,17 @@ function renderMap(data) { svg.oncontextmenu = function(e) { e.preventDefault(); }; + if (!svg._mapKeysWired) { + svg.setAttribute('tabindex', '0'); + svg.addEventListener('keydown', function(e) { + if (isShiftKey(e)) { shiftHeld = true; if (dragging) refreshDragMode(true); } + }); + svg.addEventListener('keyup', function(e) { + if (isShiftKey(e)) { shiftHeld = false; if (dragging) refreshDragMode(false); } + }); + svg._mapKeysWired = true; + } + svg.onmousedown = function(e) { var onRoom = e.target.classList.contains('rm'); var onGhost = e.target.classList.contains('ghost'); @@ -296,8 +342,9 @@ function renderMap(data) { if (e.button === 2 && onRoom) { delFromId = parseInt(e.target.getAttribute('data-id')); - delDrag = false; delTargetId = null; + delDrag = false; delTargetId = null; delOneWay = false; dragging = true; + if (svg.focus) svg.focus(); startX = e.clientX; startY = e.clientY; prevX = e.clientX; prevY = e.clientY; dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; @@ -317,9 +364,9 @@ function renderMap(data) { return; } - e.preventDefault(); dragRoom = onRoom || onGhost; dragging = true; + if (svg.focus) svg.focus(); startX = e.clientX; startY = e.clientY; prevX = e.clientX; prevY = e.clientY; linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null; @@ -1863,11 +1910,12 @@ function deleteRoom(id) { }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); } -function createRoom(fromID, dir) { +function createRoom(fromID, dir, oneway) { API.get('/api/next-room-id?from=' + fromID).then(function(r) { var newID = r.id; var name = 'Room #' + newID; var body = { name: name, link_from: fromID, link_dir: dir }; + if (oneway) body.link_oneway = true; var srcRoom = roomMap[fromID]; if (srcRoom && srcRoom.color) body.color = srcRoom.color; API.post('/api/rooms', body).then(function(resp) { @@ -2395,6 +2443,6 @@ function reloadRoomDirs(cb) { } document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); - document.addEventListener('keydown', function(e) { if (e.key === 'Shift') shiftHeld = true; }); - document.addEventListener('keyup', function(e) { if (e.key === 'Shift') shiftHeld = false; }); + window.addEventListener('keydown', function(e) { if (isShiftKey(e)) { shiftHeld = true; if (dragging) refreshDragMode(true); } }); + window.addEventListener('keyup', function(e) { if (isShiftKey(e)) { shiftHeld = false; if (dragging) refreshDragMode(false); } }); }); -- cgit v1.2.3