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: [],
|
dungeons: [],
|
||||||
current: null,
|
current: null,
|
||||||
floorIndex: 0,
|
floorIndex: 0,
|
||||||
tool: "route", // "route" | "pull" | "note"
|
tool: "route", // "route" | "pull" | "note" | "text"
|
||||||
routes: {}, // { key: [{x,y}] } image-pixel coords
|
routes: {}, // { key: [{x,y}] } image-pixel coords
|
||||||
pulls: {}, // { key: [{x,y}] }
|
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: [],
|
history: [],
|
||||||
drag: null,
|
drag: null,
|
||||||
view: { scale: 1, tx: 0, ty: 0 },
|
view: { scale: 1, tx: 0, ty: 0 },
|
||||||
@@ -209,6 +210,10 @@ function renderOverlay() {
|
|||||||
const notes = state.notes[key] || [];
|
const notes = state.notes[key] || [];
|
||||||
notes.forEach((n, i) => svg.appendChild(makeNotePin(n, i)));
|
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;
|
svg.onclick = onCanvasClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,28 +239,7 @@ function makeNotePin(note, idx) {
|
|||||||
i.textContent = "i";
|
i.textContent = "i";
|
||||||
g.appendChild(i);
|
g.appendChild(i);
|
||||||
|
|
||||||
// Always-visible label: text rendered next to the pin in image-pixel
|
// Custom-tooltip data — text only shows on hover for the (i) note.
|
||||||
// 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).
|
|
||||||
g.dataset.tooltip = note.text || "(empty)";
|
g.dataset.tooltip = note.text || "(empty)";
|
||||||
|
|
||||||
// drag-to-move + right-click delete + double-click to edit text
|
// drag-to-move + right-click delete + double-click to edit text
|
||||||
@@ -307,6 +291,91 @@ function removeNote(idx) {
|
|||||||
updateHash();
|
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:
|
// kg classification IDs:
|
||||||
// 1 = minor (NPCs, ambient mobs)
|
// 1 = minor (NPCs, ambient mobs)
|
||||||
// 2 = standard trash
|
// 2 = standard trash
|
||||||
@@ -447,10 +516,15 @@ function onCanvasClick(e) {
|
|||||||
state.pulls[key] = state.pulls[key] || [];
|
state.pulls[key] = state.pulls[key] || [];
|
||||||
state.pulls[key].push(pt);
|
state.pulls[key].push(pt);
|
||||||
} else if (state.tool === "note") {
|
} else if (state.tool === "note") {
|
||||||
const text = prompt("Note text:", "");
|
const text = prompt("Note text (shown on hover):", "");
|
||||||
if (text === null) return; // cancel
|
if (text === null) return; // cancel
|
||||||
state.notes[key] = state.notes[key] || [];
|
state.notes[key] = state.notes[key] || [];
|
||||||
state.notes[key].push({ x: pt.x, y: pt.y, text: text || "" });
|
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();
|
pushHistory();
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
@@ -674,7 +748,8 @@ function hookCanvasPanZoom() {
|
|||||||
|
|
||||||
function pushHistory() {
|
function pushHistory() {
|
||||||
state.history.push(JSON.stringify({
|
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();
|
if (state.history.length > 200) state.history.shift();
|
||||||
}
|
}
|
||||||
@@ -687,10 +762,12 @@ function undo() {
|
|||||||
state.routes = s.routes;
|
state.routes = s.routes;
|
||||||
state.pulls = s.pulls;
|
state.pulls = s.pulls;
|
||||||
state.notes = s.notes || {};
|
state.notes = s.notes || {};
|
||||||
|
state.texts = s.texts || {};
|
||||||
} else {
|
} else {
|
||||||
state.routes = {};
|
state.routes = {};
|
||||||
state.pulls = {};
|
state.pulls = {};
|
||||||
state.notes = {};
|
state.notes = {};
|
||||||
|
state.texts = {};
|
||||||
}
|
}
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
renderInfoPane();
|
renderInfoPane();
|
||||||
@@ -702,17 +779,20 @@ function clearCurrent() {
|
|||||||
const r = (state.routes[key] || []).length;
|
const r = (state.routes[key] || []).length;
|
||||||
const p = (state.pulls[key] || []).length;
|
const p = (state.pulls[key] || []).length;
|
||||||
const n = (state.notes[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;
|
if (!total) return;
|
||||||
const summary = [
|
const summary = [
|
||||||
r ? `${r} waypoint${r === 1 ? "" : "s"}` : null,
|
r ? `${r} waypoint${r === 1 ? "" : "s"}` : null,
|
||||||
p ? `${p} pull marker${p === 1 ? "" : "s"}` : null,
|
p ? `${p} pull marker${p === 1 ? "" : "s"}` : null,
|
||||||
n ? `${n} note${n === 1 ? "" : "s"}` : null,
|
n ? `${n} note${n === 1 ? "" : "s"}` : null,
|
||||||
|
t ? `${t} label${t === 1 ? "" : "s"}` : null,
|
||||||
].filter(Boolean).join(", ");
|
].filter(Boolean).join(", ");
|
||||||
if (!confirm(`Clear ${summary} on this floor? This can be undone with Undo.`)) return;
|
if (!confirm(`Clear ${summary} on this floor? This can be undone with Undo.`)) return;
|
||||||
delete state.routes[key];
|
delete state.routes[key];
|
||||||
delete state.pulls[key];
|
delete state.pulls[key];
|
||||||
delete state.notes[key];
|
delete state.notes[key];
|
||||||
|
delete state.texts[key];
|
||||||
pushHistory();
|
pushHistory();
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
renderInfoPane();
|
renderInfoPane();
|
||||||
@@ -825,7 +905,8 @@ async function updateHash() {
|
|||||||
f: state.floorIndex,
|
f: state.floorIndex,
|
||||||
r: flattenPins(state.routes),
|
r: flattenPins(state.routes),
|
||||||
p: flattenPins(state.pulls),
|
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 encoded = await encodePayload(payload);
|
||||||
const hash = "#" + encoded;
|
const hash = "#" + encoded;
|
||||||
@@ -847,6 +928,7 @@ async function loadFromHash() {
|
|||||||
state.routes = inflatePins(p.r);
|
state.routes = inflatePins(p.r);
|
||||||
state.pulls = inflatePins(p.p);
|
state.pulls = inflatePins(p.p);
|
||||||
state.notes = inflateNotes(p.n);
|
state.notes = inflateNotes(p.n);
|
||||||
|
state.texts = inflateNotes(p.t);
|
||||||
renderViewer();
|
renderViewer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("hash parse failed", e);
|
console.warn("hash parse failed", e);
|
||||||
@@ -894,7 +976,7 @@ function toast(msg) {
|
|||||||
|
|
||||||
function setTool(tool) {
|
function setTool(tool) {
|
||||||
state.tool = tool;
|
state.tool = tool;
|
||||||
for (const t of ["route", "pull", "note"]) {
|
for (const t of ["route", "pull", "note", "text"]) {
|
||||||
const btn = $(`tool-${t}`);
|
const btn = $(`tool-${t}`);
|
||||||
if (btn) btn.classList.toggle("active", tool === t);
|
if (btn) btn.classList.toggle("active", tool === t);
|
||||||
}
|
}
|
||||||
@@ -911,6 +993,8 @@ function hookEvents() {
|
|||||||
$("tool-pull").addEventListener("click", () => setTool("pull"));
|
$("tool-pull").addEventListener("click", () => setTool("pull"));
|
||||||
const noteBtn = $("tool-note");
|
const noteBtn = $("tool-note");
|
||||||
if (noteBtn) noteBtn.addEventListener("click", () => setTool("note"));
|
if (noteBtn) noteBtn.addEventListener("click", () => setTool("note"));
|
||||||
|
const textBtn = $("tool-text");
|
||||||
|
if (textBtn) textBtn.addEventListener("click", () => setTool("text"));
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="tool-route" class="tool active" title="Click map to add waypoints">Route</button>
|
<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-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="undo" title="Undo (⌘Z)">Undo</button>
|
||||||
<button id="clear" title="Clear current floor">Clear</button>
|
<button id="clear" title="Clear current floor">Clear</button>
|
||||||
<button id="share">Share</button>
|
<button id="share">Share</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user