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:
+113
-29
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user