143d2073cStracker-user/** 243d2073cStracker-user * Annotations plugin — front-end script. 343d2073cStracker-user * 443d2073cStracker-user * Responsibilities: 543d2073cStracker-user * 643d2073cStracker-user * 1. BOOT: read JSINFO.annotations (injected by action.php); if the user 743d2073cStracker-user * has disabled annotations, exit early. 843d2073cStracker-user * 943d2073cStracker-user * 2. LOAD: fetch the page's annotation list via the AJAX endpoint, then: 1043d2073cStracker-user * a. Anchor each annotation in the DOM (re-anchoring). 1143d2073cStracker-user * b. Wrap matched text in highlight <span>s. 1243d2073cStracker-user * c. Render per-line gutter markers. 1343d2073cStracker-user * d. Update the page counter bubble. 1443d2073cStracker-user * 1543d2073cStracker-user * 3. SELECTION: detect when the user finishes a text selection inside the 1643d2073cStracker-user * wiki content area, show an "Annotate" tooltip, capture the anchor on 1743d2073cStracker-user * click, and open a new-annotation form. 1843d2073cStracker-user * 1943d2073cStracker-user * 4. PANELS: clicking a highlight opens the annotation thread inline, just 2043d2073cStracker-user * below the paragraph that contains the highlight. One open panel at a 2143d2073cStracker-user * time. The panel renders the full thread: author, timestamp, body, 2243d2073cStracker-user * replies; and permission-gated action buttons. 2343d2073cStracker-user * 2443d2073cStracker-user * 5. AJAX: all state-changing operations POST JSON to 2543d2073cStracker-user * /lib/exe/ajax.php?call=annotations (with the DokuWiki security token). 2643d2073cStracker-user * Responses update the in-memory state and re-render affected highlights 2743d2073cStracker-user * / gutter markers / counter without a page reload. 2843d2073cStracker-user * 2943d2073cStracker-user * 6. ORPHANS: annotations that cannot be re-anchored are counted and 3043d2073cStracker-user * reachable via the orphaned counter link; their panels open in a 3143d2073cStracker-user * dedicated orphan drawer at the bottom of the content area. 3243d2073cStracker-user * 3343d2073cStracker-user * FF78 ESR compatibility: 3443d2073cStracker-user * - No #private fields, ??=, ||=, &&=, Array.at, structuredClone, 3543d2073cStracker-user * Object.hasOwn, native <dialog>. 3643d2073cStracker-user * - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK. 3743d2073cStracker-user */ 3843d2073cStracker-user 3943d2073cStracker-user(function () { 4043d2073cStracker-user 'use strict'; 4143d2073cStracker-user 4243d2073cStracker-user // ----------------------------------------------------------------------- 4343d2073cStracker-user // Constants 4443d2073cStracker-user // ----------------------------------------------------------------------- 4543d2073cStracker-user 4643d2073cStracker-user var AJAX_URL = DOKU_BASE + 'lib/exe/ajax.php?call=annotations'; 4743d2073cStracker-user var CONTENT_ID = 'dokuwiki__content'; 48b8076f00Stracker-user // .page is the article area inside #dokuwiki__content. Gutter markers 49b8076f00Stracker-user // are appended here so position:relative doesn't break the sidebar nav. 50b8076f00Stracker-user var PAGE_CLS = 'page'; 5143d2073cStracker-user 5243d2073cStracker-user // Colour tokens (also defined in style.css; kept here so JS can read them) 5343d2073cStracker-user var CLS_HIGHLIGHT_OPEN = 'ann-highlight-open'; 5443d2073cStracker-user var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved'; 5543d2073cStracker-user var CLS_GUTTER_MARKER = 'ann-gutter-marker'; 5643d2073cStracker-user var CLS_PANEL = 'ann-panel'; 5743d2073cStracker-user var CLS_COUNTER = 'ann-counter'; 5843d2073cStracker-user var CLS_TOOLTIP = 'ann-tooltip'; 5943d2073cStracker-user var CLS_ORPHAN_DRAWER = 'ann-orphan-drawer'; 6043d2073cStracker-user 6143d2073cStracker-user // ----------------------------------------------------------------------- 6243d2073cStracker-user // State 6343d2073cStracker-user // ----------------------------------------------------------------------- 6443d2073cStracker-user 6543d2073cStracker-user /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */ 6643d2073cStracker-user var _annotations = new Map(); 6743d2073cStracker-user 6843d2073cStracker-user /** Currently open panel element, or null. @type {HTMLElement|null} */ 6943d2073cStracker-user var _openPanel = null; 7043d2073cStracker-user 7150325813Stracker-user /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */ 7250325813Stracker-user var _pendingAnchor = null; 7350325813Stracker-user 7443d2073cStracker-user /** ID of the annotation whose panel is open, or null. @type {string|null} */ 7543d2073cStracker-user var _openAnnId = null; 7643d2073cStracker-user 7743d2073cStracker-user /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */ 7843d2073cStracker-user var _info = {}; 7943d2073cStracker-user 8043d2073cStracker-user /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */ 8143d2073cStracker-user var _lang = {}; 8243d2073cStracker-user 8343d2073cStracker-user /** The DokuWiki security token. @type {string} */ 8443d2073cStracker-user var _token = ''; 8543d2073cStracker-user 8643d2073cStracker-user /** Whether the current user is logged in. @type {bool} */ 8743d2073cStracker-user var _loggedIn = false; 8843d2073cStracker-user 8943d2073cStracker-user /** Whether the current user is an admin. @type {bool} */ 9043d2073cStracker-user var _isAdmin = false; 9143d2073cStracker-user 9243d2073cStracker-user // ----------------------------------------------------------------------- 9343d2073cStracker-user // Boot 9443d2073cStracker-user // ----------------------------------------------------------------------- 9543d2073cStracker-user 9643d2073cStracker-user /** 9743d2073cStracker-user * Entry point: wired to DOMContentLoaded. 9843d2073cStracker-user */ 9943d2073cStracker-user function boot() { 10043d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 10143d2073cStracker-user var annInfo = jsinfo.annotations || {}; 10243d2073cStracker-user 10343d2073cStracker-user if (!annInfo.enabled) { 10443d2073cStracker-user return; // user disabled annotations 10543d2073cStracker-user } 10643d2073cStracker-user 10743d2073cStracker-user _info = annInfo; 108da56206cStracker-user // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as 109da56206cStracker-user // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 110da56206cStracker-user _lang = uiLang(); 1117d2714c7Stracker-user // Token is injected into JSINFO.annotations by action.php (handleMetaHeader). 1127d2714c7Stracker-user // getSecurityToken() on the server produces it from session_id + REMOTE_USER. 1137d2714c7Stracker-user _token = annInfo.token || ''; 11443d2073cStracker-user 1157d2714c7Stracker-user // DokuWiki's JSINFO doesn't include user identity; we inject 1167d2714c7Stracker-user // user + isAdmin into JSINFO.annotations from PHP (action.php). 1177d2714c7Stracker-user _loggedIn = !!(annInfo.user && annInfo.user !== ''); 1187d2714c7Stracker-user _isAdmin = !!(annInfo.isAdmin); 11943d2073cStracker-user 12043d2073cStracker-user var content = document.getElementById(CONTENT_ID); 12143d2073cStracker-user if (!content) { 12243d2073cStracker-user return; // not a page view 12343d2073cStracker-user } 12443d2073cStracker-user 12543d2073cStracker-user renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0); 12643d2073cStracker-user loadAnnotations(); 12743d2073cStracker-user initSelectionCapture(content); 128*563f3b4cStracker-user 129*563f3b4cStracker-user // Close the open panel when the user presses Escape. 130*563f3b4cStracker-user document.addEventListener('keydown', function (e) { 131*563f3b4cStracker-user if ((e.key === 'Escape' || e.key === 'Esc') && _openPanel) { 132*563f3b4cStracker-user closePanel(); 133*563f3b4cStracker-user } 134*563f3b4cStracker-user }); 135*563f3b4cStracker-user 136*563f3b4cStracker-user // Keep gutter markers aligned with their highlights when the viewport 137*563f3b4cStracker-user // width changes: both the .page column and the highlights reflow. 138*563f3b4cStracker-user window.addEventListener('resize', repositionMarkers); 13943d2073cStracker-user } 14043d2073cStracker-user 14143d2073cStracker-user // ----------------------------------------------------------------------- 14243d2073cStracker-user // AJAX helpers 14343d2073cStracker-user // ----------------------------------------------------------------------- 14443d2073cStracker-user 14543d2073cStracker-user /** 14643d2073cStracker-user * POST a JSON payload to the AJAX endpoint. 14743d2073cStracker-user * 14843d2073cStracker-user * @param {object} payload 14943d2073cStracker-user * @returns {Promise<object>} response data 15043d2073cStracker-user */ 15143d2073cStracker-user function ajax(payload) { 15243d2073cStracker-user payload.sectok = _token; // DokuWiki security token field name for AJAX 15343d2073cStracker-user return fetch(AJAX_URL, { 15443d2073cStracker-user method: 'POST', 15543d2073cStracker-user headers: {'Content-Type': 'application/json'}, 15643d2073cStracker-user body: JSON.stringify(payload), 15743d2073cStracker-user }).then(function (res) { 15843d2073cStracker-user return res.json(); 15943d2073cStracker-user }); 16043d2073cStracker-user } 16143d2073cStracker-user 16243d2073cStracker-user // ----------------------------------------------------------------------- 16343d2073cStracker-user // Load and anchor annotations 16443d2073cStracker-user // ----------------------------------------------------------------------- 16543d2073cStracker-user 16643d2073cStracker-user /** 16743d2073cStracker-user * Fetch all annotations for the current page and render them. 16843d2073cStracker-user */ 16943d2073cStracker-user function loadAnnotations() { 17043d2073cStracker-user // We use a lightweight GET-style call: the action.php AJAX handler 17143d2073cStracker-user // is POST-only, so we pass action=load in the payload. 17243d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 17343d2073cStracker-user method: 'GET', 17443d2073cStracker-user }).then(function (res) { 17543d2073cStracker-user return res.json(); 17643d2073cStracker-user }).then(function (data) { 17743d2073cStracker-user if (!data || !Array.isArray(data.annotations)) { 17843d2073cStracker-user return; 17943d2073cStracker-user } 18043d2073cStracker-user data.annotations.forEach(function (ann) { 18143d2073cStracker-user _annotations.set(ann.id, ann); 18243d2073cStracker-user }); 18343d2073cStracker-user renderAll(); 18443d2073cStracker-user }).catch(function () { 18543d2073cStracker-user // Graceful degradation: page still works without annotations. 18643d2073cStracker-user }); 18743d2073cStracker-user } 18843d2073cStracker-user 18943d2073cStracker-user /** 19043d2073cStracker-user * Re-render everything: highlights, gutter markers, counter. 19143d2073cStracker-user */ 19243d2073cStracker-user function renderAll() { 19343d2073cStracker-user clearHighlights(); 19443d2073cStracker-user clearGutterMarkers(); 19543d2073cStracker-user 19643d2073cStracker-user var content = document.getElementById(CONTENT_ID); 19743d2073cStracker-user if (!content) return; 19843d2073cStracker-user 199da56206cStracker-user // Snapshot the page text ONCE, before any highlight is inserted. 200da56206cStracker-user // Re-collecting per annotation would exclude already-wrapped text 201da56206cStracker-user // (collectTextChunks skips our own UI), shifting every later anchor. 202da56206cStracker-user var chunks = collectTextChunks(content); 203da56206cStracker-user var rawFull = chunks.map(function (c) { return c.text; }).join(''); 204da56206cStracker-user var nm = normalizeWithMap(rawFull); 20543d2073cStracker-user 206da56206cStracker-user // Phase 1 — locate every annotation against the clean snapshot. 207da56206cStracker-user var hits = []; 20843d2073cStracker-user _annotations.forEach(function (ann) { 209da56206cStracker-user ann._range = null; 210da56206cStracker-user ann._highlightEl = null; 211da56206cStracker-user var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null; 212da56206cStracker-user if (hit) { 213da56206cStracker-user hits.push({ann: ann, pos: hit.pos, len: hit.len}); 214da56206cStracker-user ann._orphaned = false; 21543d2073cStracker-user } else { 216da56206cStracker-user ann._orphaned = true; 217da56206cStracker-user } 218da56206cStracker-user }); 219da56206cStracker-user 220da56206cStracker-user // Phase 2 — wrap later matches first, so wrapping (which splits text 221da56206cStracker-user // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones. 222da56206cStracker-user hits.sort(function (a, b) { return b.pos - a.pos; }); 223da56206cStracker-user hits.forEach(function (h) { 224da56206cStracker-user var range = buildRange(chunks, nm.map, h.pos, h.len); 225da56206cStracker-user if (range) { 226da56206cStracker-user h.ann._range = range; // cache for panel positioning 227da56206cStracker-user wrapHighlight(range, h.ann); 228da56206cStracker-user } else { 229da56206cStracker-user h.ann._orphaned = true; 23043d2073cStracker-user } 23143d2073cStracker-user }); 23243d2073cStracker-user 23343d2073cStracker-user renderGutterMarkers(); 234da56206cStracker-user updateCounter(); // recounts orphans from the _orphaned flags set above 23543d2073cStracker-user } 23643d2073cStracker-user 23743d2073cStracker-user // ----------------------------------------------------------------------- 23843d2073cStracker-user // Text anchoring (re-anchoring) 23943d2073cStracker-user // ----------------------------------------------------------------------- 24043d2073cStracker-user 24143d2073cStracker-user /** 242da56206cStracker-user * Locate an anchor's quoted text within the normalised page text. 24343d2073cStracker-user * 24443d2073cStracker-user * Algorithm: 245da56206cStracker-user * 1. Search for the exact quote (normalised). 246da56206cStracker-user * 2. If found multiple times, use prefix/suffix to disambiguate. 247da56206cStracker-user * 3. If still ambiguous, use the start offset hint. 24843d2073cStracker-user * 249da56206cStracker-user * Returns offsets into the normalised string; buildRange maps them back 250da56206cStracker-user * to a DOM Range via the normalised→raw index map. 251da56206cStracker-user * 252da56206cStracker-user * @param {string} norm normalised page text (from normalizeWithMap) 25343d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 254da56206cStracker-user * @returns {{pos:number, len:number}|null} 25543d2073cStracker-user */ 256da56206cStracker-user function locate(norm, anchor) { 25743d2073cStracker-user if (!anchor || !anchor.exact) return null; 25843d2073cStracker-user 25943d2073cStracker-user var exact = normalizeWS(anchor.exact); 260da56206cStracker-user if (exact === '') return null; 26143d2073cStracker-user var prefix = normalizeWS(anchor.prefix || ''); 26243d2073cStracker-user var suffix = normalizeWS(anchor.suffix || ''); 26343d2073cStracker-user var hint = anchor.start || 0; 26443d2073cStracker-user 26543d2073cStracker-user // Find all occurrences of exact. 26643d2073cStracker-user var positions = []; 267da56206cStracker-user var from = 0; 26843d2073cStracker-user var idx; 269da56206cStracker-user while ((idx = norm.indexOf(exact, from)) !== -1) { 270da56206cStracker-user positions.push(idx); 271da56206cStracker-user from = idx + exact.length; 27243d2073cStracker-user } 27343d2073cStracker-user 27443d2073cStracker-user if (positions.length === 0) return null; 27543d2073cStracker-user 276da56206cStracker-user var chosen = positions[0]; 27743d2073cStracker-user 27843d2073cStracker-user if (positions.length > 1) { 279da56206cStracker-user // Disambiguate using prefix + suffix context, tie-break on the hint. 28043d2073cStracker-user var bestScore = -1; 28143d2073cStracker-user positions.forEach(function (pos) { 282da56206cStracker-user var pre = norm.slice(Math.max(0, pos - prefix.length), pos); 283da56206cStracker-user var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length); 28443d2073cStracker-user var score = 0; 28543d2073cStracker-user if (prefix && pre.indexOf(prefix) !== -1) score++; 28643d2073cStracker-user if (suffix && suf.indexOf(suffix) !== -1) score++; 28743d2073cStracker-user var distToHint = Math.abs(pos - hint); 288da56206cStracker-user if (score > bestScore || 289da56206cStracker-user (score === bestScore && distToHint < Math.abs(chosen - hint))) { 29043d2073cStracker-user bestScore = score; 291da56206cStracker-user chosen = pos; 29243d2073cStracker-user } 29343d2073cStracker-user }); 29443d2073cStracker-user } 29543d2073cStracker-user 296da56206cStracker-user return {pos: chosen, len: exact.length}; 29743d2073cStracker-user } 29843d2073cStracker-user 29943d2073cStracker-user /** 30043d2073cStracker-user * Walk the text nodes under root and return an array of 30143d2073cStracker-user * {node, start, text} objects where start is the cumulative character 30243d2073cStracker-user * offset of this node's text in the joined string. 30343d2073cStracker-user * 30443d2073cStracker-user * The joined string is NOT normalised here — we normalise the full string 30543d2073cStracker-user * once above instead. 30643d2073cStracker-user * 30743d2073cStracker-user * @param {HTMLElement} root 30843d2073cStracker-user * @returns {Array<{node:Text, start:number, text:string}>} 30943d2073cStracker-user */ 31043d2073cStracker-user function collectTextChunks(root) { 31143d2073cStracker-user var walker = document.createTreeWalker( 31243d2073cStracker-user root, 31343d2073cStracker-user NodeFilter.SHOW_TEXT, 31443d2073cStracker-user null, 31543d2073cStracker-user false 31643d2073cStracker-user ); 31743d2073cStracker-user var chunks = []; 31843d2073cStracker-user var offset = 0; 31943d2073cStracker-user var node; 32043d2073cStracker-user while ((node = walker.nextNode())) { 32143d2073cStracker-user // Skip nodes inside our own UI elements. 32243d2073cStracker-user if (isAnnotationUI(node.parentNode)) continue; 32343d2073cStracker-user var text = node.nodeValue || ''; 32443d2073cStracker-user chunks.push({node: node, start: offset, text: text}); 32543d2073cStracker-user offset += text.length; 32643d2073cStracker-user } 32743d2073cStracker-user return chunks; 32843d2073cStracker-user } 32943d2073cStracker-user 33043d2073cStracker-user /** 331*563f3b4cStracker-user * True if the given node is inside an existing highlight span. 332*563f3b4cStracker-user * Used to block opening a new-annotation tooltip on already-annotated text. 333*563f3b4cStracker-user * 334*563f3b4cStracker-user * @param {Node} node 335*563f3b4cStracker-user * @returns {bool} 336*563f3b4cStracker-user */ 337*563f3b4cStracker-user function isInsideHighlight(node) { 338*563f3b4cStracker-user var el = (node && node.nodeType === 1) ? node : (node ? node.parentNode : null); 339*563f3b4cStracker-user while (el && el !== document.body) { 340*563f3b4cStracker-user if (el.className && 341*563f3b4cStracker-user (el.className.indexOf(CLS_HIGHLIGHT_OPEN) !== -1 || 342*563f3b4cStracker-user el.className.indexOf(CLS_HIGHLIGHT_RESOLVED) !== -1)) { 343*563f3b4cStracker-user return true; 344*563f3b4cStracker-user } 345*563f3b4cStracker-user el = el.parentNode; 346*563f3b4cStracker-user } 347*563f3b4cStracker-user return false; 348*563f3b4cStracker-user } 349*563f3b4cStracker-user 350*563f3b4cStracker-user /** 35143d2073cStracker-user * True if the element (or its ancestor) is part of our annotation UI. 35243d2073cStracker-user * 35343d2073cStracker-user * @param {Node} el 35443d2073cStracker-user * @returns {bool} 35543d2073cStracker-user */ 35643d2073cStracker-user function isAnnotationUI(el) { 35743d2073cStracker-user while (el && el !== document.body) { 35843d2073cStracker-user if (el.nodeType === 1) { 35943d2073cStracker-user var cls = el.className || ''; 36043d2073cStracker-user if ( 36143d2073cStracker-user cls.indexOf('ann-') !== -1 || 36243d2073cStracker-user cls.indexOf(CLS_PANEL) !== -1 36343d2073cStracker-user ) { 36443d2073cStracker-user return true; 36543d2073cStracker-user } 36643d2073cStracker-user } 36743d2073cStracker-user el = el.parentNode; 36843d2073cStracker-user } 36943d2073cStracker-user return false; 37043d2073cStracker-user } 37143d2073cStracker-user 37243d2073cStracker-user /** 373da56206cStracker-user * Turn a (start, length) offset in the normalised page text back into a 374da56206cStracker-user * DOM Range, using the normalised→raw index map. 37543d2073cStracker-user * 37643d2073cStracker-user * @param {Array<{node:Text, start:number, text:string}>} chunks 377da56206cStracker-user * @param {Array<number>} map normalised index → raw index (normalizeWithMap) 378da56206cStracker-user * @param {number} startOff start offset in the normalised text 379da56206cStracker-user * @param {number} length length in normalised characters 38043d2073cStracker-user * @returns {Range|null} 38143d2073cStracker-user */ 382da56206cStracker-user function buildRange(chunks, map, startOff, length) { 383da56206cStracker-user var rawStart = map[startOff]; 384da56206cStracker-user var rawEnd = map[startOff + length - 1]; 38543d2073cStracker-user if (rawStart === undefined || rawEnd === undefined) return null; 38643d2073cStracker-user rawEnd++; // exclusive 38743d2073cStracker-user 38843d2073cStracker-user // Find which chunks contain rawStart and rawEnd. 38943d2073cStracker-user var startChunk = null, startOffset = 0; 39043d2073cStracker-user var endChunk = null, endOffset = 0; 39143d2073cStracker-user 39243d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 39343d2073cStracker-user var c = chunks[i]; 39443d2073cStracker-user var cEnd = c.start + c.text.length; 39543d2073cStracker-user 39643d2073cStracker-user if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 39743d2073cStracker-user startChunk = c.node; 39843d2073cStracker-user startOffset = rawStart - c.start; 39943d2073cStracker-user } 40043d2073cStracker-user if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 40143d2073cStracker-user endChunk = c.node; 40243d2073cStracker-user endOffset = rawEnd - c.start; 40343d2073cStracker-user } 40443d2073cStracker-user if (startChunk && endChunk) break; 40543d2073cStracker-user } 40643d2073cStracker-user 40743d2073cStracker-user if (!startChunk || !endChunk) return null; 40843d2073cStracker-user 40943d2073cStracker-user try { 41043d2073cStracker-user var range = document.createRange(); 41143d2073cStracker-user range.setStart(startChunk, startOffset); 41243d2073cStracker-user range.setEnd(endChunk, endOffset); 41343d2073cStracker-user return range; 41443d2073cStracker-user } catch (e) { 41543d2073cStracker-user return null; 41643d2073cStracker-user } 41743d2073cStracker-user } 41843d2073cStracker-user 41943d2073cStracker-user /** 420da56206cStracker-user * Normalise raw text exactly as normalizeWS does (collapse each whitespace 421da56206cStracker-user * run to a single space, trim both ends) while recording, for every 422da56206cStracker-user * character of the normalised string, the index of the raw character it 423da56206cStracker-user * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i) 424da56206cStracker-user * (a collapsed internal space maps to the first char of its run). 425da56206cStracker-user * 426da56206cStracker-user * Normalisation and the index map MUST stay in lockstep: an earlier 427da56206cStracker-user * version built the map without trimming, so a leading whitespace text 428da56206cStracker-user * node (DokuWiki indents its content markup, so there always is one) 429da56206cStracker-user * shifted every highlight one character to the left. 43043d2073cStracker-user * 43143d2073cStracker-user * @param {string} raw 432da56206cStracker-user * @returns {{norm:string, map:Array<number>}} 43343d2073cStracker-user */ 434da56206cStracker-user function normalizeWithMap(raw) { 435da56206cStracker-user var norm = ''; 43643d2073cStracker-user var map = []; 437da56206cStracker-user var inRun = false; 438da56206cStracker-user var runStart = 0; 43943d2073cStracker-user for (var i = 0; i < raw.length; i++) { 440da56206cStracker-user if (/\s/.test(raw[i])) { 441da56206cStracker-user if (!inRun) { inRun = true; runStart = i; } 442da56206cStracker-user continue; 44343d2073cStracker-user } 444da56206cStracker-user if (inRun) { 445da56206cStracker-user inRun = false; 446da56206cStracker-user // internal run → one representative space; leading run → dropped 447da56206cStracker-user if (norm.length > 0) { 448da56206cStracker-user norm += ' '; 449da56206cStracker-user map.push(runStart); 450da56206cStracker-user } 451da56206cStracker-user } 452da56206cStracker-user norm += raw[i]; 45343d2073cStracker-user map.push(i); 45443d2073cStracker-user } 455da56206cStracker-user // a trailing whitespace run is dropped (matches trim) 456da56206cStracker-user return {norm: norm, map: map}; 45743d2073cStracker-user } 45843d2073cStracker-user 45943d2073cStracker-user // ----------------------------------------------------------------------- 46043d2073cStracker-user // Highlights 46143d2073cStracker-user // ----------------------------------------------------------------------- 46243d2073cStracker-user 46343d2073cStracker-user /** 46443d2073cStracker-user * Wrap a Range in a highlight <span> for the given annotation. 46543d2073cStracker-user * 46643d2073cStracker-user * @param {Range} range 46743d2073cStracker-user * @param {object} ann 46843d2073cStracker-user */ 46943d2073cStracker-user function wrapHighlight(range, ann) { 470*563f3b4cStracker-user var preview = ann.body || ''; 47143d2073cStracker-user var span = document.createElement('span'); 47243d2073cStracker-user span.className = ann.status === 'resolved' 47343d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 47443d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 47543d2073cStracker-user span.dataset.annId = ann.id; 476*563f3b4cStracker-user span.title = preview.slice(0, 80) + (preview.length > 80 ? '…' : ''); 47743d2073cStracker-user span.addEventListener('click', function (e) { 47843d2073cStracker-user e.stopPropagation(); 47943d2073cStracker-user openPanel(ann.id); 48043d2073cStracker-user }); 481*563f3b4cStracker-user 482*563f3b4cStracker-user try { 48343d2073cStracker-user range.surroundContents(span); 48443d2073cStracker-user ann._highlightEl = span; 48543d2073cStracker-user } catch (e) { 486*563f3b4cStracker-user // surroundContents throws if the range crosses element boundaries; 487*563f3b4cStracker-user // fall back to extract + insert, reusing the same (still-empty) span. 48843d2073cStracker-user try { 489*563f3b4cStracker-user span.appendChild(range.extractContents()); 490*563f3b4cStracker-user range.insertNode(span); 491*563f3b4cStracker-user ann._highlightEl = span; 49243d2073cStracker-user } catch (e2) { 49343d2073cStracker-user ann._highlightEl = null; 49443d2073cStracker-user } 49543d2073cStracker-user } 49643d2073cStracker-user } 49743d2073cStracker-user 49843d2073cStracker-user /** 49943d2073cStracker-user * Remove all highlight spans, restoring the original text nodes. 50043d2073cStracker-user */ 50143d2073cStracker-user function clearHighlights() { 50243d2073cStracker-user var spans = document.querySelectorAll( 503da56206cStracker-user '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 50443d2073cStracker-user ); 50543d2073cStracker-user Array.prototype.forEach.call(spans, function (span) { 50643d2073cStracker-user var parent = span.parentNode; 50743d2073cStracker-user if (!parent) return; 50843d2073cStracker-user while (span.firstChild) { 50943d2073cStracker-user parent.insertBefore(span.firstChild, span); 51043d2073cStracker-user } 51143d2073cStracker-user parent.removeChild(span); 51243d2073cStracker-user parent.normalize(); 51343d2073cStracker-user }); 51443d2073cStracker-user } 51543d2073cStracker-user 51643d2073cStracker-user // ----------------------------------------------------------------------- 51743d2073cStracker-user // Gutter markers 51843d2073cStracker-user // ----------------------------------------------------------------------- 51943d2073cStracker-user 52043d2073cStracker-user /** 521*563f3b4cStracker-user * Render a small marker for every anchored annotation. Markers are 522*563f3b4cStracker-user * appended to document.body as absolutely-positioned elements so that 523*563f3b4cStracker-user * template overflow rules on inner containers cannot clip them. 524*563f3b4cStracker-user * 525*563f3b4cStracker-user * All markers share the same X position — just to the left of the .page 526*563f3b4cStracker-user * content column — so they form a tidy vertical column in the margin. 52743d2073cStracker-user */ 52843d2073cStracker-user function renderGutterMarkers() { 529*563f3b4cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 530*563f3b4cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 531*563f3b4cStracker-user var markerLeft = gutterMarkerLeft(scrollLeft); 532*563f3b4cStracker-user 533*563f3b4cStracker-user // Speech bubble SVG — clearly communicates "annotation here". 534*563f3b4cStracker-user var ICON_SVG = 535*563f3b4cStracker-user '<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" aria-hidden="true">' + 536*563f3b4cStracker-user '<rect x="1" y="1" width="14" height="10" rx="2"/>' + 537*563f3b4cStracker-user '<path d="M4 14 L4 11 L8 11 Z"/>' + 538*563f3b4cStracker-user '</svg>'; 53943d2073cStracker-user 54043d2073cStracker-user _annotations.forEach(function (ann) { 54143d2073cStracker-user if (!ann._highlightEl) return; // orphan 54243d2073cStracker-user 543*563f3b4cStracker-user var rect = ann._highlightEl.getBoundingClientRect(); 54443d2073cStracker-user 54543d2073cStracker-user var marker = document.createElement('button'); 54643d2073cStracker-user marker.className = CLS_GUTTER_MARKER; 54743d2073cStracker-user marker.dataset.annId = ann.id; 548*563f3b4cStracker-user marker.dataset.status = ann.status || 'open'; // drives CSS amber/green colour 549da56206cStracker-user marker.setAttribute('aria-label', t('label_annotation', 'Annotation')); 55043d2073cStracker-user marker.type = 'button'; 551*563f3b4cStracker-user marker.innerHTML = ICON_SVG; 552*563f3b4cStracker-user // Align vertically with the first line of the highlight. 553*563f3b4cStracker-user marker.style.top = (rect.top + scrollTop + 3) + 'px'; 554*563f3b4cStracker-user marker.style.left = markerLeft + 'px'; 55543d2073cStracker-user marker.addEventListener('click', function (e) { 55643d2073cStracker-user e.stopPropagation(); 55743d2073cStracker-user openPanel(ann.id); 55843d2073cStracker-user }); 559*563f3b4cStracker-user document.body.appendChild(marker); 56043d2073cStracker-user ann._markerEl = marker; 56143d2073cStracker-user }); 56243d2073cStracker-user } 56343d2073cStracker-user 56443d2073cStracker-user /** 56543d2073cStracker-user * Remove all gutter markers. 56643d2073cStracker-user */ 56743d2073cStracker-user function clearGutterMarkers() { 56843d2073cStracker-user var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 56943d2073cStracker-user Array.prototype.forEach.call(markers, function (m) { 57043d2073cStracker-user if (m.parentNode) m.parentNode.removeChild(m); 57143d2073cStracker-user }); 57243d2073cStracker-user } 57343d2073cStracker-user 574*563f3b4cStracker-user /** 575*563f3b4cStracker-user * The shared X position (document coordinates) for every gutter marker: 576*563f3b4cStracker-user * just inside the left padding of the .page content column, so the markers 577*563f3b4cStracker-user * form a tidy vertical strip in the margin. Falls back to 4px when the 578*563f3b4cStracker-user * column cannot be measured. Reads the theme's computed padding so it 579*563f3b4cStracker-user * adapts to the template. 580*563f3b4cStracker-user * 581*563f3b4cStracker-user * @param {number} scrollLeft current horizontal scroll offset 582*563f3b4cStracker-user * @returns {number} 583*563f3b4cStracker-user */ 584*563f3b4cStracker-user function gutterMarkerLeft(scrollLeft) { 585*563f3b4cStracker-user var pageEl = document.querySelector('.' + PAGE_CLS) || document.getElementById(CONTENT_ID); 586*563f3b4cStracker-user if (!pageEl) return 4; 587*563f3b4cStracker-user var pageRect = pageEl.getBoundingClientRect(); 588*563f3b4cStracker-user var padLeft = parseInt(window.getComputedStyle(pageEl).paddingLeft, 10) || 32; 589*563f3b4cStracker-user return pageRect.left + scrollLeft + Math.max(2, Math.floor(padLeft * 0.25)); 590*563f3b4cStracker-user } 591*563f3b4cStracker-user 592*563f3b4cStracker-user /** 593*563f3b4cStracker-user * Re-align every existing marker with its highlight without rebuilding the 594*563f3b4cStracker-user * DOM. Highlights shift when a panel is inserted/removed or the window is 595*563f3b4cStracker-user * resized, but markers live in document.body at absolute coordinates, so 596*563f3b4cStracker-user * they would otherwise drift out of line. Cheap — only touches inline 597*563f3b4cStracker-user * top/left on the handful of markers present. 598*563f3b4cStracker-user */ 599*563f3b4cStracker-user function repositionMarkers() { 600*563f3b4cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 601*563f3b4cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 602*563f3b4cStracker-user var markerLeft = gutterMarkerLeft(scrollLeft); 603*563f3b4cStracker-user _annotations.forEach(function (ann) { 604*563f3b4cStracker-user if (!ann._markerEl || !ann._highlightEl) return; 605*563f3b4cStracker-user var rect = ann._highlightEl.getBoundingClientRect(); 606*563f3b4cStracker-user ann._markerEl.style.top = (rect.top + scrollTop + 3) + 'px'; 607*563f3b4cStracker-user ann._markerEl.style.left = markerLeft + 'px'; 608*563f3b4cStracker-user }); 609*563f3b4cStracker-user } 610*563f3b4cStracker-user 61143d2073cStracker-user // ----------------------------------------------------------------------- 61243d2073cStracker-user // Page counter 61343d2073cStracker-user // ----------------------------------------------------------------------- 61443d2073cStracker-user 61543d2073cStracker-user /** 61643d2073cStracker-user * Render (or update) the counter bubble above the content area. 61743d2073cStracker-user * 61843d2073cStracker-user * @param {object} stats {total, open, resolved} 61943d2073cStracker-user * @param {number} orphanCount 62043d2073cStracker-user */ 62143d2073cStracker-user function renderCounter(stats, orphanCount) { 62243d2073cStracker-user var existing = document.getElementById('ann-counter-bar'); 62343d2073cStracker-user if (existing) existing.parentNode.removeChild(existing); 62443d2073cStracker-user 62543d2073cStracker-user if (stats.total === 0 && orphanCount === 0) return; 62643d2073cStracker-user 62743d2073cStracker-user var bar = document.createElement('div'); 62843d2073cStracker-user bar.id = 'ann-counter-bar'; 62943d2073cStracker-user bar.className = CLS_COUNTER; 63043d2073cStracker-user 63143d2073cStracker-user var total = stats.total || 0; 63243d2073cStracker-user var label = total === 1 633da56206cStracker-user ? t('counter_annotation', '1 annotation') 634da56206cStracker-user : fmt(t('counter_annotations', '%d annotations'), total); 63543d2073cStracker-user bar.appendChild(document.createTextNode(label)); 63643d2073cStracker-user 63743d2073cStracker-user if (orphanCount > 0) { 63843d2073cStracker-user bar.appendChild(document.createTextNode(' · ')); 63943d2073cStracker-user var orphanLink = document.createElement('a'); 64043d2073cStracker-user orphanLink.href = '#ann-orphan-drawer'; 64143d2073cStracker-user orphanLink.className = 'ann-orphan-link'; 642da56206cStracker-user orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount); 64343d2073cStracker-user orphanLink.addEventListener('click', function (e) { 64443d2073cStracker-user e.preventDefault(); 64543d2073cStracker-user toggleOrphanDrawer(); 646*563f3b4cStracker-user repositionMarkers(); 64743d2073cStracker-user }); 64843d2073cStracker-user bar.appendChild(orphanLink); 64943d2073cStracker-user } 65043d2073cStracker-user 65143d2073cStracker-user if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 65243d2073cStracker-user if (stats.resolved > 0) { 65343d2073cStracker-user var btnCR = document.createElement('button'); 65443d2073cStracker-user btnCR.type = 'button'; 65543d2073cStracker-user btnCR.className = 'ann-btn ann-btn-admin'; 656da56206cStracker-user btnCR.textContent = t('btn_clear_resolved', 'Clear resolved'); 65743d2073cStracker-user btnCR.addEventListener('click', doClearResolved); 65843d2073cStracker-user bar.appendChild(btnCR); 65943d2073cStracker-user } 66043d2073cStracker-user if (orphanCount > 0) { 66143d2073cStracker-user var btnCO = document.createElement('button'); 66243d2073cStracker-user btnCO.type = 'button'; 66343d2073cStracker-user btnCO.className = 'ann-btn ann-btn-admin'; 664da56206cStracker-user btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned'); 66543d2073cStracker-user btnCO.addEventListener('click', doClearOrphaned); 66643d2073cStracker-user bar.appendChild(btnCO); 66743d2073cStracker-user } 66843d2073cStracker-user } 66943d2073cStracker-user 670*563f3b4cStracker-user // Insert inside .page, right after #dw__toc if present. 671*563f3b4cStracker-user // The TOC is float:right so placing the bar after it (not before) lets 672*563f3b4cStracker-user // it sit to the left of the float instead of pushing the TOC down. 673*563f3b4cStracker-user var pageEl = document.querySelector('.' + PAGE_CLS); 674*563f3b4cStracker-user if (pageEl) { 675*563f3b4cStracker-user var toc = pageEl.querySelector('#dw__toc'); 676*563f3b4cStracker-user if (toc && toc.nextSibling) { 677*563f3b4cStracker-user pageEl.insertBefore(bar, toc.nextSibling); 678*563f3b4cStracker-user } else if (toc) { 679*563f3b4cStracker-user pageEl.appendChild(bar); 680*563f3b4cStracker-user } else { 681*563f3b4cStracker-user pageEl.insertBefore(bar, pageEl.firstChild); 682*563f3b4cStracker-user } 683*563f3b4cStracker-user } else { 68443d2073cStracker-user var content = document.getElementById(CONTENT_ID); 685*563f3b4cStracker-user if (content) content.insertBefore(bar, content.firstChild); 68643d2073cStracker-user } 68743d2073cStracker-user } 68843d2073cStracker-user 68943d2073cStracker-user /** 69043d2073cStracker-user * Recount and re-render the counter from in-memory state. 69143d2073cStracker-user */ 69243d2073cStracker-user function updateCounter(orphanCount) { 69343d2073cStracker-user var open = 0, resolved = 0; 69443d2073cStracker-user if (orphanCount === undefined) { 69543d2073cStracker-user orphanCount = 0; 69643d2073cStracker-user } 69743d2073cStracker-user _annotations.forEach(function (ann) { 69843d2073cStracker-user if (ann._orphaned) { 69943d2073cStracker-user orphanCount++; 70043d2073cStracker-user } else if (ann.status === 'resolved') { 70143d2073cStracker-user resolved++; 70243d2073cStracker-user } else { 70343d2073cStracker-user open++; 70443d2073cStracker-user } 70543d2073cStracker-user }); 70643d2073cStracker-user renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 70743d2073cStracker-user } 70843d2073cStracker-user 70943d2073cStracker-user // ----------------------------------------------------------------------- 71043d2073cStracker-user // Annotation panel 71143d2073cStracker-user // ----------------------------------------------------------------------- 71243d2073cStracker-user 71343d2073cStracker-user /** 71443d2073cStracker-user * Open the thread panel for the given annotation id. 71543d2073cStracker-user * If that panel is already open, close it. 71643d2073cStracker-user * 71743d2073cStracker-user * @param {string} annId 718*563f3b4cStracker-user * @param {boolean} [focusReply] focus the reply box once open (default true); 719*563f3b4cStracker-user * reopenPanel passes false so re-rendering after 720*563f3b4cStracker-user * an action doesn't yank the viewport to the form. 72143d2073cStracker-user */ 722*563f3b4cStracker-user function openPanel(annId, focusReply) { 72343d2073cStracker-user if (_openAnnId === annId) { 72443d2073cStracker-user closePanel(); 72543d2073cStracker-user return; 72643d2073cStracker-user } 72743d2073cStracker-user closePanel(); 72843d2073cStracker-user 72943d2073cStracker-user var ann = _annotations.get(annId); 73043d2073cStracker-user if (!ann) return; 73143d2073cStracker-user 73243d2073cStracker-user var panel = buildPanel(ann); 73343d2073cStracker-user _openPanel = panel; 73443d2073cStracker-user _openAnnId = annId; 73543d2073cStracker-user 73643d2073cStracker-user // Insert below the paragraph that contains the highlight. 73743d2073cStracker-user var anchor = ann._highlightEl || null; 73843d2073cStracker-user var insertAfter = findParagraph(anchor); 73943d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 74043d2073cStracker-user insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 74143d2073cStracker-user } else { 74243d2073cStracker-user // Orphan or no paragraph found: show at the bottom of content. 74343d2073cStracker-user var content = document.getElementById(CONTENT_ID); 74443d2073cStracker-user if (content) content.appendChild(panel); 74543d2073cStracker-user } 74643d2073cStracker-user 747*563f3b4cStracker-user if (focusReply !== false) { 748*563f3b4cStracker-user var input = panel.querySelector('.ann-body-input'); 749*563f3b4cStracker-user if (input) input.focus(); 750*563f3b4cStracker-user } 751*563f3b4cStracker-user 752*563f3b4cStracker-user // The panel grew the document; nudge markers below it back into line. 753*563f3b4cStracker-user repositionMarkers(); 75443d2073cStracker-user } 75543d2073cStracker-user 75643d2073cStracker-user /** 75743d2073cStracker-user * Close and remove the currently open panel. 75843d2073cStracker-user */ 75943d2073cStracker-user function closePanel() { 76043d2073cStracker-user if (_openPanel && _openPanel.parentNode) { 76143d2073cStracker-user _openPanel.parentNode.removeChild(_openPanel); 76243d2073cStracker-user } 76343d2073cStracker-user _openPanel = null; 76443d2073cStracker-user _openAnnId = null; 765*563f3b4cStracker-user repositionMarkers(); 76643d2073cStracker-user } 76743d2073cStracker-user 76843d2073cStracker-user /** 76943d2073cStracker-user * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 77043d2073cStracker-user * that can receive a sibling element. 77143d2073cStracker-user * 77243d2073cStracker-user * @param {HTMLElement|null} el 77343d2073cStracker-user * @returns {HTMLElement|null} 77443d2073cStracker-user */ 77543d2073cStracker-user function findParagraph(el) { 77643d2073cStracker-user var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 77743d2073cStracker-user var node = el; 77843d2073cStracker-user while (node && node.id !== CONTENT_ID) { 77943d2073cStracker-user if (node.nodeType === 1 && block.test(node.tagName)) { 78043d2073cStracker-user return node; 78143d2073cStracker-user } 78243d2073cStracker-user node = node.parentNode; 78343d2073cStracker-user } 78443d2073cStracker-user return el; // fallback: use the element itself 78543d2073cStracker-user } 78643d2073cStracker-user 78743d2073cStracker-user /** 78843d2073cStracker-user * Build and return the panel DOM element for one annotation. 78943d2073cStracker-user * 79043d2073cStracker-user * @param {object} ann 79143d2073cStracker-user * @returns {HTMLElement} 79243d2073cStracker-user */ 79343d2073cStracker-user function buildPanel(ann) { 79443d2073cStracker-user var panel = document.createElement('div'); 79543d2073cStracker-user panel.className = CLS_PANEL; 79643d2073cStracker-user panel.dataset.annId = ann.id; 797da56206cStracker-user panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css 79843d2073cStracker-user 799*563f3b4cStracker-user // Main annotation thread entry (close button lives in its meta row). 800*563f3b4cStracker-user var rootEntry = buildThreadEntry(ann, true); 801*563f3b4cStracker-user var meta = rootEntry.querySelector('.ann-meta'); 802*563f3b4cStracker-user if (meta) { 80343d2073cStracker-user var closeBtn = document.createElement('button'); 80443d2073cStracker-user closeBtn.type = 'button'; 80543d2073cStracker-user closeBtn.className = 'ann-btn ann-close'; 806da56206cStracker-user closeBtn.setAttribute('aria-label', t('label_close', 'Close')); 807*563f3b4cStracker-user closeBtn.textContent = '×'; // × 808*563f3b4cStracker-user closeBtn.style.marginLeft = 'auto'; 80943d2073cStracker-user closeBtn.addEventListener('click', closePanel); 810*563f3b4cStracker-user meta.appendChild(closeBtn); 811*563f3b4cStracker-user } 812*563f3b4cStracker-user panel.appendChild(rootEntry); 81343d2073cStracker-user 814*563f3b4cStracker-user // Replies: build hierarchy from flat list and render depth-indented. 815*563f3b4cStracker-user appendReplyTree(panel, ann, buildReplyTree(ann.replies || []), 0); 81643d2073cStracker-user 817*563f3b4cStracker-user // Reply form at the bottom for root-level replies. 81843d2073cStracker-user if (_loggedIn) { 81943d2073cStracker-user panel.appendChild(buildReplyForm(ann)); 82043d2073cStracker-user } 82143d2073cStracker-user 82243d2073cStracker-user return panel; 82343d2073cStracker-user } 82443d2073cStracker-user 82543d2073cStracker-user /** 82643d2073cStracker-user * Build the DOM for the top-level annotation entry. 82743d2073cStracker-user * 82843d2073cStracker-user * @param {object} ann 82943d2073cStracker-user * @param {boolean} isRoot true for the annotation itself, false for replies 83043d2073cStracker-user * @returns {HTMLElement} 83143d2073cStracker-user */ 83243d2073cStracker-user function buildThreadEntry(ann, isRoot) { 83343d2073cStracker-user var entry = document.createElement('div'); 83443d2073cStracker-user entry.className = 'ann-thread-entry ann-annotation'; 83543d2073cStracker-user entry.dataset.annId = ann.id; 83643d2073cStracker-user 83743d2073cStracker-user // Meta row: avatar, author, time, status pill 83843d2073cStracker-user entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 83943d2073cStracker-user 84043d2073cStracker-user // Body 84143d2073cStracker-user var bodyEl = document.createElement('div'); 84243d2073cStracker-user bodyEl.className = 'ann-body'; 84343d2073cStracker-user bodyEl.textContent = ann.body; 84443d2073cStracker-user entry.appendChild(bodyEl); 84543d2073cStracker-user 84643d2073cStracker-user // Quoted text snippet 84743d2073cStracker-user if (ann.anchor && ann.anchor.exact) { 84843d2073cStracker-user var quote = document.createElement('blockquote'); 84943d2073cStracker-user quote.className = 'ann-quote'; 85043d2073cStracker-user quote.textContent = ann.anchor.exact; 85143d2073cStracker-user entry.appendChild(quote); 85243d2073cStracker-user } 85343d2073cStracker-user 85443d2073cStracker-user // Action buttons 85543d2073cStracker-user var actions = document.createElement('div'); 85643d2073cStracker-user actions.className = 'ann-actions'; 85743d2073cStracker-user 85843d2073cStracker-user // Resolve/Reopen (any reader) 85943d2073cStracker-user if (_loggedIn) { 86043d2073cStracker-user var resolveBtn = document.createElement('button'); 86143d2073cStracker-user resolveBtn.type = 'button'; 862*563f3b4cStracker-user resolveBtn.className = 'ann-btn ann-btn-primary'; 863da56206cStracker-user resolveBtn.textContent = ann.status === 'resolved' 864da56206cStracker-user ? t('btn_reopen', 'Reopen') 865da56206cStracker-user : t('btn_resolve', 'Resolve'); 86643d2073cStracker-user resolveBtn.addEventListener('click', function () { 867*563f3b4cStracker-user doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved', resolveBtn); 86843d2073cStracker-user }); 86943d2073cStracker-user actions.appendChild(resolveBtn); 87043d2073cStracker-user } 87143d2073cStracker-user 87243d2073cStracker-user // Edit + Delete (own or admin) 87343d2073cStracker-user var canEdit = _isAdmin || ann.author === currentUser(); 87443d2073cStracker-user if (canEdit && _loggedIn) { 87543d2073cStracker-user var editBtn = document.createElement('button'); 87643d2073cStracker-user editBtn.type = 'button'; 87743d2073cStracker-user editBtn.className = 'ann-btn'; 878da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 87943d2073cStracker-user editBtn.addEventListener('click', function () { 88043d2073cStracker-user showEditForm(entry, ann, 'annotation'); 88143d2073cStracker-user }); 88243d2073cStracker-user actions.appendChild(editBtn); 88343d2073cStracker-user 88443d2073cStracker-user var delBtn = document.createElement('button'); 88543d2073cStracker-user delBtn.type = 'button'; 88643d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 887da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 88843d2073cStracker-user delBtn.addEventListener('click', function () { 889da56206cStracker-user if (confirm(t('confirm_delete', 'Delete this annotation?'))) { 890*563f3b4cStracker-user doDeleteAnnotation(ann.id, delBtn); 89143d2073cStracker-user } 89243d2073cStracker-user }); 89343d2073cStracker-user actions.appendChild(delBtn); 89443d2073cStracker-user } 89543d2073cStracker-user 89643d2073cStracker-user entry.appendChild(actions); 89743d2073cStracker-user return entry; 89843d2073cStracker-user } 89943d2073cStracker-user 90043d2073cStracker-user /** 901*563f3b4cStracker-user * Build the DOM for one reply entry, indented according to its nesting depth. 90243d2073cStracker-user * 90343d2073cStracker-user * @param {object} ann parent annotation 90443d2073cStracker-user * @param {object} reply 905*563f3b4cStracker-user * @param {number} depth 0 = direct reply to annotation; 1+ = nested 90643d2073cStracker-user * @returns {HTMLElement} 90743d2073cStracker-user */ 908*563f3b4cStracker-user function buildReplyEntry(ann, reply, depth) { 90943d2073cStracker-user var entry = document.createElement('div'); 91043d2073cStracker-user entry.className = 'ann-thread-entry ann-reply'; 91143d2073cStracker-user entry.dataset.replyId = reply.id; 912*563f3b4cStracker-user // Indent nested replies up to 4 levels (1.5 em each). 913*563f3b4cStracker-user var indent = Math.min(depth, 4) * 1.5 + 1.5; 914*563f3b4cStracker-user if (indent > 0) { 915*563f3b4cStracker-user entry.style.marginLeft = indent + 'em'; 916*563f3b4cStracker-user } 91743d2073cStracker-user 91843d2073cStracker-user entry.appendChild(buildMeta(reply.author, reply.created, null)); 91943d2073cStracker-user 92043d2073cStracker-user var bodyEl = document.createElement('div'); 92143d2073cStracker-user bodyEl.className = 'ann-body'; 92243d2073cStracker-user bodyEl.textContent = reply.body; 92343d2073cStracker-user entry.appendChild(bodyEl); 92443d2073cStracker-user 92543d2073cStracker-user var actions = document.createElement('div'); 92643d2073cStracker-user actions.className = 'ann-actions'; 92743d2073cStracker-user 928*563f3b4cStracker-user // "Reply to this reply" button for logged-in users. 929*563f3b4cStracker-user if (_loggedIn) { 930*563f3b4cStracker-user var replyToBtn = document.createElement('button'); 931*563f3b4cStracker-user replyToBtn.type = 'button'; 932*563f3b4cStracker-user replyToBtn.className = 'ann-btn ann-btn-primary'; 933*563f3b4cStracker-user replyToBtn.textContent = t('btn_reply', 'Reply'); 934*563f3b4cStracker-user replyToBtn.addEventListener('click', function () { 935*563f3b4cStracker-user // Toggle an inline reply form directly after this entry. 936*563f3b4cStracker-user var next = entry.nextSibling; 937*563f3b4cStracker-user if (next && next.classList && next.classList.contains('ann-inline-reply')) { 938*563f3b4cStracker-user next.parentNode.removeChild(next); 939*563f3b4cStracker-user return; 940*563f3b4cStracker-user } 941*563f3b4cStracker-user var form = buildInlineReplyForm(ann, reply.id, depth + 1); 942*563f3b4cStracker-user entry.parentNode.insertBefore(form, entry.nextSibling); 943*563f3b4cStracker-user var ta = form.querySelector('.ann-body-input'); 944*563f3b4cStracker-user if (ta) ta.focus(); 945*563f3b4cStracker-user }); 946*563f3b4cStracker-user actions.appendChild(replyToBtn); 947*563f3b4cStracker-user } 948*563f3b4cStracker-user 94943d2073cStracker-user var canEdit = _isAdmin || reply.author === currentUser(); 95043d2073cStracker-user if (canEdit && _loggedIn) { 95143d2073cStracker-user var editBtn = document.createElement('button'); 95243d2073cStracker-user editBtn.type = 'button'; 95343d2073cStracker-user editBtn.className = 'ann-btn'; 954da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 95543d2073cStracker-user editBtn.addEventListener('click', function () { 95643d2073cStracker-user showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 95743d2073cStracker-user }); 95843d2073cStracker-user actions.appendChild(editBtn); 95943d2073cStracker-user 96043d2073cStracker-user var delBtn = document.createElement('button'); 96143d2073cStracker-user delBtn.type = 'button'; 96243d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 963da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 96443d2073cStracker-user delBtn.addEventListener('click', function () { 965da56206cStracker-user if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) { 966*563f3b4cStracker-user doDeleteReply(ann.id, reply.id, delBtn); 96743d2073cStracker-user } 96843d2073cStracker-user }); 96943d2073cStracker-user actions.appendChild(delBtn); 97043d2073cStracker-user } 97143d2073cStracker-user 97243d2073cStracker-user entry.appendChild(actions); 97343d2073cStracker-user return entry; 97443d2073cStracker-user } 97543d2073cStracker-user 97643d2073cStracker-user /** 977*563f3b4cStracker-user * Build a nested tree structure from a flat reply list. Replies without a 978*563f3b4cStracker-user * known parentId (including legacy replies with no parentId field) are 979*563f3b4cStracker-user * treated as root-level. 980*563f3b4cStracker-user * 981*563f3b4cStracker-user * @param {Array} replies flat array of reply objects 982*563f3b4cStracker-user * @returns {Array} array of {reply, children} nodes 983*563f3b4cStracker-user */ 984*563f3b4cStracker-user function buildReplyTree(replies) { 985*563f3b4cStracker-user var map = {}; 986*563f3b4cStracker-user var roots = []; 987*563f3b4cStracker-user replies.forEach(function (r) { 988*563f3b4cStracker-user map[r.id] = {reply: r, children: []}; 989*563f3b4cStracker-user }); 990*563f3b4cStracker-user replies.forEach(function (r) { 991*563f3b4cStracker-user var pid = r.parentId || ''; 992*563f3b4cStracker-user if (pid && map[pid]) { 993*563f3b4cStracker-user map[pid].children.push(map[r.id]); 994*563f3b4cStracker-user } else { 995*563f3b4cStracker-user roots.push(map[r.id]); 996*563f3b4cStracker-user } 997*563f3b4cStracker-user }); 998*563f3b4cStracker-user return roots; 999*563f3b4cStracker-user } 1000*563f3b4cStracker-user 1001*563f3b4cStracker-user /** 1002*563f3b4cStracker-user * Recursively append reply entries into the panel. 1003*563f3b4cStracker-user * 1004*563f3b4cStracker-user * @param {HTMLElement} panel 1005*563f3b4cStracker-user * @param {object} ann 1006*563f3b4cStracker-user * @param {Array} nodes array of {reply, children} tree nodes 1007*563f3b4cStracker-user * @param {number} depth 1008*563f3b4cStracker-user */ 1009*563f3b4cStracker-user function appendReplyTree(panel, ann, nodes, depth) { 1010*563f3b4cStracker-user nodes.forEach(function (node) { 1011*563f3b4cStracker-user panel.appendChild(buildReplyEntry(ann, node.reply, depth)); 1012*563f3b4cStracker-user if (node.children.length > 0) { 1013*563f3b4cStracker-user appendReplyTree(panel, ann, node.children, depth + 1); 1014*563f3b4cStracker-user } 1015*563f3b4cStracker-user }); 1016*563f3b4cStracker-user } 1017*563f3b4cStracker-user 1018*563f3b4cStracker-user /** 1019*563f3b4cStracker-user * Build an inline reply form that appears directly below a reply entry. 1020*563f3b4cStracker-user * 1021*563f3b4cStracker-user * @param {object} ann parent annotation 1022*563f3b4cStracker-user * @param {string} parentReplyId id of the reply being replied to 1023*563f3b4cStracker-user * @param {number} depth visual nesting depth for the new reply 1024*563f3b4cStracker-user * @returns {HTMLElement} 1025*563f3b4cStracker-user */ 1026*563f3b4cStracker-user function buildInlineReplyForm(ann, parentReplyId, depth) { 1027*563f3b4cStracker-user var form = document.createElement('div'); 1028*563f3b4cStracker-user form.className = 'ann-thread-entry ann-reply ann-inline-reply'; 1029*563f3b4cStracker-user var indent = Math.min(depth, 4) * 1.5 + 1.5; 1030*563f3b4cStracker-user if (indent > 0) { 1031*563f3b4cStracker-user form.style.marginLeft = indent + 'em'; 1032*563f3b4cStracker-user } 1033*563f3b4cStracker-user 1034*563f3b4cStracker-user var ta = document.createElement('textarea'); 1035*563f3b4cStracker-user ta.className = 'ann-body-input'; 1036*563f3b4cStracker-user ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1037*563f3b4cStracker-user ta.rows = 2; 1038*563f3b4cStracker-user form.appendChild(ta); 1039*563f3b4cStracker-user 1040*563f3b4cStracker-user var row = document.createElement('div'); 1041*563f3b4cStracker-user row.className = 'ann-form-row'; 1042*563f3b4cStracker-user 1043*563f3b4cStracker-user var submitBtn = document.createElement('button'); 1044*563f3b4cStracker-user submitBtn.type = 'button'; 1045*563f3b4cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1046*563f3b4cStracker-user submitBtn.textContent = t('btn_reply', 'Reply'); 1047*563f3b4cStracker-user submitBtn.addEventListener('click', function () { 1048*563f3b4cStracker-user var body = ta.value.trim(); 1049*563f3b4cStracker-user if (!body) return; 1050*563f3b4cStracker-user doAddReply(ann.id, body, function () { 1051*563f3b4cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1052*563f3b4cStracker-user }, submitBtn, parentReplyId); 1053*563f3b4cStracker-user }); 1054*563f3b4cStracker-user 1055*563f3b4cStracker-user var cancelBtn = document.createElement('button'); 1056*563f3b4cStracker-user cancelBtn.type = 'button'; 1057*563f3b4cStracker-user cancelBtn.className = 'ann-btn'; 1058*563f3b4cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1059*563f3b4cStracker-user cancelBtn.addEventListener('click', function () { 1060*563f3b4cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1061*563f3b4cStracker-user }); 1062*563f3b4cStracker-user 1063*563f3b4cStracker-user row.appendChild(submitBtn); 1064*563f3b4cStracker-user row.appendChild(cancelBtn); 1065*563f3b4cStracker-user form.appendChild(row); 1066*563f3b4cStracker-user return form; 1067*563f3b4cStracker-user } 1068*563f3b4cStracker-user 1069*563f3b4cStracker-user /** 107043d2073cStracker-user * Build the meta row (avatar initials, author name, timestamp, status pill). 107143d2073cStracker-user * 107243d2073cStracker-user * @param {string} author 107343d2073cStracker-user * @param {number} timestamp Unix seconds 107443d2073cStracker-user * @param {string|null} status 'open'|'resolved'|null 107543d2073cStracker-user * @returns {HTMLElement} 107643d2073cStracker-user */ 107743d2073cStracker-user function buildMeta(author, timestamp, status) { 107843d2073cStracker-user var meta = document.createElement('div'); 107943d2073cStracker-user meta.className = 'ann-meta'; 108043d2073cStracker-user 108143d2073cStracker-user var avatar = document.createElement('span'); 108243d2073cStracker-user avatar.className = 'ann-avatar'; 108343d2073cStracker-user avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 108443d2073cStracker-user meta.appendChild(avatar); 108543d2073cStracker-user 108643d2073cStracker-user var authorEl = document.createElement('span'); 108743d2073cStracker-user authorEl.className = 'ann-author'; 1088da56206cStracker-user authorEl.textContent = author || t('label_unknown', 'Unknown'); 108943d2073cStracker-user meta.appendChild(authorEl); 109043d2073cStracker-user 109143d2073cStracker-user var timeEl = document.createElement('time'); 109243d2073cStracker-user timeEl.className = 'ann-time'; 109343d2073cStracker-user var d = new Date(timestamp * 1000); 109443d2073cStracker-user timeEl.dateTime = d.toISOString(); 109543d2073cStracker-user timeEl.textContent = formatDate(d); 109643d2073cStracker-user meta.appendChild(timeEl); 109743d2073cStracker-user 109843d2073cStracker-user if (status) { 109943d2073cStracker-user var pill = document.createElement('span'); 110043d2073cStracker-user pill.className = 'ann-status ann-status-' + status; 1101da56206cStracker-user pill.textContent = status === 'resolved' 1102da56206cStracker-user ? t('status_resolved', 'Resolved') 1103da56206cStracker-user : t('status_open', 'Open'); 110443d2073cStracker-user meta.appendChild(pill); 110543d2073cStracker-user } 110643d2073cStracker-user 110743d2073cStracker-user return meta; 110843d2073cStracker-user } 110943d2073cStracker-user 111043d2073cStracker-user /** 111143d2073cStracker-user * Build a reply form at the bottom of the panel. 111243d2073cStracker-user * 111343d2073cStracker-user * @param {object} ann 111443d2073cStracker-user * @returns {HTMLElement} 111543d2073cStracker-user */ 111643d2073cStracker-user function buildReplyForm(ann) { 111743d2073cStracker-user var form = document.createElement('div'); 111843d2073cStracker-user form.className = 'ann-reply-form'; 111943d2073cStracker-user 112043d2073cStracker-user var ta = document.createElement('textarea'); 112143d2073cStracker-user ta.className = 'ann-body-input'; 1122da56206cStracker-user ta.placeholder = t('placeholder_reply', 'Write a reply…'); 112343d2073cStracker-user ta.rows = 3; 112443d2073cStracker-user form.appendChild(ta); 112543d2073cStracker-user 112643d2073cStracker-user var row = document.createElement('div'); 112743d2073cStracker-user row.className = 'ann-form-row'; 112843d2073cStracker-user 112943d2073cStracker-user var submitBtn = document.createElement('button'); 113043d2073cStracker-user submitBtn.type = 'button'; 113143d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1132da56206cStracker-user submitBtn.textContent = t('btn_reply', 'Reply'); 113343d2073cStracker-user submitBtn.addEventListener('click', function () { 113443d2073cStracker-user var body = ta.value.trim(); 113543d2073cStracker-user if (!body) return; 113643d2073cStracker-user doAddReply(ann.id, body, function () { 113743d2073cStracker-user ta.value = ''; 1138*563f3b4cStracker-user }, submitBtn); 113943d2073cStracker-user }); 114043d2073cStracker-user row.appendChild(submitBtn); 114143d2073cStracker-user form.appendChild(row); 114243d2073cStracker-user 114343d2073cStracker-user return form; 114443d2073cStracker-user } 114543d2073cStracker-user 114643d2073cStracker-user /** 114743d2073cStracker-user * Replace the body of an entry with an inline edit form. 114843d2073cStracker-user * 114943d2073cStracker-user * @param {HTMLElement} entry 115043d2073cStracker-user * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 115143d2073cStracker-user * @param {string} type 'annotation' | 'reply' 115243d2073cStracker-user */ 115343d2073cStracker-user function showEditForm(entry, data, type) { 115443d2073cStracker-user var bodyEl = entry.querySelector('.ann-body'); 115543d2073cStracker-user if (!bodyEl) return; 115643d2073cStracker-user 115743d2073cStracker-user var ta = document.createElement('textarea'); 115843d2073cStracker-user ta.className = 'ann-body-input'; 115943d2073cStracker-user ta.value = data.body || ''; 1160*563f3b4cStracker-user ta.rows = 3; 116143d2073cStracker-user 116243d2073cStracker-user var row = document.createElement('div'); 116343d2073cStracker-user row.className = 'ann-form-row'; 116443d2073cStracker-user 116543d2073cStracker-user var saveBtn = document.createElement('button'); 116643d2073cStracker-user saveBtn.type = 'button'; 116743d2073cStracker-user saveBtn.className = 'ann-btn ann-btn-primary'; 1168da56206cStracker-user saveBtn.textContent = t('btn_save', 'Save'); 116943d2073cStracker-user saveBtn.addEventListener('click', function () { 117043d2073cStracker-user var newBody = ta.value.trim(); 117143d2073cStracker-user if (!newBody) return; 117243d2073cStracker-user if (type === 'annotation') { 1173*563f3b4cStracker-user doEditAnnotation(data.id || _openAnnId, newBody, saveBtn); 117443d2073cStracker-user } else { 1175*563f3b4cStracker-user doEditReply(data.annId, data.replyId, newBody, saveBtn); 117643d2073cStracker-user } 117743d2073cStracker-user }); 117843d2073cStracker-user 117943d2073cStracker-user var cancelBtn = document.createElement('button'); 118043d2073cStracker-user cancelBtn.type = 'button'; 118143d2073cStracker-user cancelBtn.className = 'ann-btn'; 1182da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 118343d2073cStracker-user cancelBtn.addEventListener('click', function () { 118443d2073cStracker-user entry.removeChild(ta); 118543d2073cStracker-user entry.removeChild(row); 118643d2073cStracker-user bodyEl.style.display = ''; 118743d2073cStracker-user }); 118843d2073cStracker-user 118943d2073cStracker-user row.appendChild(saveBtn); 119043d2073cStracker-user row.appendChild(cancelBtn); 119143d2073cStracker-user 119243d2073cStracker-user bodyEl.style.display = 'none'; 119343d2073cStracker-user entry.insertBefore(ta, bodyEl.nextSibling); 119443d2073cStracker-user entry.insertBefore(row, ta.nextSibling); 119543d2073cStracker-user ta.focus(); 119643d2073cStracker-user } 119743d2073cStracker-user 119843d2073cStracker-user // ----------------------------------------------------------------------- 119943d2073cStracker-user // Orphan drawer 120043d2073cStracker-user // ----------------------------------------------------------------------- 120143d2073cStracker-user 120243d2073cStracker-user /** 120343d2073cStracker-user * Toggle the orphan drawer visibility. 120443d2073cStracker-user */ 120543d2073cStracker-user function toggleOrphanDrawer() { 120643d2073cStracker-user var drawer = document.getElementById('ann-orphan-drawer'); 120743d2073cStracker-user if (drawer) { 120843d2073cStracker-user drawer.parentNode.removeChild(drawer); 120943d2073cStracker-user return; 121043d2073cStracker-user } 121143d2073cStracker-user renderOrphanDrawer(); 121243d2073cStracker-user } 121343d2073cStracker-user 121443d2073cStracker-user /** 121543d2073cStracker-user * Build and insert the orphan drawer at the bottom of the content area. 121643d2073cStracker-user */ 121743d2073cStracker-user function renderOrphanDrawer() { 121843d2073cStracker-user var content = document.getElementById(CONTENT_ID); 121943d2073cStracker-user if (!content) return; 122043d2073cStracker-user 122143d2073cStracker-user var drawer = document.createElement('div'); 122243d2073cStracker-user drawer.id = 'ann-orphan-drawer'; 122343d2073cStracker-user drawer.className = CLS_ORPHAN_DRAWER; 122443d2073cStracker-user 122543d2073cStracker-user var heading = document.createElement('h4'); 1226da56206cStracker-user heading.textContent = t('orphaned_heading', 'Orphaned annotations'); 122743d2073cStracker-user drawer.appendChild(heading); 122843d2073cStracker-user 122943d2073cStracker-user var note = document.createElement('p'); 123043d2073cStracker-user note.className = 'ann-orphan-note'; 1231da56206cStracker-user note.textContent = t('orphaned_note', 1232da56206cStracker-user 'These annotations reference text that no longer appears on the page.'); 123343d2073cStracker-user drawer.appendChild(note); 123443d2073cStracker-user 123543d2073cStracker-user var found = false; 123643d2073cStracker-user _annotations.forEach(function (ann) { 123743d2073cStracker-user if (!ann._orphaned) return; 123843d2073cStracker-user found = true; 123943d2073cStracker-user var entry = buildThreadEntry(ann, true); 124043d2073cStracker-user drawer.appendChild(entry); 124143d2073cStracker-user }); 124243d2073cStracker-user 124343d2073cStracker-user if (!found) { 124443d2073cStracker-user var empty = document.createElement('p'); 1245da56206cStracker-user empty.textContent = t('orphaned_none', 'None.'); 124643d2073cStracker-user drawer.appendChild(empty); 124743d2073cStracker-user } 124843d2073cStracker-user 1249*563f3b4cStracker-user // Insert right below the counter bar, which lives inside .page. 1250*563f3b4cStracker-user // All fallbacks also target .page so the drawer never stretches past 1251*563f3b4cStracker-user // the content column. 1252*563f3b4cStracker-user var bar = document.getElementById('ann-counter-bar'); 1253*563f3b4cStracker-user if (bar && bar.parentNode) { 1254*563f3b4cStracker-user bar.parentNode.insertBefore(drawer, bar.nextSibling); 1255*563f3b4cStracker-user } else { 1256*563f3b4cStracker-user var pageEl2 = document.querySelector('.' + PAGE_CLS); 1257*563f3b4cStracker-user if (pageEl2) { 1258*563f3b4cStracker-user pageEl2.insertBefore(drawer, pageEl2.firstChild); 1259*563f3b4cStracker-user } else { 1260*563f3b4cStracker-user content.insertBefore(drawer, content.firstChild); 1261*563f3b4cStracker-user } 1262*563f3b4cStracker-user } 126343d2073cStracker-user } 126443d2073cStracker-user 126543d2073cStracker-user // ----------------------------------------------------------------------- 126643d2073cStracker-user // Selection capture 126743d2073cStracker-user // ----------------------------------------------------------------------- 126843d2073cStracker-user 126943d2073cStracker-user /** 127043d2073cStracker-user * Wire up mouseup/touchend listeners to detect text selection. 127143d2073cStracker-user * 127243d2073cStracker-user * @param {HTMLElement} content 127343d2073cStracker-user */ 127443d2073cStracker-user function initSelectionCapture(content) { 127543d2073cStracker-user if (!_loggedIn) return; // anonymous users cannot annotate 127643d2073cStracker-user 127743d2073cStracker-user document.addEventListener('mouseup', function (e) { 127843d2073cStracker-user handleSelectionEnd(e, content); 127943d2073cStracker-user }); 128043d2073cStracker-user document.addEventListener('touchend', function (e) { 128143d2073cStracker-user // Small delay so the browser has committed the selection. 128243d2073cStracker-user setTimeout(function () { handleSelectionEnd(e, content); }, 50); 128343d2073cStracker-user }); 128443d2073cStracker-user 128550325813Stracker-user // Close tooltip on click outside (but not when clicking the new-form). 128643d2073cStracker-user document.addEventListener('mousedown', function (e) { 128743d2073cStracker-user var tooltip = document.getElementById('ann-tooltip'); 128843d2073cStracker-user if (tooltip && !tooltip.contains(e.target)) { 128950325813Stracker-user var naf = document.getElementById('ann-new-form'); 129050325813Stracker-user if (!naf || !naf.contains(e.target)) { 129143d2073cStracker-user hideTooltip(); 129243d2073cStracker-user } 129350325813Stracker-user } 129443d2073cStracker-user }); 129543d2073cStracker-user } 129643d2073cStracker-user 129743d2073cStracker-user /** 129843d2073cStracker-user * Handle end of selection: show the "Annotate" tooltip if there is a 129943d2073cStracker-user * non-empty selection inside the content area. 130043d2073cStracker-user * 130143d2073cStracker-user * @param {Event} e 130243d2073cStracker-user * @param {HTMLElement} content 130343d2073cStracker-user */ 130443d2073cStracker-user function handleSelectionEnd(e, content) { 130543d2073cStracker-user var sel = window.getSelection(); 130643d2073cStracker-user if (!sel || sel.isCollapsed) { 130750325813Stracker-user // Don't hide the tooltip if the mouseup came from inside it — 130850325813Stracker-user // the click handler is responsible for cleanup in that case. 130950325813Stracker-user var tip = document.getElementById('ann-tooltip'); 131050325813Stracker-user if (tip && tip.contains(e.target)) { 131150325813Stracker-user return; 131250325813Stracker-user } 131350325813Stracker-user // Don't hide if a new-annotation form is open (user clicked 131450325813Stracker-user // inside the form, collapsing the original selection). 131550325813Stracker-user var naf = document.getElementById('ann-new-form'); 131650325813Stracker-user if (naf && naf.contains(e.target)) { 131750325813Stracker-user return; 131850325813Stracker-user } 131943d2073cStracker-user hideTooltip(); 132043d2073cStracker-user return; 132143d2073cStracker-user } 132243d2073cStracker-user var range = sel.getRangeAt(0); 132343d2073cStracker-user if (!content.contains(range.commonAncestorContainer)) { 132443d2073cStracker-user hideTooltip(); 132543d2073cStracker-user return; 132643d2073cStracker-user } 1327*563f3b4cStracker-user // Don't open a new annotation when the selection overlaps existing annotated text. 1328*563f3b4cStracker-user if (isInsideHighlight(range.startContainer) || isInsideHighlight(range.endContainer)) { 1329*563f3b4cStracker-user hideTooltip(); 1330*563f3b4cStracker-user return; 1331*563f3b4cStracker-user } 133243d2073cStracker-user var text = sel.toString().trim(); 133343d2073cStracker-user if (text.length < 1) { 133443d2073cStracker-user hideTooltip(); 133543d2073cStracker-user return; 133643d2073cStracker-user } 133743d2073cStracker-user 133850325813Stracker-user // If the tooltip is already showing (e.g. user moused up after 133950325813Stracker-user // pressing the Annotate button), don't replace it with a fresh one — 134050325813Stracker-user // that would orphan the button mid-click and break the click handler. 134150325813Stracker-user if (document.getElementById('ann-tooltip')) { 134250325813Stracker-user return; 134350325813Stracker-user } 134450325813Stracker-user 134543d2073cStracker-user // Show the tooltip near the end of the selection. 134643d2073cStracker-user var rect = range.getBoundingClientRect(); 134743d2073cStracker-user showTooltip(rect, range, sel, content); 134843d2073cStracker-user } 134943d2073cStracker-user 135043d2073cStracker-user /** 135143d2073cStracker-user * Show the "Annotate" tooltip bubble. 135243d2073cStracker-user * 135343d2073cStracker-user * @param {DOMRect} rect bounding rect of the selection 135443d2073cStracker-user * @param {Range} range 135543d2073cStracker-user * @param {Selection} sel 135643d2073cStracker-user * @param {HTMLElement} content 135743d2073cStracker-user */ 135843d2073cStracker-user function showTooltip(rect, range, sel, content) { 135943d2073cStracker-user hideTooltip(); 136043d2073cStracker-user 136143d2073cStracker-user var tip = document.createElement('div'); 136243d2073cStracker-user tip.id = 'ann-tooltip'; 136343d2073cStracker-user tip.className = CLS_TOOLTIP; 136443d2073cStracker-user 136550325813Stracker-user // Capture the anchor on mousedown while the selection is guaranteed 136650325813Stracker-user // to still exist. By the time 'click' fires, many browsers have 136750325813Stracker-user // already collapsed the selection, so captureAnchor would return null. 136850325813Stracker-user // _pendingAnchor is module-level so it survives tooltip replacement. 136943d2073cStracker-user var btn = document.createElement('button'); 137043d2073cStracker-user btn.type = 'button'; 1371da56206cStracker-user btn.textContent = t('btn_annotate', 'Annotate'); 137243d2073cStracker-user btn.className = 'ann-btn ann-btn-primary'; 137343d2073cStracker-user btn.addEventListener('mousedown', function (e) { 137450325813Stracker-user e.preventDefault(); // prevent focus-change deselection 137550325813Stracker-user // Capture now, while the selection is still intact. 137650325813Stracker-user _pendingAnchor = captureAnchor(sel, range, content); 137743d2073cStracker-user }); 137843d2073cStracker-user btn.addEventListener('click', function () { 137950325813Stracker-user var anchor = _pendingAnchor; 138050325813Stracker-user _pendingAnchor = null; 138143d2073cStracker-user hideTooltip(); 138243d2073cStracker-user if (anchor) { 138343d2073cStracker-user openNewAnnotationForm(anchor, range); 138443d2073cStracker-user } 138543d2073cStracker-user }); 138643d2073cStracker-user tip.appendChild(btn); 138743d2073cStracker-user 138843d2073cStracker-user document.body.appendChild(tip); 138943d2073cStracker-user 139043d2073cStracker-user // Position below the selection's end. 139143d2073cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 139243d2073cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 139343d2073cStracker-user tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 139443d2073cStracker-user tip.style.left = (rect.left + scrollLeft) + 'px'; 139543d2073cStracker-user } 139643d2073cStracker-user 139743d2073cStracker-user /** 139843d2073cStracker-user * Remove the tooltip if it exists. 139943d2073cStracker-user */ 140043d2073cStracker-user function hideTooltip() { 140143d2073cStracker-user var tip = document.getElementById('ann-tooltip'); 140243d2073cStracker-user if (tip && tip.parentNode) { 140343d2073cStracker-user tip.parentNode.removeChild(tip); 140443d2073cStracker-user } 140550325813Stracker-user // Note: ann-new-form is NOT removed here — it has its own Cancel 140650325813Stracker-user // button and must survive the mouseup that fires after the click. 140743d2073cStracker-user } 140843d2073cStracker-user 140943d2073cStracker-user /** 141043d2073cStracker-user * Capture an anchor object from the current Selection. 141143d2073cStracker-user * 141243d2073cStracker-user * @param {Selection} sel 141343d2073cStracker-user * @param {Range} range 141443d2073cStracker-user * @param {HTMLElement} content 141543d2073cStracker-user * @returns {object|null} {exact, prefix, suffix, start} 141643d2073cStracker-user */ 141743d2073cStracker-user function captureAnchor(sel, range, content) { 141843d2073cStracker-user var exact = normalizeWS(sel.toString()); 141943d2073cStracker-user if (!exact) return null; 142043d2073cStracker-user 142143d2073cStracker-user // Get full page text for prefix/suffix and start computation. 142243d2073cStracker-user var chunks = collectTextChunks(content); 142343d2073cStracker-user var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1424da56206cStracker-user var nm = normalizeWithMap(fullRaw); 1425da56206cStracker-user var fullNorm = nm.norm; 142643d2073cStracker-user 142743d2073cStracker-user // Find where this text node + offset lands in the raw full text. 142843d2073cStracker-user var rawStart = 0; 142943d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 143043d2073cStracker-user var c = chunks[i]; 143143d2073cStracker-user if (c.node === range.startContainer) { 143243d2073cStracker-user rawStart = c.start + range.startOffset; 143343d2073cStracker-user break; 143443d2073cStracker-user } 143543d2073cStracker-user } 143643d2073cStracker-user 1437da56206cStracker-user // Map that raw offset to an offset in the normalised text, using the 1438da56206cStracker-user // same map as re-anchoring so capture and find stay in agreement. 1439da56206cStracker-user var normStart = nm.norm.length; 1440da56206cStracker-user for (var j = 0; j < nm.map.length; j++) { 1441da56206cStracker-user if (nm.map[j] >= rawStart) { 144243d2073cStracker-user normStart = j; 144343d2073cStracker-user break; 144443d2073cStracker-user } 144543d2073cStracker-user } 144643d2073cStracker-user 144743d2073cStracker-user var CTX = 30; 144843d2073cStracker-user var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 144943d2073cStracker-user var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 145043d2073cStracker-user 145143d2073cStracker-user return { 145243d2073cStracker-user exact: exact, 145343d2073cStracker-user prefix: prefix, 145443d2073cStracker-user suffix: suffix, 145543d2073cStracker-user start: normStart, 145643d2073cStracker-user }; 145743d2073cStracker-user } 145843d2073cStracker-user 145943d2073cStracker-user /** 146043d2073cStracker-user * Open the new-annotation form below the paragraph containing the selection. 146143d2073cStracker-user * 146243d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 146343d2073cStracker-user * @param {Range} range 146443d2073cStracker-user */ 146543d2073cStracker-user function openNewAnnotationForm(anchor, range) { 146643d2073cStracker-user closePanel(); 146743d2073cStracker-user 146843d2073cStracker-user var insertAfter = findParagraph(range.commonAncestorContainer); 146943d2073cStracker-user var form = document.createElement('div'); 147043d2073cStracker-user form.id = 'ann-new-form'; 147143d2073cStracker-user form.className = 'ann-new-form'; 147243d2073cStracker-user 147343d2073cStracker-user var quote = document.createElement('blockquote'); 147443d2073cStracker-user quote.className = 'ann-quote'; 147543d2073cStracker-user quote.textContent = anchor.exact; 147643d2073cStracker-user form.appendChild(quote); 147743d2073cStracker-user 147843d2073cStracker-user var ta = document.createElement('textarea'); 147943d2073cStracker-user ta.className = 'ann-body-input'; 1480da56206cStracker-user ta.placeholder = t('placeholder_body', 'Add a comment…'); 1481*563f3b4cStracker-user ta.rows = 3; 148243d2073cStracker-user form.appendChild(ta); 148343d2073cStracker-user 148443d2073cStracker-user var row = document.createElement('div'); 148543d2073cStracker-user row.className = 'ann-form-row'; 148643d2073cStracker-user 148743d2073cStracker-user var submitBtn = document.createElement('button'); 148843d2073cStracker-user submitBtn.type = 'button'; 148943d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1490da56206cStracker-user submitBtn.textContent = t('btn_annotate', 'Annotate'); 149143d2073cStracker-user submitBtn.addEventListener('click', function () { 149243d2073cStracker-user var body = ta.value.trim(); 149343d2073cStracker-user if (!body) return; 149443d2073cStracker-user doCreate(anchor, body, function () { 149543d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1496*563f3b4cStracker-user }, submitBtn); 149743d2073cStracker-user }); 149843d2073cStracker-user 149943d2073cStracker-user var cancelBtn = document.createElement('button'); 150043d2073cStracker-user cancelBtn.type = 'button'; 150143d2073cStracker-user cancelBtn.className = 'ann-btn'; 1502da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 150343d2073cStracker-user cancelBtn.addEventListener('click', function () { 150443d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 150543d2073cStracker-user }); 150643d2073cStracker-user 150743d2073cStracker-user row.appendChild(submitBtn); 150843d2073cStracker-user row.appendChild(cancelBtn); 150943d2073cStracker-user form.appendChild(row); 151043d2073cStracker-user 151143d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 151243d2073cStracker-user insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 151343d2073cStracker-user } else { 151443d2073cStracker-user var content = document.getElementById(CONTENT_ID); 151543d2073cStracker-user if (content) content.appendChild(form); 151643d2073cStracker-user } 151743d2073cStracker-user 151843d2073cStracker-user ta.focus(); 151943d2073cStracker-user } 152043d2073cStracker-user 152143d2073cStracker-user // ----------------------------------------------------------------------- 152243d2073cStracker-user // AJAX actions 152343d2073cStracker-user // ----------------------------------------------------------------------- 152443d2073cStracker-user 152543d2073cStracker-user /** 152643d2073cStracker-user * POST create action and update state on success. 152743d2073cStracker-user * 152843d2073cStracker-user * @param {object} anchor 152943d2073cStracker-user * @param {string} body 153043d2073cStracker-user * @param {Function} onSuccess 1531*563f3b4cStracker-user * @param {HTMLElement} [btn] button to disable while the request is in flight 153243d2073cStracker-user */ 1533*563f3b4cStracker-user function doCreate(anchor, body, onSuccess, btn) { 1534*563f3b4cStracker-user setBusy(btn, true); 153543d2073cStracker-user ajax({ 153643d2073cStracker-user action: 'create', 153743d2073cStracker-user id: _info.pageId, 153843d2073cStracker-user anchor: anchor, 153943d2073cStracker-user body: body, 154043d2073cStracker-user }).then(function (data) { 1541*563f3b4cStracker-user setBusy(btn, false); 154243d2073cStracker-user if (!data.success) { 1543da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 154443d2073cStracker-user return; 154543d2073cStracker-user } 154643d2073cStracker-user var ann = data.annotation; 154743d2073cStracker-user _annotations.set(ann.id, ann); 154843d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(ann); 154943d2073cStracker-user renderAll(); 155043d2073cStracker-user }).catch(function () { 1551*563f3b4cStracker-user setBusy(btn, false); 1552da56206cStracker-user alert(t('error_save', 'Could not save — please try again.')); 155343d2073cStracker-user }); 155443d2073cStracker-user } 155543d2073cStracker-user 155643d2073cStracker-user /** 1557*563f3b4cStracker-user * Run a thread-level mutation (reply / edit annotation / edit reply / 1558*563f3b4cStracker-user * delete reply): POST the payload, then on success store the returned 1559*563f3b4cStracker-user * annotation — keeping the client-side render state via mergeClientProps — 1560*563f3b4cStracker-user * and re-open its panel. The server returns the full updated annotation, so 1561*563f3b4cStracker-user * no second GET is needed. These four actions share this exact shape; 1562*563f3b4cStracker-user * create / delete-annotation / resolve differ (they re-render the whole 1563*563f3b4cStracker-user * overlay) and stay separate below. 1564*563f3b4cStracker-user * 1565*563f3b4cStracker-user * @param {object} payload AJAX body; must carry annId 1566*563f3b4cStracker-user * @param {HTMLElement} [btn] button to show the busy spinner on 1567*563f3b4cStracker-user * @param {string} errKey lang key for the failure message 1568*563f3b4cStracker-user * @param {string} errText English fallback for that message 1569*563f3b4cStracker-user * @param {Function} [onOk] optional callback run before re-rendering 1570*563f3b4cStracker-user */ 1571*563f3b4cStracker-user function submitThreadAction(payload, btn, errKey, errText, onOk) { 1572*563f3b4cStracker-user setBusy(btn, true); 1573*563f3b4cStracker-user ajax(payload).then(function (data) { 1574*563f3b4cStracker-user setBusy(btn, false); 1575*563f3b4cStracker-user if (!data.success) { 1576*563f3b4cStracker-user showError(t(errKey, errText), data); 1577*563f3b4cStracker-user return; 1578*563f3b4cStracker-user } 1579*563f3b4cStracker-user _annotations.set(data.annotation.id, mergeClientProps(data.annotation)); 1580*563f3b4cStracker-user if (typeof onOk === 'function') onOk(); 1581*563f3b4cStracker-user reopenPanel(payload.annId); 1582*563f3b4cStracker-user }).catch(function () { 1583*563f3b4cStracker-user setBusy(btn, false); 1584*563f3b4cStracker-user alert(t(errKey, errText)); 1585*563f3b4cStracker-user }); 1586*563f3b4cStracker-user } 1587*563f3b4cStracker-user 1588*563f3b4cStracker-user /** 158943d2073cStracker-user * POST reply action and refresh the open panel. 159043d2073cStracker-user * 159143d2073cStracker-user * @param {string} annId 159243d2073cStracker-user * @param {string} body 159343d2073cStracker-user * @param {Function} onSuccess 1594*563f3b4cStracker-user * @param {HTMLElement} [btn] 1595*563f3b4cStracker-user * @param {string} [parentReplyId] id of the reply being replied to, or '' 159643d2073cStracker-user */ 1597*563f3b4cStracker-user function doAddReply(annId, body, onSuccess, btn, parentReplyId) { 1598*563f3b4cStracker-user submitThreadAction({ 159943d2073cStracker-user action: 'reply', 160043d2073cStracker-user id: _info.pageId, 160143d2073cStracker-user annId: annId, 160243d2073cStracker-user body: body, 1603*563f3b4cStracker-user parentId: parentReplyId || '', 1604*563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.', onSuccess); 160543d2073cStracker-user } 160643d2073cStracker-user 160743d2073cStracker-user /** 160843d2073cStracker-user * POST edit_annotation and re-render. 160943d2073cStracker-user * 161043d2073cStracker-user * @param {string} annId 161143d2073cStracker-user * @param {string} body 1612*563f3b4cStracker-user * @param {HTMLElement} [btn] 161343d2073cStracker-user */ 1614*563f3b4cStracker-user function doEditAnnotation(annId, body, btn) { 1615*563f3b4cStracker-user submitThreadAction({ 161643d2073cStracker-user action: 'edit_annotation', 161743d2073cStracker-user id: _info.pageId, 161843d2073cStracker-user annId: annId, 161943d2073cStracker-user body: body, 1620*563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.'); 162143d2073cStracker-user } 162243d2073cStracker-user 162343d2073cStracker-user /** 162443d2073cStracker-user * POST edit_reply and re-render. 162543d2073cStracker-user * 162643d2073cStracker-user * @param {string} annId 162743d2073cStracker-user * @param {string} replyId 162843d2073cStracker-user * @param {string} body 1629*563f3b4cStracker-user * @param {HTMLElement} [btn] 163043d2073cStracker-user */ 1631*563f3b4cStracker-user function doEditReply(annId, replyId, body, btn) { 1632*563f3b4cStracker-user submitThreadAction({ 163343d2073cStracker-user action: 'edit_reply', 163443d2073cStracker-user id: _info.pageId, 163543d2073cStracker-user annId: annId, 163643d2073cStracker-user replyId: replyId, 163743d2073cStracker-user body: body, 1638*563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.'); 163943d2073cStracker-user } 164043d2073cStracker-user 164143d2073cStracker-user /** 164243d2073cStracker-user * POST delete_annotation. 164343d2073cStracker-user * 164443d2073cStracker-user * @param {string} annId 1645*563f3b4cStracker-user * @param {HTMLElement} [btn] 164643d2073cStracker-user */ 1647*563f3b4cStracker-user function doDeleteAnnotation(annId, btn) { 1648*563f3b4cStracker-user setBusy(btn, true); 164943d2073cStracker-user ajax({ 165043d2073cStracker-user action: 'delete_annotation', 165143d2073cStracker-user id: _info.pageId, 165243d2073cStracker-user annId: annId, 165343d2073cStracker-user }).then(function (data) { 1654*563f3b4cStracker-user setBusy(btn, false); 165543d2073cStracker-user if (!data.success) { 1656da56206cStracker-user showError(t('error_delete', 'Could not delete — please try again.'), data); 165743d2073cStracker-user return; 165843d2073cStracker-user } 165943d2073cStracker-user _annotations.delete(annId); 166043d2073cStracker-user closePanel(); 166143d2073cStracker-user renderAll(); 1662*563f3b4cStracker-user }).catch(function () { 1663*563f3b4cStracker-user setBusy(btn, false); 166443d2073cStracker-user }); 166543d2073cStracker-user } 166643d2073cStracker-user 166743d2073cStracker-user /** 166843d2073cStracker-user * POST delete_reply and re-render. 166943d2073cStracker-user * 167043d2073cStracker-user * @param {string} annId 167143d2073cStracker-user * @param {string} replyId 1672*563f3b4cStracker-user * @param {HTMLElement} [btn] 167343d2073cStracker-user */ 1674*563f3b4cStracker-user function doDeleteReply(annId, replyId, btn) { 1675*563f3b4cStracker-user submitThreadAction({ 167643d2073cStracker-user action: 'delete_reply', 167743d2073cStracker-user id: _info.pageId, 167843d2073cStracker-user annId: annId, 167943d2073cStracker-user replyId: replyId, 1680*563f3b4cStracker-user }, btn, 'error_delete', 'Could not delete — please try again.'); 168143d2073cStracker-user } 168243d2073cStracker-user 168343d2073cStracker-user /** 168443d2073cStracker-user * POST resolve/reopen action. 168543d2073cStracker-user * 168643d2073cStracker-user * @param {string} annId 168743d2073cStracker-user * @param {string} status 'open' | 'resolved' 1688*563f3b4cStracker-user * @param {HTMLElement} [btn] 168943d2073cStracker-user */ 1690*563f3b4cStracker-user function doResolve(annId, status, btn) { 1691*563f3b4cStracker-user setBusy(btn, true); 169243d2073cStracker-user ajax({ 169343d2073cStracker-user action: 'resolve', 169443d2073cStracker-user id: _info.pageId, 169543d2073cStracker-user annId: annId, 169643d2073cStracker-user status: status, 169743d2073cStracker-user }).then(function (data) { 1698*563f3b4cStracker-user setBusy(btn, false); 169943d2073cStracker-user if (!data.success) { 1700da56206cStracker-user showError(t('error_status', 'Could not update the status — please try again.'), data); 170143d2073cStracker-user return; 170243d2073cStracker-user } 1703*563f3b4cStracker-user _annotations.set(data.annotation.id, data.annotation); 170443d2073cStracker-user renderAll(); 170543d2073cStracker-user reopenPanel(annId); 1706*563f3b4cStracker-user }).catch(function () { 1707*563f3b4cStracker-user setBusy(btn, false); 170843d2073cStracker-user }); 170943d2073cStracker-user } 171043d2073cStracker-user 171143d2073cStracker-user /** 171243d2073cStracker-user * POST clear_resolved (admin). 171343d2073cStracker-user */ 171443d2073cStracker-user function doClearResolved() { 1715da56206cStracker-user if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return; 171643d2073cStracker-user ajax({ 171743d2073cStracker-user action: 'clear_resolved', 171843d2073cStracker-user id: _info.pageId, 171943d2073cStracker-user }).then(function (data) { 172043d2073cStracker-user if (!data.success) { 1721da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 172243d2073cStracker-user return; 172343d2073cStracker-user } 172443d2073cStracker-user // Remove resolved from local state. 172543d2073cStracker-user _annotations.forEach(function (ann, id) { 172643d2073cStracker-user if (ann.status === 'resolved') _annotations.delete(id); 172743d2073cStracker-user }); 172843d2073cStracker-user closePanel(); 172943d2073cStracker-user renderAll(); 173043d2073cStracker-user }); 173143d2073cStracker-user } 173243d2073cStracker-user 173343d2073cStracker-user /** 173443d2073cStracker-user * POST clear_orphaned (admin). 173543d2073cStracker-user */ 173643d2073cStracker-user function doClearOrphaned() { 1737da56206cStracker-user if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return; 173843d2073cStracker-user ajax({ 173943d2073cStracker-user action: 'clear_orphaned', 174043d2073cStracker-user id: _info.pageId, 174143d2073cStracker-user }).then(function (data) { 174243d2073cStracker-user if (!data.success) { 1743da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 174443d2073cStracker-user return; 174543d2073cStracker-user } 174643d2073cStracker-user _annotations.forEach(function (ann, id) { 174743d2073cStracker-user if (ann._orphaned) _annotations.delete(id); 174843d2073cStracker-user }); 174943d2073cStracker-user closePanel(); 175043d2073cStracker-user renderAll(); 175143d2073cStracker-user }); 175243d2073cStracker-user } 175343d2073cStracker-user 175443d2073cStracker-user // ----------------------------------------------------------------------- 175543d2073cStracker-user // Panel management helpers 175643d2073cStracker-user // ----------------------------------------------------------------------- 175743d2073cStracker-user 175843d2073cStracker-user /** 175943d2073cStracker-user * Close the current panel and re-open it (preserves scroll position and 176043d2073cStracker-user * re-renders the thread with fresh data). 176143d2073cStracker-user * 176243d2073cStracker-user * @param {string} annId 176343d2073cStracker-user */ 176443d2073cStracker-user function reopenPanel(annId) { 1765*563f3b4cStracker-user // closePanel() first clears _openAnnId so openPanel() rebuilds instead 1766*563f3b4cStracker-user // of treating the same id as a toggle. focusReply=false keeps the 1767*563f3b4cStracker-user // viewport put after resolve / edit / delete actions. 176843d2073cStracker-user closePanel(); 1769*563f3b4cStracker-user openPanel(annId, false); 177043d2073cStracker-user } 177143d2073cStracker-user 177243d2073cStracker-user // ----------------------------------------------------------------------- 177343d2073cStracker-user // Utilities 177443d2073cStracker-user // ----------------------------------------------------------------------- 177543d2073cStracker-user 177643d2073cStracker-user /** 1777*563f3b4cStracker-user * Disable a button and show a spinner while an AJAX request is in flight; 1778*563f3b4cStracker-user * restore label and width on completion. 1779*563f3b4cStracker-user * 1780*563f3b4cStracker-user * @param {HTMLElement|null|undefined} btn 1781*563f3b4cStracker-user * @param {boolean} busy 1782*563f3b4cStracker-user */ 1783*563f3b4cStracker-user function setBusy(btn, busy) { 1784*563f3b4cStracker-user if (!btn) return; 1785*563f3b4cStracker-user if (busy) { 1786*563f3b4cStracker-user btn.disabled = true; 1787*563f3b4cStracker-user btn.dataset.prevText = btn.textContent; 1788*563f3b4cStracker-user // Lock the width before clearing text so the button doesn't shrink. 1789*563f3b4cStracker-user btn.style.minWidth = btn.offsetWidth + 'px'; 1790*563f3b4cStracker-user btn.textContent = ' '; // non-breaking space keeps height 1791*563f3b4cStracker-user btn.classList.add('ann-btn-busy'); 1792*563f3b4cStracker-user } else { 1793*563f3b4cStracker-user btn.disabled = false; 1794*563f3b4cStracker-user btn.classList.remove('ann-btn-busy'); 1795*563f3b4cStracker-user if (btn.dataset.prevText !== undefined) { 1796*563f3b4cStracker-user btn.textContent = btn.dataset.prevText; 1797*563f3b4cStracker-user delete btn.dataset.prevText; 1798*563f3b4cStracker-user } 1799*563f3b4cStracker-user btn.style.minWidth = ''; 1800*563f3b4cStracker-user } 1801*563f3b4cStracker-user } 1802*563f3b4cStracker-user 1803*563f3b4cStracker-user /** 1804*563f3b4cStracker-user * Copy client-only runtime properties (_highlightEl, _markerEl, 1805*563f3b4cStracker-user * _orphaned, _range) from the currently stored annotation onto a 1806*563f3b4cStracker-user * freshly-returned server object before storing it, so that panels 1807*563f3b4cStracker-user * reopen at the correct position instead of falling back to the 1808*563f3b4cStracker-user * bottom of the page. 1809*563f3b4cStracker-user * 1810*563f3b4cStracker-user * @param {object} fresh annotation object from the server 1811*563f3b4cStracker-user * @returns {object} the same object, augmented 1812*563f3b4cStracker-user */ 1813*563f3b4cStracker-user function mergeClientProps(fresh) { 1814*563f3b4cStracker-user var existing = _annotations.get(fresh.id); 1815*563f3b4cStracker-user if (existing) { 1816*563f3b4cStracker-user fresh._highlightEl = existing._highlightEl; 1817*563f3b4cStracker-user fresh._markerEl = existing._markerEl; 1818*563f3b4cStracker-user fresh._orphaned = existing._orphaned; 1819*563f3b4cStracker-user fresh._range = existing._range; 1820*563f3b4cStracker-user } 1821*563f3b4cStracker-user return fresh; 1822*563f3b4cStracker-user } 1823*563f3b4cStracker-user 1824*563f3b4cStracker-user /** 1825da56206cStracker-user * The per-plugin JS language bundle, exposed by DokuWiki as 1826da56206cStracker-user * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 1827da56206cStracker-user * 1828da56206cStracker-user * @returns {object} 1829da56206cStracker-user */ 1830da56206cStracker-user function uiLang() { 1831da56206cStracker-user if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) { 1832da56206cStracker-user return LANG.plugins.annotations; 1833da56206cStracker-user } 1834da56206cStracker-user return {}; 1835da56206cStracker-user } 1836da56206cStracker-user 1837da56206cStracker-user /** 1838da56206cStracker-user * Look up a UI string by key, falling back to the supplied English text if 1839da56206cStracker-user * the bundle is missing the key (e.g. a lang file not yet updated). 1840da56206cStracker-user * 1841da56206cStracker-user * @param {string} key 1842da56206cStracker-user * @param {string} fallback English default 1843da56206cStracker-user * @returns {string} 1844da56206cStracker-user */ 1845da56206cStracker-user function t(key, fallback) { 1846da56206cStracker-user var s = _lang[key]; 1847da56206cStracker-user return (s === undefined || s === null || s === '') ? fallback : s; 1848da56206cStracker-user } 1849da56206cStracker-user 1850da56206cStracker-user /** 1851da56206cStracker-user * Substitute a single %d placeholder with a number. 1852da56206cStracker-user * 1853da56206cStracker-user * @param {string} str 1854da56206cStracker-user * @param {number} n 1855da56206cStracker-user * @returns {string} 1856da56206cStracker-user */ 1857da56206cStracker-user function fmt(str, n) { 1858da56206cStracker-user return String(str).replace('%d', n); 1859da56206cStracker-user } 1860da56206cStracker-user 1861da56206cStracker-user /** 1862da56206cStracker-user * Show a localised error, appending the server's reason in parentheses 1863da56206cStracker-user * when one is present. 1864da56206cStracker-user * 1865da56206cStracker-user * @param {string} base localised message 1866da56206cStracker-user * @param {object} data AJAX response ({error?:string}) 1867da56206cStracker-user */ 1868da56206cStracker-user function showError(base, data) { 1869da56206cStracker-user var reason = (data && data.error) ? data.error : ''; 1870da56206cStracker-user alert(reason ? base + ' (' + reason + ')' : base); 1871da56206cStracker-user } 1872da56206cStracker-user 1873da56206cStracker-user /** 187443d2073cStracker-user * Collapse consecutive whitespace to a single space and trim. 187543d2073cStracker-user * 187643d2073cStracker-user * @param {string} s 187743d2073cStracker-user * @returns {string} 187843d2073cStracker-user */ 187943d2073cStracker-user function normalizeWS(s) { 188043d2073cStracker-user return String(s || '').replace(/\s+/g, ' ').trim(); 188143d2073cStracker-user } 188243d2073cStracker-user 188343d2073cStracker-user /** 188443d2073cStracker-user * Return the current DokuWiki username from JSINFO. 188543d2073cStracker-user * 188643d2073cStracker-user * @returns {string} 188743d2073cStracker-user */ 188843d2073cStracker-user function currentUser() { 188943d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 18907d2714c7Stracker-user return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : ''; 189143d2073cStracker-user } 189243d2073cStracker-user 189343d2073cStracker-user /** 189443d2073cStracker-user * Format a Date for display. 189543d2073cStracker-user * 189643d2073cStracker-user * @param {Date} d 189743d2073cStracker-user * @returns {string} 189843d2073cStracker-user */ 189943d2073cStracker-user function formatDate(d) { 190043d2073cStracker-user var now = new Date(); 190143d2073cStracker-user var diff = (now - d) / 1000; // seconds 1902da56206cStracker-user if (diff < 60) return t('time_now', 'just now'); 1903da56206cStracker-user if (diff < 3600) return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60)); 1904da56206cStracker-user if (diff < 86400) return fmt(t('time_hours', '%dh ago'), Math.floor(diff / 3600)); 1905da56206cStracker-user if (diff < 86400 * 7) return fmt(t('time_days', '%dd ago'), Math.floor(diff / 86400)); 190643d2073cStracker-user return d.toLocaleDateString(); 190743d2073cStracker-user } 190843d2073cStracker-user 190943d2073cStracker-user // ----------------------------------------------------------------------- 191043d2073cStracker-user // Init 191143d2073cStracker-user // ----------------------------------------------------------------------- 191243d2073cStracker-user 191343d2073cStracker-user if (document.readyState === 'loading') { 191443d2073cStracker-user document.addEventListener('DOMContentLoaded', boot); 191543d2073cStracker-user } else { 191643d2073cStracker-user boot(); 191743d2073cStracker-user } 191843d2073cStracker-user 191943d2073cStracker-user}()); 1920