diff --git a/web/app.js b/web/app.js index ed7feb8..a47fd7b 100644 --- a/web/app.js +++ b/web/app.js @@ -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(); diff --git a/web/index.html b/web/index.html index 3e31dd8..54d601a 100644 --- a/web/index.html +++ b/web/index.html @@ -31,7 +31,8 @@