split note tools: (i) hover-only icon vs. always-visible freetext label

- Note tool (existing) now strictly hover-only: drops a yellow (i) pin, text appears in the instant tooltip on hover; no inline label
- new Text tool: drops the user-typed string as an always-visible map label, no icon — for naming pull groups, calling out hazards, etc.
- both tools support drag, right-click delete, double-click edit; share-URL hash carries them in 'n' (notes) and 't' (texts) keys
This commit is contained in:
2026-04-25 23:04:51 +02:00
parent 71bef7b8ff
commit 569dbb3b7c
2 changed files with 115 additions and 30 deletions
+113 -29
View File
@@ -9,10 +9,11 @@ const state = {
dungeons: [],
current: null,
floorIndex: 0,
tool: "route", // "route" | "pull" | "note"
tool: "route", // "route" | "pull" | "note" | "text"
routes: {}, // { key: [{x,y}] } image-pixel coords
pulls: {}, // { key: [{x,y}] }
notes: {}, // { key: [{x,y,text}] }
notes: {}, // { key: [{x,y,text}] } — `i` pin, hover-only
texts: {}, // { key: [{x,y,text}] } — always-visible label, no icon
history: [],
drag: null,
view: { scale: 1, tx: 0, ty: 0 },
@@ -209,6 +210,10 @@ function renderOverlay() {
const notes = state.notes[key] || [];
notes.forEach((n, i) => svg.appendChild(makeNotePin(n, i)));
// Always-visible freetext labels
const texts = state.texts[key] || [];
texts.forEach((it, i) => svg.appendChild(makeTextLabel(it, i)));
svg.onclick = onCanvasClick;
}
@@ -234,28 +239,7 @@ function makeNotePin(note, idx) {
i.textContent = "i";
g.appendChild(i);
// Always-visible label: text rendered next to the pin in image-pixel
// space, with a dark stroke for readability over the parchment.
if (note.text) {
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("class", "note-label");
label.setAttribute("x", 30); // sits to the right of the circle
label.setAttribute("y", 10);
label.setAttribute("font-size", "28");
label.setAttribute("font-family", "system-ui, sans-serif");
label.setAttribute("font-weight", "600");
label.setAttribute("fill", "#fff7d6");
label.setAttribute("stroke", "#1a1208");
label.setAttribute("stroke-width", "5");
label.setAttribute("paint-order", "stroke");
// Truncate very long text on the SVG side to ~80 chars so it doesn't
// overflow the map. The full text is still in the data tooltip.
const truncated = note.text.length > 80 ? note.text.slice(0, 77) + "..." : note.text;
label.textContent = truncated;
g.appendChild(label);
}
// Custom-tooltip data (instant, used by ensureTooltip).
// Custom-tooltip data — text only shows on hover for the (i) note.
g.dataset.tooltip = note.text || "(empty)";
// drag-to-move + right-click delete + double-click to edit text
@@ -307,6 +291,91 @@ function removeNote(idx) {
updateHash();
}
/* --- always-visible freetext labels -------------------------------------- */
function makeTextLabel(item, idx) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "freetext");
g.setAttribute("transform", `translate(${item.x},${item.y})`);
const text = item.text || "";
// Background plate so light/dark areas don't both kill readability.
// We measure after first paint via getBBox; provide an initial backdrop
// via stroke on the text itself (paint-order: stroke).
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("class", "freetext-label");
t.setAttribute("font-size", "32");
t.setAttribute("font-family", "system-ui, sans-serif");
t.setAttribute("font-weight", "700");
t.setAttribute("fill", "#fff7d6");
t.setAttribute("stroke", "#1a1208");
t.setAttribute("stroke-width", "6");
t.setAttribute("paint-order", "stroke");
t.setAttribute("alignment-baseline", "middle");
t.setAttribute("dominant-baseline", "middle");
t.setAttribute("y", 8);
t.textContent = text || "(empty)";
g.appendChild(t);
// Drag handle — invisible rect over the text bounding region. We can't
// size it without measurement, so use a roomy default (will be replaced
// by getBBox after insertion).
const hit = document.createElementNS(SVG_NS, "rect");
hit.setAttribute("class", "freetext-hit");
hit.setAttribute("x", -4); hit.setAttribute("y", -22);
hit.setAttribute("width", Math.max(40, text.length * 16));
hit.setAttribute("height", 44);
hit.setAttribute("fill", "transparent");
g.appendChild(hit);
// Drag / right-click delete / dbl-click edit
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind: "text", idx };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return;
const pt = svgPointFromEvent(e);
const arr = state.texts[currentKey()];
arr[idx].x = pt.x; arr[idx].y = pt.y;
g.setAttribute("transform", `translate(${pt.x},${pt.y})`);
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
updateHash();
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
removeText(idx);
});
g.addEventListener("dblclick", (e) => {
e.preventDefault();
e.stopPropagation();
const txt = prompt("Edit label", item.text || "");
if (txt !== null) {
state.texts[currentKey()][idx].text = txt;
pushHistory();
renderOverlay();
updateHash();
}
});
return g;
}
function removeText(idx) {
const key = currentKey();
const arr = state.texts[key];
if (!arr) return;
arr.splice(idx, 1);
pushHistory();
renderOverlay();
updateHash();
}
// kg classification IDs:
// 1 = minor (NPCs, ambient mobs)
// 2 = standard trash
@@ -447,10 +516,15 @@ function onCanvasClick(e) {
state.pulls[key] = state.pulls[key] || [];
state.pulls[key].push(pt);
} else if (state.tool === "note") {
const text = prompt("Note text:", "");
const text = prompt("Note text (shown on hover):", "");
if (text === null) return; // cancel
state.notes[key] = state.notes[key] || [];
state.notes[key].push({ x: pt.x, y: pt.y, text: text || "" });
} else if (state.tool === "text") {
const text = prompt("Label text (always visible):", "");
if (text === null) return; // cancel
state.texts[key] = state.texts[key] || [];
state.texts[key].push({ x: pt.x, y: pt.y, text: text || "" });
}
pushHistory();
renderOverlay();
@@ -674,7 +748,8 @@ function hookCanvasPanZoom() {
function pushHistory() {
state.history.push(JSON.stringify({
routes: state.routes, pulls: state.pulls, notes: state.notes,
routes: state.routes, pulls: state.pulls,
notes: state.notes, texts: state.texts,
}));
if (state.history.length > 200) state.history.shift();
}
@@ -687,10 +762,12 @@ function undo() {
state.routes = s.routes;
state.pulls = s.pulls;
state.notes = s.notes || {};
state.texts = s.texts || {};
} else {
state.routes = {};
state.pulls = {};
state.notes = {};
state.texts = {};
}
renderOverlay();
renderInfoPane();
@@ -702,17 +779,20 @@ function clearCurrent() {
const r = (state.routes[key] || []).length;
const p = (state.pulls[key] || []).length;
const n = (state.notes[key] || []).length;
const total = r + p + n;
const t = (state.texts[key] || []).length;
const total = r + p + n + t;
if (!total) return;
const summary = [
r ? `${r} waypoint${r === 1 ? "" : "s"}` : null,
p ? `${p} pull marker${p === 1 ? "" : "s"}` : null,
n ? `${n} note${n === 1 ? "" : "s"}` : null,
t ? `${t} label${t === 1 ? "" : "s"}` : null,
].filter(Boolean).join(", ");
if (!confirm(`Clear ${summary} on this floor? This can be undone with Undo.`)) return;
delete state.routes[key];
delete state.pulls[key];
delete state.notes[key];
delete state.texts[key];
pushHistory();
renderOverlay();
renderInfoPane();
@@ -825,7 +905,8 @@ async function updateHash() {
f: state.floorIndex,
r: flattenPins(state.routes),
p: flattenPins(state.pulls),
n: flattenNotes(state.notes),
n: flattenNotes(state.notes), // hover-only (i) notes
t: flattenNotes(state.texts), // always-visible labels
};
const encoded = await encodePayload(payload);
const hash = "#" + encoded;
@@ -847,6 +928,7 @@ async function loadFromHash() {
state.routes = inflatePins(p.r);
state.pulls = inflatePins(p.p);
state.notes = inflateNotes(p.n);
state.texts = inflateNotes(p.t);
renderViewer();
} catch (e) {
console.warn("hash parse failed", e);
@@ -894,7 +976,7 @@ function toast(msg) {
function setTool(tool) {
state.tool = tool;
for (const t of ["route", "pull", "note"]) {
for (const t of ["route", "pull", "note", "text"]) {
const btn = $(`tool-${t}`);
if (btn) btn.classList.toggle("active", tool === t);
}
@@ -911,6 +993,8 @@ function hookEvents() {
$("tool-pull").addEventListener("click", () => setTool("pull"));
const noteBtn = $("tool-note");
if (noteBtn) noteBtn.addEventListener("click", () => setTool("note"));
const textBtn = $("tool-text");
if (textBtn) textBtn.addEventListener("click", () => setTool("text"));
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
+2 -1
View File
@@ -31,7 +31,8 @@
<div class="toolbar">
<button id="tool-route" class="tool active" title="Click map to add waypoints">Route</button>
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
<button id="tool-note" class="tool" title="Click map to drop a note (info icon, hover to read; double-click to edit)">Note</button>
<button id="tool-note" class="tool" title="Drop an (i) info icon — text shows on hover">Note</button>
<button id="tool-text" class="tool" title="Drop a freetext label — text always visible on the map">Text</button>
<button id="undo" title="Undo (⌘Z)">Undo</button>
<button id="clear" title="Clear current floor">Clear</button>
<button id="share">Share</button>