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 30*ad1073d4Stracker-user * reachable via the orphaned counter link; their full threads (root 31*ad1073d4Stracker-user * message plus the read-only reply tree) render in a dedicated orphan 32*ad1073d4Stracker-user * drawer at the bottom of the content area. 3343d2073cStracker-user * 3443d2073cStracker-user * FF78 ESR compatibility: 3543d2073cStracker-user * - No #private fields, ??=, ||=, &&=, Array.at, structuredClone, 3643d2073cStracker-user * Object.hasOwn, native <dialog>. 3743d2073cStracker-user * - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK. 3843d2073cStracker-user */ 3943d2073cStracker-user 4043d2073cStracker-user(function () { 4143d2073cStracker-user 'use strict'; 4243d2073cStracker-user 4343d2073cStracker-user // ----------------------------------------------------------------------- 4443d2073cStracker-user // Constants 4543d2073cStracker-user // ----------------------------------------------------------------------- 4643d2073cStracker-user 4743d2073cStracker-user var AJAX_URL = DOKU_BASE + 'lib/exe/ajax.php?call=annotations'; 4843d2073cStracker-user var CONTENT_ID = 'dokuwiki__content'; 49b8076f00Stracker-user // .page is the article area inside #dokuwiki__content. Gutter markers 50b8076f00Stracker-user // are appended here so position:relative doesn't break the sidebar nav. 51b8076f00Stracker-user var PAGE_CLS = 'page'; 5243d2073cStracker-user 5343d2073cStracker-user // Colour tokens (also defined in style.css; kept here so JS can read them) 5443d2073cStracker-user var CLS_HIGHLIGHT_OPEN = 'ann-highlight-open'; 5543d2073cStracker-user var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved'; 5643d2073cStracker-user var CLS_GUTTER_MARKER = 'ann-gutter-marker'; 5743d2073cStracker-user var CLS_PANEL = 'ann-panel'; 5843d2073cStracker-user var CLS_COUNTER = 'ann-counter'; 5943d2073cStracker-user var CLS_TOOLTIP = 'ann-tooltip'; 6043d2073cStracker-user var CLS_ORPHAN_DRAWER = 'ann-orphan-drawer'; 6143d2073cStracker-user 6243d2073cStracker-user // ----------------------------------------------------------------------- 6343d2073cStracker-user // State 6443d2073cStracker-user // ----------------------------------------------------------------------- 6543d2073cStracker-user 6643d2073cStracker-user /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */ 6743d2073cStracker-user var _annotations = new Map(); 6843d2073cStracker-user 6943d2073cStracker-user /** Currently open panel element, or null. @type {HTMLElement|null} */ 7043d2073cStracker-user var _openPanel = null; 7143d2073cStracker-user 7250325813Stracker-user /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */ 7350325813Stracker-user var _pendingAnchor = null; 7450325813Stracker-user 7543d2073cStracker-user /** ID of the annotation whose panel is open, or null. @type {string|null} */ 7643d2073cStracker-user var _openAnnId = null; 7743d2073cStracker-user 7843d2073cStracker-user /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */ 7943d2073cStracker-user var _info = {}; 8043d2073cStracker-user 8143d2073cStracker-user /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */ 8243d2073cStracker-user var _lang = {}; 8343d2073cStracker-user 8443d2073cStracker-user /** The DokuWiki security token. @type {string} */ 8543d2073cStracker-user var _token = ''; 8643d2073cStracker-user 8743d2073cStracker-user /** Whether the current user is logged in. @type {bool} */ 8843d2073cStracker-user var _loggedIn = false; 8943d2073cStracker-user 9043d2073cStracker-user /** Whether the current user is an admin. @type {bool} */ 9143d2073cStracker-user var _isAdmin = false; 9243d2073cStracker-user 9343d2073cStracker-user // ----------------------------------------------------------------------- 9443d2073cStracker-user // Boot 9543d2073cStracker-user // ----------------------------------------------------------------------- 9643d2073cStracker-user 9743d2073cStracker-user /** 9843d2073cStracker-user * Entry point: wired to DOMContentLoaded. 9943d2073cStracker-user */ 10043d2073cStracker-user function boot() { 10143d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 10243d2073cStracker-user var annInfo = jsinfo.annotations || {}; 10343d2073cStracker-user 10443d2073cStracker-user if (!annInfo.enabled) { 10543d2073cStracker-user return; // user disabled annotations 10643d2073cStracker-user } 10743d2073cStracker-user 10843d2073cStracker-user _info = annInfo; 109da56206cStracker-user // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as 110da56206cStracker-user // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 111da56206cStracker-user _lang = uiLang(); 1127d2714c7Stracker-user // Token is injected into JSINFO.annotations by action.php (handleMetaHeader). 1137d2714c7Stracker-user // getSecurityToken() on the server produces it from session_id + REMOTE_USER. 1147d2714c7Stracker-user _token = annInfo.token || ''; 11543d2073cStracker-user 1167d2714c7Stracker-user // DokuWiki's JSINFO doesn't include user identity; we inject 1177d2714c7Stracker-user // user + isAdmin into JSINFO.annotations from PHP (action.php). 1187d2714c7Stracker-user _loggedIn = !!(annInfo.user && annInfo.user !== ''); 1197d2714c7Stracker-user _isAdmin = !!(annInfo.isAdmin); 12043d2073cStracker-user 12143d2073cStracker-user var content = document.getElementById(CONTENT_ID); 12243d2073cStracker-user if (!content) { 12343d2073cStracker-user return; // not a page view 12443d2073cStracker-user } 12543d2073cStracker-user 12643d2073cStracker-user renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0); 12743d2073cStracker-user loadAnnotations(); 12843d2073cStracker-user initSelectionCapture(content); 129563f3b4cStracker-user 130563f3b4cStracker-user // Close the open panel when the user presses Escape. 131563f3b4cStracker-user document.addEventListener('keydown', function (e) { 132563f3b4cStracker-user if ((e.key === 'Escape' || e.key === 'Esc') && _openPanel) { 133563f3b4cStracker-user closePanel(); 134563f3b4cStracker-user } 135563f3b4cStracker-user }); 136563f3b4cStracker-user 137563f3b4cStracker-user // Keep gutter markers aligned with their highlights when the viewport 138563f3b4cStracker-user // width changes: both the .page column and the highlights reflow. 139563f3b4cStracker-user window.addEventListener('resize', repositionMarkers); 140108f92bdStracker-user 141108f92bdStracker-user // Annotations now render at DOMContentLoaded (the list ships inline), 142108f92bdStracker-user // so late-loading images/web fonts can still shift the layout under the 143108f92bdStracker-user // already-placed markers. Re-align them once everything has loaded. 144108f92bdStracker-user window.addEventListener('load', repositionMarkers); 14543d2073cStracker-user } 14643d2073cStracker-user 14743d2073cStracker-user // ----------------------------------------------------------------------- 14843d2073cStracker-user // AJAX helpers 14943d2073cStracker-user // ----------------------------------------------------------------------- 15043d2073cStracker-user 15143d2073cStracker-user /** 15243d2073cStracker-user * POST a JSON payload to the AJAX endpoint. 15343d2073cStracker-user * 15443d2073cStracker-user * @param {object} payload 15543d2073cStracker-user * @returns {Promise<object>} response data 15643d2073cStracker-user */ 15743d2073cStracker-user function ajax(payload) { 15843d2073cStracker-user payload.sectok = _token; // DokuWiki security token field name for AJAX 15943d2073cStracker-user return fetch(AJAX_URL, { 16043d2073cStracker-user method: 'POST', 16143d2073cStracker-user headers: {'Content-Type': 'application/json'}, 16243d2073cStracker-user body: JSON.stringify(payload), 16343d2073cStracker-user }).then(function (res) { 16443d2073cStracker-user return res.json(); 16543d2073cStracker-user }); 16643d2073cStracker-user } 16743d2073cStracker-user 16843d2073cStracker-user // ----------------------------------------------------------------------- 16943d2073cStracker-user // Load and anchor annotations 17043d2073cStracker-user // ----------------------------------------------------------------------- 17143d2073cStracker-user 17243d2073cStracker-user /** 173108f92bdStracker-user * Load all annotations for the current page and render them. 174108f92bdStracker-user * 175108f92bdStracker-user * Fast path: action.php normally ships the list inline with the page (in 176108f92bdStracker-user * JSINFO.annotations.annotations), so we render straight away with no 177108f92bdStracker-user * round-trip. Only heavily-annotated pages omit the inline list, in which 178108f92bdStracker-user * case we fall back to the GET 'load' endpoint. 17943d2073cStracker-user */ 18043d2073cStracker-user function loadAnnotations() { 181108f92bdStracker-user if (Array.isArray(_info.annotations)) { 182108f92bdStracker-user ingestAnnotations(_info.annotations); 183108f92bdStracker-user return; 184108f92bdStracker-user } 185108f92bdStracker-user 186108f92bdStracker-user // Fallback: the inline list was too large to embed. Fetch it instead. 187108f92bdStracker-user // action.php's AJAX handler accepts action=load as a GET query. 18843d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 18943d2073cStracker-user method: 'GET', 19043d2073cStracker-user }).then(function (res) { 19143d2073cStracker-user return res.json(); 19243d2073cStracker-user }).then(function (data) { 19343d2073cStracker-user if (!data || !Array.isArray(data.annotations)) { 19443d2073cStracker-user return; 19543d2073cStracker-user } 196108f92bdStracker-user ingestAnnotations(data.annotations); 19743d2073cStracker-user }).catch(function () { 19843d2073cStracker-user // Graceful degradation: page still works without annotations. 19943d2073cStracker-user }); 20043d2073cStracker-user } 20143d2073cStracker-user 20243d2073cStracker-user /** 203108f92bdStracker-user * Store a loaded annotation list (inline or fetched) and render everything. 204108f92bdStracker-user * 205108f92bdStracker-user * @param {Array} list annotation objects from the server 206108f92bdStracker-user */ 207108f92bdStracker-user function ingestAnnotations(list) { 208108f92bdStracker-user list.forEach(function (ann) { 209108f92bdStracker-user _annotations.set(ann.id, ann); 210108f92bdStracker-user }); 211108f92bdStracker-user renderAll(); 212108f92bdStracker-user } 213108f92bdStracker-user 214108f92bdStracker-user /** 21543d2073cStracker-user * Re-render everything: highlights, gutter markers, counter. 21643d2073cStracker-user */ 21743d2073cStracker-user function renderAll() { 21843d2073cStracker-user clearHighlights(); 21943d2073cStracker-user clearGutterMarkers(); 22043d2073cStracker-user 22143d2073cStracker-user var content = document.getElementById(CONTENT_ID); 22243d2073cStracker-user if (!content) return; 22343d2073cStracker-user 224da56206cStracker-user // Snapshot the page text ONCE, before any highlight is inserted. 225da56206cStracker-user // Re-collecting per annotation would exclude already-wrapped text 226da56206cStracker-user // (collectTextChunks skips our own UI), shifting every later anchor. 227da56206cStracker-user var chunks = collectTextChunks(content); 228da56206cStracker-user var rawFull = chunks.map(function (c) { return c.text; }).join(''); 229da56206cStracker-user var nm = normalizeWithMap(rawFull); 23043d2073cStracker-user 231da56206cStracker-user // Phase 1 — locate every annotation against the clean snapshot. 232da56206cStracker-user var hits = []; 23343d2073cStracker-user _annotations.forEach(function (ann) { 234da56206cStracker-user ann._range = null; 235da56206cStracker-user ann._highlightEl = null; 236da56206cStracker-user var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null; 237da56206cStracker-user if (hit) { 238da56206cStracker-user hits.push({ann: ann, pos: hit.pos, len: hit.len}); 239da56206cStracker-user ann._orphaned = false; 24043d2073cStracker-user } else { 241da56206cStracker-user ann._orphaned = true; 242da56206cStracker-user } 243da56206cStracker-user }); 244da56206cStracker-user 245da56206cStracker-user // Phase 2 — wrap later matches first, so wrapping (which splits text 246da56206cStracker-user // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones. 247da56206cStracker-user hits.sort(function (a, b) { return b.pos - a.pos; }); 248da56206cStracker-user hits.forEach(function (h) { 249da56206cStracker-user var range = buildRange(chunks, nm.map, h.pos, h.len); 250da56206cStracker-user if (range) { 251da56206cStracker-user h.ann._range = range; // cache for panel positioning 252da56206cStracker-user wrapHighlight(range, h.ann); 253da56206cStracker-user } else { 254da56206cStracker-user h.ann._orphaned = true; 25543d2073cStracker-user } 25643d2073cStracker-user }); 25743d2073cStracker-user 25843d2073cStracker-user renderGutterMarkers(); 259da56206cStracker-user updateCounter(); // recounts orphans from the _orphaned flags set above 26043d2073cStracker-user } 26143d2073cStracker-user 26243d2073cStracker-user // ----------------------------------------------------------------------- 26343d2073cStracker-user // Text anchoring (re-anchoring) 26443d2073cStracker-user // ----------------------------------------------------------------------- 26543d2073cStracker-user 26643d2073cStracker-user /** 267da56206cStracker-user * Locate an anchor's quoted text within the normalised page text. 26843d2073cStracker-user * 26943d2073cStracker-user * Algorithm: 270da56206cStracker-user * 1. Search for the exact quote (normalised). 271da56206cStracker-user * 2. If found multiple times, use prefix/suffix to disambiguate. 272da56206cStracker-user * 3. If still ambiguous, use the start offset hint. 27343d2073cStracker-user * 274da56206cStracker-user * Returns offsets into the normalised string; buildRange maps them back 275da56206cStracker-user * to a DOM Range via the normalised→raw index map. 276da56206cStracker-user * 277da56206cStracker-user * @param {string} norm normalised page text (from normalizeWithMap) 27843d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 279da56206cStracker-user * @returns {{pos:number, len:number}|null} 28043d2073cStracker-user */ 281da56206cStracker-user function locate(norm, anchor) { 28243d2073cStracker-user if (!anchor || !anchor.exact) return null; 28343d2073cStracker-user 28443d2073cStracker-user var exact = normalizeWS(anchor.exact); 285da56206cStracker-user if (exact === '') return null; 28643d2073cStracker-user var prefix = normalizeWS(anchor.prefix || ''); 28743d2073cStracker-user var suffix = normalizeWS(anchor.suffix || ''); 28843d2073cStracker-user var hint = anchor.start || 0; 28943d2073cStracker-user 29043d2073cStracker-user // Find all occurrences of exact. 29143d2073cStracker-user var positions = []; 292da56206cStracker-user var from = 0; 29343d2073cStracker-user var idx; 294da56206cStracker-user while ((idx = norm.indexOf(exact, from)) !== -1) { 295da56206cStracker-user positions.push(idx); 296da56206cStracker-user from = idx + exact.length; 29743d2073cStracker-user } 29843d2073cStracker-user 29943d2073cStracker-user if (positions.length === 0) return null; 30043d2073cStracker-user 301da56206cStracker-user var chosen = positions[0]; 30243d2073cStracker-user 30343d2073cStracker-user if (positions.length > 1) { 304da56206cStracker-user // Disambiguate using prefix + suffix context, tie-break on the hint. 30543d2073cStracker-user var bestScore = -1; 30643d2073cStracker-user positions.forEach(function (pos) { 307da56206cStracker-user var pre = norm.slice(Math.max(0, pos - prefix.length), pos); 308da56206cStracker-user var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length); 30943d2073cStracker-user var score = 0; 31043d2073cStracker-user if (prefix && pre.indexOf(prefix) !== -1) score++; 31143d2073cStracker-user if (suffix && suf.indexOf(suffix) !== -1) score++; 31243d2073cStracker-user var distToHint = Math.abs(pos - hint); 313da56206cStracker-user if (score > bestScore || 314da56206cStracker-user (score === bestScore && distToHint < Math.abs(chosen - hint))) { 31543d2073cStracker-user bestScore = score; 316da56206cStracker-user chosen = pos; 31743d2073cStracker-user } 31843d2073cStracker-user }); 31943d2073cStracker-user } 32043d2073cStracker-user 321da56206cStracker-user return {pos: chosen, len: exact.length}; 32243d2073cStracker-user } 32343d2073cStracker-user 32443d2073cStracker-user /** 32543d2073cStracker-user * Walk the text nodes under root and return an array of 32643d2073cStracker-user * {node, start, text} objects where start is the cumulative character 32743d2073cStracker-user * offset of this node's text in the joined string. 32843d2073cStracker-user * 32943d2073cStracker-user * The joined string is NOT normalised here — we normalise the full string 33043d2073cStracker-user * once above instead. 33143d2073cStracker-user * 33243d2073cStracker-user * @param {HTMLElement} root 33343d2073cStracker-user * @returns {Array<{node:Text, start:number, text:string}>} 33443d2073cStracker-user */ 33543d2073cStracker-user function collectTextChunks(root) { 33643d2073cStracker-user var walker = document.createTreeWalker( 33743d2073cStracker-user root, 33843d2073cStracker-user NodeFilter.SHOW_TEXT, 33943d2073cStracker-user null, 34043d2073cStracker-user false 34143d2073cStracker-user ); 34243d2073cStracker-user var chunks = []; 34343d2073cStracker-user var offset = 0; 34443d2073cStracker-user var node; 34543d2073cStracker-user while ((node = walker.nextNode())) { 34643d2073cStracker-user // Skip nodes inside our own UI elements. 34743d2073cStracker-user if (isAnnotationUI(node.parentNode)) continue; 34843d2073cStracker-user var text = node.nodeValue || ''; 34943d2073cStracker-user chunks.push({node: node, start: offset, text: text}); 35043d2073cStracker-user offset += text.length; 35143d2073cStracker-user } 35243d2073cStracker-user return chunks; 35343d2073cStracker-user } 35443d2073cStracker-user 35543d2073cStracker-user /** 35686c7806dStracker-user * The first existing highlight span the given range overlaps, or null. 35786c7806dStracker-user * Used to redirect a selection that touches an annotation into opening it, 35886c7806dStracker-user * rather than offering to create a new (overlapping) one. intersectsNode is 35986c7806dStracker-user * supported in Firefox 78 ESR. 36086c7806dStracker-user * 36186c7806dStracker-user * @param {Range} range 36286c7806dStracker-user * @returns {HTMLElement|null} 36386c7806dStracker-user */ 36486c7806dStracker-user function selectionHitsHighlight(range) { 36586c7806dStracker-user var spans = document.querySelectorAll( 36686c7806dStracker-user '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 36786c7806dStracker-user ); 36886c7806dStracker-user for (var i = 0; i < spans.length; i++) { 36986c7806dStracker-user if (range.intersectsNode(spans[i])) { 37086c7806dStracker-user return spans[i]; 37186c7806dStracker-user } 37286c7806dStracker-user } 37386c7806dStracker-user return null; 37486c7806dStracker-user } 37586c7806dStracker-user 37686c7806dStracker-user /** 37786c7806dStracker-user * True if the node sits in a region that must never receive a new 37886c7806dStracker-user * annotation: our own annotation UI (panels, counter bar, tooltip, 37986c7806dStracker-user * highlights), the table of contents (#dw__toc), the page-info line 38086c7806dStracker-user * (.docInfo), or a section-edit button (.secedit). These all live inside 38186c7806dStracker-user * #dokuwiki__content, so plain containment is not enough to gate selection. 382563f3b4cStracker-user * 383563f3b4cStracker-user * @param {Node} node 384563f3b4cStracker-user * @returns {bool} 385563f3b4cStracker-user */ 38686c7806dStracker-user function isInExcludedRegion(node) { 387563f3b4cStracker-user var el = (node && node.nodeType === 1) ? node : (node ? node.parentNode : null); 388563f3b4cStracker-user while (el && el !== document.body) { 38986c7806dStracker-user if (el.nodeType === 1) { 39086c7806dStracker-user var cls = el.className; 39186c7806dStracker-user if (typeof cls === 'string') { 39286c7806dStracker-user if (cls.indexOf('ann-') !== -1 || // our own UI + highlights 39386c7806dStracker-user cls.indexOf('docInfo') !== -1 || 39486c7806dStracker-user cls.indexOf('secedit') !== -1) { 395563f3b4cStracker-user return true; 396563f3b4cStracker-user } 39786c7806dStracker-user } 39886c7806dStracker-user if (el.id === 'dw__toc') return true; 39986c7806dStracker-user } 400563f3b4cStracker-user el = el.parentNode; 401563f3b4cStracker-user } 402563f3b4cStracker-user return false; 403563f3b4cStracker-user } 404563f3b4cStracker-user 405563f3b4cStracker-user /** 40643d2073cStracker-user * True if the element (or its ancestor) is part of our annotation UI. 40743d2073cStracker-user * 40843d2073cStracker-user * @param {Node} el 40943d2073cStracker-user * @returns {bool} 41043d2073cStracker-user */ 41143d2073cStracker-user function isAnnotationUI(el) { 41243d2073cStracker-user while (el && el !== document.body) { 41343d2073cStracker-user if (el.nodeType === 1) { 41443d2073cStracker-user var cls = el.className || ''; 41543d2073cStracker-user if ( 41643d2073cStracker-user cls.indexOf('ann-') !== -1 || 41743d2073cStracker-user cls.indexOf(CLS_PANEL) !== -1 41843d2073cStracker-user ) { 41943d2073cStracker-user return true; 42043d2073cStracker-user } 42143d2073cStracker-user } 42243d2073cStracker-user el = el.parentNode; 42343d2073cStracker-user } 42443d2073cStracker-user return false; 42543d2073cStracker-user } 42643d2073cStracker-user 42743d2073cStracker-user /** 428da56206cStracker-user * Turn a (start, length) offset in the normalised page text back into a 429da56206cStracker-user * DOM Range, using the normalised→raw index map. 43043d2073cStracker-user * 43143d2073cStracker-user * @param {Array<{node:Text, start:number, text:string}>} chunks 432da56206cStracker-user * @param {Array<number>} map normalised index → raw index (normalizeWithMap) 433da56206cStracker-user * @param {number} startOff start offset in the normalised text 434da56206cStracker-user * @param {number} length length in normalised characters 43543d2073cStracker-user * @returns {Range|null} 43643d2073cStracker-user */ 437da56206cStracker-user function buildRange(chunks, map, startOff, length) { 438da56206cStracker-user var rawStart = map[startOff]; 439da56206cStracker-user var rawEnd = map[startOff + length - 1]; 44043d2073cStracker-user if (rawStart === undefined || rawEnd === undefined) return null; 44143d2073cStracker-user rawEnd++; // exclusive 44243d2073cStracker-user 44343d2073cStracker-user // Find which chunks contain rawStart and rawEnd. 44443d2073cStracker-user var startChunk = null, startOffset = 0; 44543d2073cStracker-user var endChunk = null, endOffset = 0; 44643d2073cStracker-user 44743d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 44843d2073cStracker-user var c = chunks[i]; 44943d2073cStracker-user var cEnd = c.start + c.text.length; 45043d2073cStracker-user 45143d2073cStracker-user if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 45243d2073cStracker-user startChunk = c.node; 45343d2073cStracker-user startOffset = rawStart - c.start; 45443d2073cStracker-user } 45543d2073cStracker-user if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 45643d2073cStracker-user endChunk = c.node; 45743d2073cStracker-user endOffset = rawEnd - c.start; 45843d2073cStracker-user } 45943d2073cStracker-user if (startChunk && endChunk) break; 46043d2073cStracker-user } 46143d2073cStracker-user 46243d2073cStracker-user if (!startChunk || !endChunk) return null; 46343d2073cStracker-user 46443d2073cStracker-user try { 46543d2073cStracker-user var range = document.createRange(); 46643d2073cStracker-user range.setStart(startChunk, startOffset); 46743d2073cStracker-user range.setEnd(endChunk, endOffset); 46843d2073cStracker-user return range; 46943d2073cStracker-user } catch (e) { 47043d2073cStracker-user return null; 47143d2073cStracker-user } 47243d2073cStracker-user } 47343d2073cStracker-user 47443d2073cStracker-user /** 475da56206cStracker-user * Normalise raw text exactly as normalizeWS does (collapse each whitespace 476da56206cStracker-user * run to a single space, trim both ends) while recording, for every 477da56206cStracker-user * character of the normalised string, the index of the raw character it 478da56206cStracker-user * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i) 479da56206cStracker-user * (a collapsed internal space maps to the first char of its run). 480da56206cStracker-user * 481da56206cStracker-user * Normalisation and the index map MUST stay in lockstep: an earlier 482da56206cStracker-user * version built the map without trimming, so a leading whitespace text 483da56206cStracker-user * node (DokuWiki indents its content markup, so there always is one) 484da56206cStracker-user * shifted every highlight one character to the left. 48543d2073cStracker-user * 48643d2073cStracker-user * @param {string} raw 487da56206cStracker-user * @returns {{norm:string, map:Array<number>}} 48843d2073cStracker-user */ 489da56206cStracker-user function normalizeWithMap(raw) { 490da56206cStracker-user var norm = ''; 49143d2073cStracker-user var map = []; 492da56206cStracker-user var inRun = false; 493da56206cStracker-user var runStart = 0; 49443d2073cStracker-user for (var i = 0; i < raw.length; i++) { 495da56206cStracker-user if (/\s/.test(raw[i])) { 496da56206cStracker-user if (!inRun) { inRun = true; runStart = i; } 497da56206cStracker-user continue; 49843d2073cStracker-user } 499da56206cStracker-user if (inRun) { 500da56206cStracker-user inRun = false; 501da56206cStracker-user // internal run → one representative space; leading run → dropped 502da56206cStracker-user if (norm.length > 0) { 503da56206cStracker-user norm += ' '; 504da56206cStracker-user map.push(runStart); 505da56206cStracker-user } 506da56206cStracker-user } 507da56206cStracker-user norm += raw[i]; 50843d2073cStracker-user map.push(i); 50943d2073cStracker-user } 510da56206cStracker-user // a trailing whitespace run is dropped (matches trim) 511da56206cStracker-user return {norm: norm, map: map}; 51243d2073cStracker-user } 51343d2073cStracker-user 51443d2073cStracker-user // ----------------------------------------------------------------------- 51543d2073cStracker-user // Highlights 51643d2073cStracker-user // ----------------------------------------------------------------------- 51743d2073cStracker-user 51843d2073cStracker-user /** 51943d2073cStracker-user * Wrap a Range in a highlight <span> for the given annotation. 52043d2073cStracker-user * 52143d2073cStracker-user * @param {Range} range 52243d2073cStracker-user * @param {object} ann 52343d2073cStracker-user */ 52443d2073cStracker-user function wrapHighlight(range, ann) { 525563f3b4cStracker-user var preview = ann.body || ''; 52643d2073cStracker-user var span = document.createElement('span'); 52743d2073cStracker-user span.className = ann.status === 'resolved' 52843d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 52943d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 53043d2073cStracker-user span.dataset.annId = ann.id; 531563f3b4cStracker-user span.title = preview.slice(0, 80) + (preview.length > 80 ? '…' : ''); 53243d2073cStracker-user span.addEventListener('click', function (e) { 53343d2073cStracker-user e.stopPropagation(); 53443d2073cStracker-user openPanel(ann.id); 53543d2073cStracker-user }); 536563f3b4cStracker-user 537563f3b4cStracker-user try { 53843d2073cStracker-user range.surroundContents(span); 53943d2073cStracker-user ann._highlightEl = span; 54043d2073cStracker-user } catch (e) { 541563f3b4cStracker-user // surroundContents throws if the range crosses element boundaries; 542563f3b4cStracker-user // fall back to extract + insert, reusing the same (still-empty) span. 54343d2073cStracker-user try { 544563f3b4cStracker-user span.appendChild(range.extractContents()); 545563f3b4cStracker-user range.insertNode(span); 546563f3b4cStracker-user ann._highlightEl = span; 54743d2073cStracker-user } catch (e2) { 54843d2073cStracker-user ann._highlightEl = null; 54943d2073cStracker-user } 55043d2073cStracker-user } 55143d2073cStracker-user } 55243d2073cStracker-user 55343d2073cStracker-user /** 55443d2073cStracker-user * Remove all highlight spans, restoring the original text nodes. 55543d2073cStracker-user */ 55643d2073cStracker-user function clearHighlights() { 55743d2073cStracker-user var spans = document.querySelectorAll( 558da56206cStracker-user '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 55943d2073cStracker-user ); 56043d2073cStracker-user Array.prototype.forEach.call(spans, function (span) { 56143d2073cStracker-user var parent = span.parentNode; 56243d2073cStracker-user if (!parent) return; 56343d2073cStracker-user while (span.firstChild) { 56443d2073cStracker-user parent.insertBefore(span.firstChild, span); 56543d2073cStracker-user } 56643d2073cStracker-user parent.removeChild(span); 56743d2073cStracker-user parent.normalize(); 56843d2073cStracker-user }); 56943d2073cStracker-user } 57043d2073cStracker-user 57143d2073cStracker-user // ----------------------------------------------------------------------- 57243d2073cStracker-user // Gutter markers 57343d2073cStracker-user // ----------------------------------------------------------------------- 57443d2073cStracker-user 57543d2073cStracker-user /** 576563f3b4cStracker-user * Render a small marker for every anchored annotation. Markers are 577563f3b4cStracker-user * appended to document.body as absolutely-positioned elements so that 578563f3b4cStracker-user * template overflow rules on inner containers cannot clip them. 579563f3b4cStracker-user * 580563f3b4cStracker-user * All markers share the same X position — just to the left of the .page 581563f3b4cStracker-user * content column — so they form a tidy vertical column in the margin. 58243d2073cStracker-user */ 58343d2073cStracker-user function renderGutterMarkers() { 584563f3b4cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 585563f3b4cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 586563f3b4cStracker-user var markerLeft = gutterMarkerLeft(scrollLeft); 587563f3b4cStracker-user 588563f3b4cStracker-user // Speech bubble SVG — clearly communicates "annotation here". 589563f3b4cStracker-user var ICON_SVG = 590563f3b4cStracker-user '<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" aria-hidden="true">' + 591563f3b4cStracker-user '<rect x="1" y="1" width="14" height="10" rx="2"/>' + 592563f3b4cStracker-user '<path d="M4 14 L4 11 L8 11 Z"/>' + 593563f3b4cStracker-user '</svg>'; 59443d2073cStracker-user 59543d2073cStracker-user _annotations.forEach(function (ann) { 59643d2073cStracker-user if (!ann._highlightEl) return; // orphan 59743d2073cStracker-user 598563f3b4cStracker-user var rect = ann._highlightEl.getBoundingClientRect(); 59943d2073cStracker-user 60043d2073cStracker-user var marker = document.createElement('button'); 60143d2073cStracker-user marker.className = CLS_GUTTER_MARKER; 60243d2073cStracker-user marker.dataset.annId = ann.id; 603563f3b4cStracker-user marker.dataset.status = ann.status || 'open'; // drives CSS amber/green colour 604da56206cStracker-user marker.setAttribute('aria-label', t('label_annotation', 'Annotation')); 60543d2073cStracker-user marker.type = 'button'; 606563f3b4cStracker-user marker.innerHTML = ICON_SVG; 607563f3b4cStracker-user // Align vertically with the first line of the highlight. 608563f3b4cStracker-user marker.style.top = (rect.top + scrollTop + 3) + 'px'; 609563f3b4cStracker-user marker.style.left = markerLeft + 'px'; 61043d2073cStracker-user marker.addEventListener('click', function (e) { 61143d2073cStracker-user e.stopPropagation(); 61243d2073cStracker-user openPanel(ann.id); 61343d2073cStracker-user }); 614563f3b4cStracker-user document.body.appendChild(marker); 61543d2073cStracker-user ann._markerEl = marker; 61643d2073cStracker-user }); 61743d2073cStracker-user } 61843d2073cStracker-user 61943d2073cStracker-user /** 62043d2073cStracker-user * Remove all gutter markers. 62143d2073cStracker-user */ 62243d2073cStracker-user function clearGutterMarkers() { 62343d2073cStracker-user var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 62443d2073cStracker-user Array.prototype.forEach.call(markers, function (m) { 62543d2073cStracker-user if (m.parentNode) m.parentNode.removeChild(m); 62643d2073cStracker-user }); 62743d2073cStracker-user } 62843d2073cStracker-user 629563f3b4cStracker-user /** 630563f3b4cStracker-user * The shared X position (document coordinates) for every gutter marker: 631563f3b4cStracker-user * just inside the left padding of the .page content column, so the markers 632563f3b4cStracker-user * form a tidy vertical strip in the margin. Falls back to 4px when the 633563f3b4cStracker-user * column cannot be measured. Reads the theme's computed padding so it 634563f3b4cStracker-user * adapts to the template. 635563f3b4cStracker-user * 636563f3b4cStracker-user * @param {number} scrollLeft current horizontal scroll offset 637563f3b4cStracker-user * @returns {number} 638563f3b4cStracker-user */ 639563f3b4cStracker-user function gutterMarkerLeft(scrollLeft) { 640563f3b4cStracker-user var pageEl = document.querySelector('.' + PAGE_CLS) || document.getElementById(CONTENT_ID); 641563f3b4cStracker-user if (!pageEl) return 4; 642563f3b4cStracker-user var pageRect = pageEl.getBoundingClientRect(); 643563f3b4cStracker-user var padLeft = parseInt(window.getComputedStyle(pageEl).paddingLeft, 10) || 32; 644563f3b4cStracker-user return pageRect.left + scrollLeft + Math.max(2, Math.floor(padLeft * 0.25)); 645563f3b4cStracker-user } 646563f3b4cStracker-user 647563f3b4cStracker-user /** 648563f3b4cStracker-user * Re-align every existing marker with its highlight without rebuilding the 649563f3b4cStracker-user * DOM. Highlights shift when a panel is inserted/removed or the window is 650563f3b4cStracker-user * resized, but markers live in document.body at absolute coordinates, so 651563f3b4cStracker-user * they would otherwise drift out of line. Cheap — only touches inline 652563f3b4cStracker-user * top/left on the handful of markers present. 653563f3b4cStracker-user */ 654563f3b4cStracker-user function repositionMarkers() { 655563f3b4cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 656563f3b4cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 657563f3b4cStracker-user var markerLeft = gutterMarkerLeft(scrollLeft); 658563f3b4cStracker-user _annotations.forEach(function (ann) { 659563f3b4cStracker-user if (!ann._markerEl || !ann._highlightEl) return; 660563f3b4cStracker-user var rect = ann._highlightEl.getBoundingClientRect(); 661563f3b4cStracker-user ann._markerEl.style.top = (rect.top + scrollTop + 3) + 'px'; 662563f3b4cStracker-user ann._markerEl.style.left = markerLeft + 'px'; 663563f3b4cStracker-user }); 664563f3b4cStracker-user } 665563f3b4cStracker-user 66643d2073cStracker-user // ----------------------------------------------------------------------- 66743d2073cStracker-user // Page counter 66843d2073cStracker-user // ----------------------------------------------------------------------- 66943d2073cStracker-user 67043d2073cStracker-user /** 67143d2073cStracker-user * Render (or update) the counter bubble above the content area. 67243d2073cStracker-user * 67343d2073cStracker-user * @param {object} stats {total, open, resolved} 67443d2073cStracker-user * @param {number} orphanCount 67543d2073cStracker-user */ 67643d2073cStracker-user function renderCounter(stats, orphanCount) { 67743d2073cStracker-user var existing = document.getElementById('ann-counter-bar'); 67843d2073cStracker-user if (existing) existing.parentNode.removeChild(existing); 67943d2073cStracker-user 68043d2073cStracker-user if (stats.total === 0 && orphanCount === 0) return; 68143d2073cStracker-user 68243d2073cStracker-user var bar = document.createElement('div'); 68343d2073cStracker-user bar.id = 'ann-counter-bar'; 68443d2073cStracker-user bar.className = CLS_COUNTER; 68543d2073cStracker-user 68643d2073cStracker-user var total = stats.total || 0; 68743d2073cStracker-user var label = total === 1 688da56206cStracker-user ? t('counter_annotation', '1 annotation') 689da56206cStracker-user : fmt(t('counter_annotations', '%d annotations'), total); 69043d2073cStracker-user bar.appendChild(document.createTextNode(label)); 69143d2073cStracker-user 69243d2073cStracker-user if (orphanCount > 0) { 69343d2073cStracker-user bar.appendChild(document.createTextNode(' · ')); 69443d2073cStracker-user var orphanLink = document.createElement('a'); 69543d2073cStracker-user orphanLink.href = '#ann-orphan-drawer'; 69643d2073cStracker-user orphanLink.className = 'ann-orphan-link'; 697da56206cStracker-user orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount); 69843d2073cStracker-user orphanLink.addEventListener('click', function (e) { 69943d2073cStracker-user e.preventDefault(); 70043d2073cStracker-user toggleOrphanDrawer(); 701563f3b4cStracker-user repositionMarkers(); 70243d2073cStracker-user }); 70343d2073cStracker-user bar.appendChild(orphanLink); 70443d2073cStracker-user } 70543d2073cStracker-user 70643d2073cStracker-user if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 70743d2073cStracker-user if (stats.resolved > 0) { 70843d2073cStracker-user var btnCR = document.createElement('button'); 70943d2073cStracker-user btnCR.type = 'button'; 71043d2073cStracker-user btnCR.className = 'ann-btn ann-btn-admin'; 711da56206cStracker-user btnCR.textContent = t('btn_clear_resolved', 'Clear resolved'); 712d6f8bd9dStracker-user btnCR.addEventListener('click', function () { doClearResolved(btnCR); }); 71343d2073cStracker-user bar.appendChild(btnCR); 71443d2073cStracker-user } 71543d2073cStracker-user if (orphanCount > 0) { 71643d2073cStracker-user var btnCO = document.createElement('button'); 71743d2073cStracker-user btnCO.type = 'button'; 71843d2073cStracker-user btnCO.className = 'ann-btn ann-btn-admin'; 719da56206cStracker-user btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned'); 720d6f8bd9dStracker-user btnCO.addEventListener('click', function () { doClearOrphaned(btnCO); }); 72143d2073cStracker-user bar.appendChild(btnCO); 72243d2073cStracker-user } 72343d2073cStracker-user } 72443d2073cStracker-user 725563f3b4cStracker-user // Insert inside .page, right after #dw__toc if present. 726563f3b4cStracker-user // The TOC is float:right so placing the bar after it (not before) lets 727563f3b4cStracker-user // it sit to the left of the float instead of pushing the TOC down. 728563f3b4cStracker-user var pageEl = document.querySelector('.' + PAGE_CLS); 729563f3b4cStracker-user if (pageEl) { 730563f3b4cStracker-user var toc = pageEl.querySelector('#dw__toc'); 731563f3b4cStracker-user if (toc && toc.nextSibling) { 732563f3b4cStracker-user pageEl.insertBefore(bar, toc.nextSibling); 733563f3b4cStracker-user } else if (toc) { 734563f3b4cStracker-user pageEl.appendChild(bar); 735563f3b4cStracker-user } else { 736563f3b4cStracker-user pageEl.insertBefore(bar, pageEl.firstChild); 737563f3b4cStracker-user } 738563f3b4cStracker-user } else { 73943d2073cStracker-user var content = document.getElementById(CONTENT_ID); 740563f3b4cStracker-user if (content) content.insertBefore(bar, content.firstChild); 74143d2073cStracker-user } 74243d2073cStracker-user } 74343d2073cStracker-user 74443d2073cStracker-user /** 74543d2073cStracker-user * Recount and re-render the counter from in-memory state. 74643d2073cStracker-user */ 74743d2073cStracker-user function updateCounter(orphanCount) { 74843d2073cStracker-user var open = 0, resolved = 0; 74943d2073cStracker-user if (orphanCount === undefined) { 75043d2073cStracker-user orphanCount = 0; 75143d2073cStracker-user } 75243d2073cStracker-user _annotations.forEach(function (ann) { 75343d2073cStracker-user if (ann._orphaned) { 75443d2073cStracker-user orphanCount++; 75543d2073cStracker-user } else if (ann.status === 'resolved') { 75643d2073cStracker-user resolved++; 75743d2073cStracker-user } else { 75843d2073cStracker-user open++; 75943d2073cStracker-user } 76043d2073cStracker-user }); 76143d2073cStracker-user renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 76243d2073cStracker-user } 76343d2073cStracker-user 76443d2073cStracker-user // ----------------------------------------------------------------------- 76543d2073cStracker-user // Annotation panel 76643d2073cStracker-user // ----------------------------------------------------------------------- 76743d2073cStracker-user 76843d2073cStracker-user /** 76943d2073cStracker-user * Open the thread panel for the given annotation id. 77043d2073cStracker-user * If that panel is already open, close it. 77143d2073cStracker-user * 77243d2073cStracker-user * @param {string} annId 773563f3b4cStracker-user * @param {boolean} [focusReply] focus the reply box once open (default true); 774563f3b4cStracker-user * reopenPanel passes false so re-rendering after 775563f3b4cStracker-user * an action doesn't yank the viewport to the form. 77643d2073cStracker-user */ 777563f3b4cStracker-user function openPanel(annId, focusReply) { 77843d2073cStracker-user if (_openAnnId === annId) { 77943d2073cStracker-user closePanel(); 78043d2073cStracker-user return; 78143d2073cStracker-user } 78243d2073cStracker-user closePanel(); 78343d2073cStracker-user 78443d2073cStracker-user var ann = _annotations.get(annId); 78543d2073cStracker-user if (!ann) return; 78643d2073cStracker-user 78743d2073cStracker-user var panel = buildPanel(ann); 78843d2073cStracker-user _openPanel = panel; 78943d2073cStracker-user _openAnnId = annId; 79043d2073cStracker-user 79143d2073cStracker-user // Insert below the paragraph that contains the highlight. 79243d2073cStracker-user var anchor = ann._highlightEl || null; 79343d2073cStracker-user var insertAfter = findParagraph(anchor); 79443d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 79543d2073cStracker-user insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 79643d2073cStracker-user } else { 79743d2073cStracker-user // Orphan or no paragraph found: show at the bottom of content. 79843d2073cStracker-user var content = document.getElementById(CONTENT_ID); 79943d2073cStracker-user if (content) content.appendChild(panel); 80043d2073cStracker-user } 80143d2073cStracker-user 802563f3b4cStracker-user if (focusReply !== false) { 803563f3b4cStracker-user var input = panel.querySelector('.ann-body-input'); 804563f3b4cStracker-user if (input) input.focus(); 805563f3b4cStracker-user } 806563f3b4cStracker-user 807563f3b4cStracker-user // The panel grew the document; nudge markers below it back into line. 808563f3b4cStracker-user repositionMarkers(); 80943d2073cStracker-user } 81043d2073cStracker-user 81143d2073cStracker-user /** 81243d2073cStracker-user * Close and remove the currently open panel. 81343d2073cStracker-user */ 81443d2073cStracker-user function closePanel() { 81543d2073cStracker-user if (_openPanel && _openPanel.parentNode) { 81643d2073cStracker-user _openPanel.parentNode.removeChild(_openPanel); 81743d2073cStracker-user } 81843d2073cStracker-user _openPanel = null; 81943d2073cStracker-user _openAnnId = null; 820563f3b4cStracker-user repositionMarkers(); 82143d2073cStracker-user } 82243d2073cStracker-user 82343d2073cStracker-user /** 82443d2073cStracker-user * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 82543d2073cStracker-user * that can receive a sibling element. 82643d2073cStracker-user * 82743d2073cStracker-user * @param {HTMLElement|null} el 82843d2073cStracker-user * @returns {HTMLElement|null} 82943d2073cStracker-user */ 83043d2073cStracker-user function findParagraph(el) { 83143d2073cStracker-user var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 83243d2073cStracker-user var node = el; 83343d2073cStracker-user while (node && node.id !== CONTENT_ID) { 83443d2073cStracker-user if (node.nodeType === 1 && block.test(node.tagName)) { 83543d2073cStracker-user return node; 83643d2073cStracker-user } 83743d2073cStracker-user node = node.parentNode; 83843d2073cStracker-user } 83943d2073cStracker-user return el; // fallback: use the element itself 84043d2073cStracker-user } 84143d2073cStracker-user 84243d2073cStracker-user /** 84343d2073cStracker-user * Build and return the panel DOM element for one annotation. 84443d2073cStracker-user * 84543d2073cStracker-user * @param {object} ann 84643d2073cStracker-user * @returns {HTMLElement} 84743d2073cStracker-user */ 84843d2073cStracker-user function buildPanel(ann) { 84943d2073cStracker-user var panel = document.createElement('div'); 85043d2073cStracker-user panel.className = CLS_PANEL; 85143d2073cStracker-user panel.dataset.annId = ann.id; 852da56206cStracker-user panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css 85343d2073cStracker-user 854563f3b4cStracker-user // Main annotation thread entry (close button lives in its meta row). 855563f3b4cStracker-user var rootEntry = buildThreadEntry(ann, true); 856563f3b4cStracker-user var meta = rootEntry.querySelector('.ann-meta'); 857563f3b4cStracker-user if (meta) { 85843d2073cStracker-user var closeBtn = document.createElement('button'); 85943d2073cStracker-user closeBtn.type = 'button'; 86043d2073cStracker-user closeBtn.className = 'ann-btn ann-close'; 861da56206cStracker-user closeBtn.setAttribute('aria-label', t('label_close', 'Close')); 862563f3b4cStracker-user closeBtn.textContent = '×'; // × 863563f3b4cStracker-user closeBtn.style.marginLeft = 'auto'; 86443d2073cStracker-user closeBtn.addEventListener('click', closePanel); 865563f3b4cStracker-user meta.appendChild(closeBtn); 866563f3b4cStracker-user } 867563f3b4cStracker-user panel.appendChild(rootEntry); 86843d2073cStracker-user 869563f3b4cStracker-user // Replies: build hierarchy from flat list and render depth-indented. 870563f3b4cStracker-user appendReplyTree(panel, ann, buildReplyTree(ann.replies || []), 0); 87143d2073cStracker-user 872563f3b4cStracker-user // Reply form at the bottom for root-level replies. 87343d2073cStracker-user if (_loggedIn) { 87443d2073cStracker-user panel.appendChild(buildReplyForm(ann)); 87543d2073cStracker-user } 87643d2073cStracker-user 87743d2073cStracker-user return panel; 87843d2073cStracker-user } 87943d2073cStracker-user 88043d2073cStracker-user /** 88143d2073cStracker-user * Build the DOM for the top-level annotation entry. 88243d2073cStracker-user * 88343d2073cStracker-user * @param {object} ann 88443d2073cStracker-user * @param {boolean} isRoot true for the annotation itself, false for replies 88543d2073cStracker-user * @returns {HTMLElement} 88643d2073cStracker-user */ 88743d2073cStracker-user function buildThreadEntry(ann, isRoot) { 88843d2073cStracker-user var entry = document.createElement('div'); 88943d2073cStracker-user entry.className = 'ann-thread-entry ann-annotation'; 89043d2073cStracker-user entry.dataset.annId = ann.id; 89143d2073cStracker-user 89243d2073cStracker-user // Meta row: avatar, author, time, status pill 89343d2073cStracker-user entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 89443d2073cStracker-user 89543d2073cStracker-user // Body 89643d2073cStracker-user var bodyEl = document.createElement('div'); 89743d2073cStracker-user bodyEl.className = 'ann-body'; 89843d2073cStracker-user bodyEl.textContent = ann.body; 89943d2073cStracker-user entry.appendChild(bodyEl); 90043d2073cStracker-user 90143d2073cStracker-user // Quoted text snippet 90243d2073cStracker-user if (ann.anchor && ann.anchor.exact) { 90343d2073cStracker-user var quote = document.createElement('blockquote'); 90443d2073cStracker-user quote.className = 'ann-quote'; 90543d2073cStracker-user quote.textContent = ann.anchor.exact; 90643d2073cStracker-user entry.appendChild(quote); 90743d2073cStracker-user } 90843d2073cStracker-user 90943d2073cStracker-user // Action buttons 91043d2073cStracker-user var actions = document.createElement('div'); 91143d2073cStracker-user actions.className = 'ann-actions'; 91243d2073cStracker-user 91372d60f2dStracker-user // An orphaned annotation is read-only: its quoted text is gone from the 91472d60f2dStracker-user // page, so resolving/reopening and editing the body no longer make sense. 91572d60f2dStracker-user // It keeps only the Delete button (for its author or an admin), so the 91672d60f2dStracker-user // only remaining action is to remove it. 91772d60f2dStracker-user var isOrphan = !!ann._orphaned; 91872d60f2dStracker-user 91972d60f2dStracker-user // Resolve/Reopen (any reader) — not for orphans. 92072d60f2dStracker-user if (_loggedIn && !isOrphan) { 92143d2073cStracker-user var resolveBtn = document.createElement('button'); 92243d2073cStracker-user resolveBtn.type = 'button'; 923563f3b4cStracker-user resolveBtn.className = 'ann-btn ann-btn-primary'; 924da56206cStracker-user resolveBtn.textContent = ann.status === 'resolved' 925da56206cStracker-user ? t('btn_reopen', 'Reopen') 926da56206cStracker-user : t('btn_resolve', 'Resolve'); 92743d2073cStracker-user resolveBtn.addEventListener('click', function () { 928563f3b4cStracker-user doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved', resolveBtn); 92943d2073cStracker-user }); 93043d2073cStracker-user actions.appendChild(resolveBtn); 93143d2073cStracker-user } 93243d2073cStracker-user 93372d60f2dStracker-user // Edit (own or admin) — not for orphans; Delete stays available. 93443d2073cStracker-user var canEdit = _isAdmin || ann.author === currentUser(); 93543d2073cStracker-user if (canEdit && _loggedIn) { 93672d60f2dStracker-user if (!isOrphan) { 93743d2073cStracker-user var editBtn = document.createElement('button'); 93843d2073cStracker-user editBtn.type = 'button'; 93943d2073cStracker-user editBtn.className = 'ann-btn'; 940da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 94143d2073cStracker-user editBtn.addEventListener('click', function () { 94243d2073cStracker-user showEditForm(entry, ann, 'annotation'); 94343d2073cStracker-user }); 94443d2073cStracker-user actions.appendChild(editBtn); 94572d60f2dStracker-user } 94643d2073cStracker-user 94743d2073cStracker-user var delBtn = document.createElement('button'); 94843d2073cStracker-user delBtn.type = 'button'; 94943d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 950da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 95143d2073cStracker-user delBtn.addEventListener('click', function () { 952da56206cStracker-user if (confirm(t('confirm_delete', 'Delete this annotation?'))) { 953563f3b4cStracker-user doDeleteAnnotation(ann.id, delBtn); 95443d2073cStracker-user } 95543d2073cStracker-user }); 95643d2073cStracker-user actions.appendChild(delBtn); 95743d2073cStracker-user } 95843d2073cStracker-user 95943d2073cStracker-user entry.appendChild(actions); 96043d2073cStracker-user return entry; 96143d2073cStracker-user } 96243d2073cStracker-user 96343d2073cStracker-user /** 964563f3b4cStracker-user * Build the DOM for one reply entry, indented according to its nesting depth. 96543d2073cStracker-user * 96643d2073cStracker-user * @param {object} ann parent annotation 96743d2073cStracker-user * @param {object} reply 968563f3b4cStracker-user * @param {number} depth 0 = direct reply to annotation; 1+ = nested 969*ad1073d4Stracker-user * @param {boolean} [readOnly] omit the action buttons (reply/edit/delete); 970*ad1073d4Stracker-user * used by the orphan drawer, where the thread is 971*ad1073d4Stracker-user * shown for reading only 97243d2073cStracker-user * @returns {HTMLElement} 97343d2073cStracker-user */ 974*ad1073d4Stracker-user function buildReplyEntry(ann, reply, depth, readOnly) { 97543d2073cStracker-user var entry = document.createElement('div'); 97643d2073cStracker-user entry.className = 'ann-thread-entry ann-reply'; 97743d2073cStracker-user entry.dataset.replyId = reply.id; 978563f3b4cStracker-user // Indent nested replies up to 4 levels (1.5 em each). 979563f3b4cStracker-user var indent = Math.min(depth, 4) * 1.5 + 1.5; 980563f3b4cStracker-user if (indent > 0) { 981563f3b4cStracker-user entry.style.marginLeft = indent + 'em'; 982563f3b4cStracker-user } 98343d2073cStracker-user 98443d2073cStracker-user entry.appendChild(buildMeta(reply.author, reply.created, null)); 98543d2073cStracker-user 98643d2073cStracker-user var bodyEl = document.createElement('div'); 98743d2073cStracker-user bodyEl.className = 'ann-body'; 98843d2073cStracker-user bodyEl.textContent = reply.body; 98943d2073cStracker-user entry.appendChild(bodyEl); 99043d2073cStracker-user 991*ad1073d4Stracker-user // The orphan drawer renders the thread read-only: no reply, edit, or 992*ad1073d4Stracker-user // delete buttons on replies, mirroring the read-only root entry there. 993*ad1073d4Stracker-user if (readOnly) { 994*ad1073d4Stracker-user return entry; 995*ad1073d4Stracker-user } 996*ad1073d4Stracker-user 99743d2073cStracker-user var actions = document.createElement('div'); 99843d2073cStracker-user actions.className = 'ann-actions'; 99943d2073cStracker-user 1000563f3b4cStracker-user // "Reply to this reply" button for logged-in users. 1001563f3b4cStracker-user if (_loggedIn) { 1002563f3b4cStracker-user var replyToBtn = document.createElement('button'); 1003563f3b4cStracker-user replyToBtn.type = 'button'; 1004563f3b4cStracker-user replyToBtn.className = 'ann-btn ann-btn-primary'; 1005563f3b4cStracker-user replyToBtn.textContent = t('btn_reply', 'Reply'); 1006563f3b4cStracker-user replyToBtn.addEventListener('click', function () { 1007563f3b4cStracker-user // Toggle an inline reply form directly after this entry. 1008563f3b4cStracker-user var next = entry.nextSibling; 1009563f3b4cStracker-user if (next && next.classList && next.classList.contains('ann-inline-reply')) { 1010563f3b4cStracker-user next.parentNode.removeChild(next); 1011563f3b4cStracker-user return; 1012563f3b4cStracker-user } 1013563f3b4cStracker-user var form = buildInlineReplyForm(ann, reply.id, depth + 1); 1014563f3b4cStracker-user entry.parentNode.insertBefore(form, entry.nextSibling); 1015563f3b4cStracker-user var ta = form.querySelector('.ann-body-input'); 1016563f3b4cStracker-user if (ta) ta.focus(); 1017563f3b4cStracker-user }); 1018563f3b4cStracker-user actions.appendChild(replyToBtn); 1019563f3b4cStracker-user } 1020563f3b4cStracker-user 102143d2073cStracker-user var canEdit = _isAdmin || reply.author === currentUser(); 102243d2073cStracker-user if (canEdit && _loggedIn) { 102343d2073cStracker-user var editBtn = document.createElement('button'); 102443d2073cStracker-user editBtn.type = 'button'; 102543d2073cStracker-user editBtn.className = 'ann-btn'; 1026da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 102743d2073cStracker-user editBtn.addEventListener('click', function () { 102843d2073cStracker-user showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 102943d2073cStracker-user }); 103043d2073cStracker-user actions.appendChild(editBtn); 103143d2073cStracker-user 103243d2073cStracker-user var delBtn = document.createElement('button'); 103343d2073cStracker-user delBtn.type = 'button'; 103443d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 1035da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 103643d2073cStracker-user delBtn.addEventListener('click', function () { 1037da56206cStracker-user if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) { 1038563f3b4cStracker-user doDeleteReply(ann.id, reply.id, delBtn); 103943d2073cStracker-user } 104043d2073cStracker-user }); 104143d2073cStracker-user actions.appendChild(delBtn); 104243d2073cStracker-user } 104343d2073cStracker-user 104443d2073cStracker-user entry.appendChild(actions); 104543d2073cStracker-user return entry; 104643d2073cStracker-user } 104743d2073cStracker-user 104843d2073cStracker-user /** 1049563f3b4cStracker-user * Build a nested tree structure from a flat reply list. Replies without a 1050563f3b4cStracker-user * known parentId (including legacy replies with no parentId field) are 1051563f3b4cStracker-user * treated as root-level. 1052563f3b4cStracker-user * 1053563f3b4cStracker-user * @param {Array} replies flat array of reply objects 1054563f3b4cStracker-user * @returns {Array} array of {reply, children} nodes 1055563f3b4cStracker-user */ 1056563f3b4cStracker-user function buildReplyTree(replies) { 1057563f3b4cStracker-user var map = {}; 1058563f3b4cStracker-user var roots = []; 1059563f3b4cStracker-user replies.forEach(function (r) { 1060563f3b4cStracker-user map[r.id] = {reply: r, children: []}; 1061563f3b4cStracker-user }); 1062563f3b4cStracker-user replies.forEach(function (r) { 1063563f3b4cStracker-user var pid = r.parentId || ''; 1064563f3b4cStracker-user if (pid && map[pid]) { 1065563f3b4cStracker-user map[pid].children.push(map[r.id]); 1066563f3b4cStracker-user } else { 1067563f3b4cStracker-user roots.push(map[r.id]); 1068563f3b4cStracker-user } 1069563f3b4cStracker-user }); 1070563f3b4cStracker-user return roots; 1071563f3b4cStracker-user } 1072563f3b4cStracker-user 1073563f3b4cStracker-user /** 1074563f3b4cStracker-user * Recursively append reply entries into the panel. 1075563f3b4cStracker-user * 1076563f3b4cStracker-user * @param {HTMLElement} panel 1077563f3b4cStracker-user * @param {object} ann 1078563f3b4cStracker-user * @param {Array} nodes array of {reply, children} tree nodes 1079563f3b4cStracker-user * @param {number} depth 1080*ad1073d4Stracker-user * @param {boolean} [readOnly] render entries without action buttons 1081563f3b4cStracker-user */ 1082*ad1073d4Stracker-user function appendReplyTree(panel, ann, nodes, depth, readOnly) { 1083563f3b4cStracker-user nodes.forEach(function (node) { 1084*ad1073d4Stracker-user panel.appendChild(buildReplyEntry(ann, node.reply, depth, readOnly)); 1085563f3b4cStracker-user if (node.children.length > 0) { 1086*ad1073d4Stracker-user appendReplyTree(panel, ann, node.children, depth + 1, readOnly); 1087563f3b4cStracker-user } 1088563f3b4cStracker-user }); 1089563f3b4cStracker-user } 1090563f3b4cStracker-user 1091563f3b4cStracker-user /** 1092563f3b4cStracker-user * Build an inline reply form that appears directly below a reply entry. 1093563f3b4cStracker-user * 1094563f3b4cStracker-user * @param {object} ann parent annotation 1095563f3b4cStracker-user * @param {string} parentReplyId id of the reply being replied to 1096563f3b4cStracker-user * @param {number} depth visual nesting depth for the new reply 1097563f3b4cStracker-user * @returns {HTMLElement} 1098563f3b4cStracker-user */ 1099563f3b4cStracker-user function buildInlineReplyForm(ann, parentReplyId, depth) { 1100563f3b4cStracker-user var form = document.createElement('div'); 1101563f3b4cStracker-user form.className = 'ann-thread-entry ann-reply ann-inline-reply'; 1102563f3b4cStracker-user var indent = Math.min(depth, 4) * 1.5 + 1.5; 1103563f3b4cStracker-user if (indent > 0) { 1104563f3b4cStracker-user form.style.marginLeft = indent + 'em'; 1105563f3b4cStracker-user } 1106563f3b4cStracker-user 1107563f3b4cStracker-user var ta = document.createElement('textarea'); 1108563f3b4cStracker-user ta.className = 'ann-body-input'; 1109563f3b4cStracker-user ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1110108f92bdStracker-user ta.rows = 3; 1111563f3b4cStracker-user form.appendChild(ta); 1112563f3b4cStracker-user 1113563f3b4cStracker-user var row = document.createElement('div'); 1114563f3b4cStracker-user row.className = 'ann-form-row'; 1115563f3b4cStracker-user 1116563f3b4cStracker-user var submitBtn = document.createElement('button'); 1117563f3b4cStracker-user submitBtn.type = 'button'; 1118563f3b4cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1119563f3b4cStracker-user submitBtn.textContent = t('btn_reply', 'Reply'); 1120563f3b4cStracker-user submitBtn.addEventListener('click', function () { 1121563f3b4cStracker-user var body = ta.value.trim(); 1122563f3b4cStracker-user if (!body) return; 1123563f3b4cStracker-user doAddReply(ann.id, body, function () { 1124563f3b4cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1125563f3b4cStracker-user }, submitBtn, parentReplyId); 1126563f3b4cStracker-user }); 1127563f3b4cStracker-user 1128563f3b4cStracker-user var cancelBtn = document.createElement('button'); 1129563f3b4cStracker-user cancelBtn.type = 'button'; 1130563f3b4cStracker-user cancelBtn.className = 'ann-btn'; 1131563f3b4cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1132563f3b4cStracker-user cancelBtn.addEventListener('click', function () { 1133563f3b4cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1134563f3b4cStracker-user }); 1135563f3b4cStracker-user 1136563f3b4cStracker-user row.appendChild(submitBtn); 1137563f3b4cStracker-user row.appendChild(cancelBtn); 1138563f3b4cStracker-user form.appendChild(row); 1139563f3b4cStracker-user return form; 1140563f3b4cStracker-user } 1141563f3b4cStracker-user 1142563f3b4cStracker-user /** 114343d2073cStracker-user * Build the meta row (avatar initials, author name, timestamp, status pill). 114443d2073cStracker-user * 114543d2073cStracker-user * @param {string} author 114643d2073cStracker-user * @param {number} timestamp Unix seconds 114743d2073cStracker-user * @param {string|null} status 'open'|'resolved'|null 114843d2073cStracker-user * @returns {HTMLElement} 114943d2073cStracker-user */ 115043d2073cStracker-user function buildMeta(author, timestamp, status) { 115143d2073cStracker-user var meta = document.createElement('div'); 115243d2073cStracker-user meta.className = 'ann-meta'; 115343d2073cStracker-user 115443d2073cStracker-user var avatar = document.createElement('span'); 115543d2073cStracker-user avatar.className = 'ann-avatar'; 115643d2073cStracker-user avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 115743d2073cStracker-user meta.appendChild(avatar); 115843d2073cStracker-user 115943d2073cStracker-user var authorEl = document.createElement('span'); 116043d2073cStracker-user authorEl.className = 'ann-author'; 1161da56206cStracker-user authorEl.textContent = author || t('label_unknown', 'Unknown'); 116243d2073cStracker-user meta.appendChild(authorEl); 116343d2073cStracker-user 116443d2073cStracker-user var timeEl = document.createElement('time'); 116543d2073cStracker-user timeEl.className = 'ann-time'; 116643d2073cStracker-user var d = new Date(timestamp * 1000); 116743d2073cStracker-user timeEl.dateTime = d.toISOString(); 116843d2073cStracker-user timeEl.textContent = formatDate(d); 116943d2073cStracker-user meta.appendChild(timeEl); 117043d2073cStracker-user 117143d2073cStracker-user if (status) { 117243d2073cStracker-user var pill = document.createElement('span'); 117343d2073cStracker-user pill.className = 'ann-status ann-status-' + status; 1174da56206cStracker-user pill.textContent = status === 'resolved' 1175da56206cStracker-user ? t('status_resolved', 'Resolved') 1176da56206cStracker-user : t('status_open', 'Open'); 117743d2073cStracker-user meta.appendChild(pill); 117843d2073cStracker-user } 117943d2073cStracker-user 118043d2073cStracker-user return meta; 118143d2073cStracker-user } 118243d2073cStracker-user 118343d2073cStracker-user /** 118443d2073cStracker-user * Build a reply form at the bottom of the panel. 118543d2073cStracker-user * 118643d2073cStracker-user * @param {object} ann 118743d2073cStracker-user * @returns {HTMLElement} 118843d2073cStracker-user */ 118943d2073cStracker-user function buildReplyForm(ann) { 119043d2073cStracker-user var form = document.createElement('div'); 119143d2073cStracker-user form.className = 'ann-reply-form'; 119243d2073cStracker-user 119343d2073cStracker-user var ta = document.createElement('textarea'); 119443d2073cStracker-user ta.className = 'ann-body-input'; 1195da56206cStracker-user ta.placeholder = t('placeholder_reply', 'Write a reply…'); 119643d2073cStracker-user ta.rows = 3; 119743d2073cStracker-user form.appendChild(ta); 119843d2073cStracker-user 119943d2073cStracker-user var row = document.createElement('div'); 120043d2073cStracker-user row.className = 'ann-form-row'; 120143d2073cStracker-user 120243d2073cStracker-user var submitBtn = document.createElement('button'); 120343d2073cStracker-user submitBtn.type = 'button'; 120443d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1205da56206cStracker-user submitBtn.textContent = t('btn_reply', 'Reply'); 120643d2073cStracker-user submitBtn.addEventListener('click', function () { 120743d2073cStracker-user var body = ta.value.trim(); 120843d2073cStracker-user if (!body) return; 120943d2073cStracker-user doAddReply(ann.id, body, function () { 121043d2073cStracker-user ta.value = ''; 1211563f3b4cStracker-user }, submitBtn); 121243d2073cStracker-user }); 121343d2073cStracker-user row.appendChild(submitBtn); 121443d2073cStracker-user form.appendChild(row); 121543d2073cStracker-user 121643d2073cStracker-user return form; 121743d2073cStracker-user } 121843d2073cStracker-user 121943d2073cStracker-user /** 122043d2073cStracker-user * Replace the body of an entry with an inline edit form. 122143d2073cStracker-user * 122243d2073cStracker-user * @param {HTMLElement} entry 122343d2073cStracker-user * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 122443d2073cStracker-user * @param {string} type 'annotation' | 'reply' 122543d2073cStracker-user */ 122643d2073cStracker-user function showEditForm(entry, data, type) { 122743d2073cStracker-user var bodyEl = entry.querySelector('.ann-body'); 122843d2073cStracker-user if (!bodyEl) return; 122943d2073cStracker-user 123043d2073cStracker-user var ta = document.createElement('textarea'); 123143d2073cStracker-user ta.className = 'ann-body-input'; 123243d2073cStracker-user ta.value = data.body || ''; 1233563f3b4cStracker-user ta.rows = 3; 123443d2073cStracker-user 123543d2073cStracker-user var row = document.createElement('div'); 123643d2073cStracker-user row.className = 'ann-form-row'; 123743d2073cStracker-user 123843d2073cStracker-user var saveBtn = document.createElement('button'); 123943d2073cStracker-user saveBtn.type = 'button'; 124043d2073cStracker-user saveBtn.className = 'ann-btn ann-btn-primary'; 1241da56206cStracker-user saveBtn.textContent = t('btn_save', 'Save'); 124243d2073cStracker-user saveBtn.addEventListener('click', function () { 124343d2073cStracker-user var newBody = ta.value.trim(); 124443d2073cStracker-user if (!newBody) return; 124543d2073cStracker-user if (type === 'annotation') { 1246563f3b4cStracker-user doEditAnnotation(data.id || _openAnnId, newBody, saveBtn); 124743d2073cStracker-user } else { 1248563f3b4cStracker-user doEditReply(data.annId, data.replyId, newBody, saveBtn); 124943d2073cStracker-user } 125043d2073cStracker-user }); 125143d2073cStracker-user 125243d2073cStracker-user var cancelBtn = document.createElement('button'); 125343d2073cStracker-user cancelBtn.type = 'button'; 125443d2073cStracker-user cancelBtn.className = 'ann-btn'; 1255da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 125643d2073cStracker-user cancelBtn.addEventListener('click', function () { 125743d2073cStracker-user entry.removeChild(ta); 125843d2073cStracker-user entry.removeChild(row); 125943d2073cStracker-user bodyEl.style.display = ''; 126043d2073cStracker-user }); 126143d2073cStracker-user 126243d2073cStracker-user row.appendChild(saveBtn); 126343d2073cStracker-user row.appendChild(cancelBtn); 126443d2073cStracker-user 126543d2073cStracker-user bodyEl.style.display = 'none'; 126643d2073cStracker-user entry.insertBefore(ta, bodyEl.nextSibling); 126743d2073cStracker-user entry.insertBefore(row, ta.nextSibling); 126843d2073cStracker-user ta.focus(); 126943d2073cStracker-user } 127043d2073cStracker-user 127143d2073cStracker-user // ----------------------------------------------------------------------- 127243d2073cStracker-user // Orphan drawer 127343d2073cStracker-user // ----------------------------------------------------------------------- 127443d2073cStracker-user 127543d2073cStracker-user /** 127643d2073cStracker-user * Toggle the orphan drawer visibility. 127743d2073cStracker-user */ 127843d2073cStracker-user function toggleOrphanDrawer() { 127943d2073cStracker-user var drawer = document.getElementById('ann-orphan-drawer'); 128043d2073cStracker-user if (drawer) { 128143d2073cStracker-user drawer.parentNode.removeChild(drawer); 128243d2073cStracker-user return; 128343d2073cStracker-user } 128443d2073cStracker-user renderOrphanDrawer(); 128543d2073cStracker-user } 128643d2073cStracker-user 128743d2073cStracker-user /** 1288d6f8bd9dStracker-user * Keep the orphan drawer in step with the current orphan set after a 1289d6f8bd9dStracker-user * mutation (delete / clear). No-op when the drawer is closed. When it is 1290d6f8bd9dStracker-user * open, rebuild it from the live _orphaned flags so deleted entries 1291d6f8bd9dStracker-user * disappear; if no orphans remain, remove the drawer entirely instead of 1292d6f8bd9dStracker-user * leaving an empty shell behind. 1293d6f8bd9dStracker-user * 1294d6f8bd9dStracker-user * Must run after renderAll(), which recomputes every ann._orphaned flag. 1295d6f8bd9dStracker-user */ 1296d6f8bd9dStracker-user function syncOrphanDrawer() { 1297d6f8bd9dStracker-user var drawer = document.getElementById('ann-orphan-drawer'); 1298d6f8bd9dStracker-user if (!drawer) return; // drawer not open — nothing to do 1299d6f8bd9dStracker-user 1300d6f8bd9dStracker-user var hasOrphans = false; 1301d6f8bd9dStracker-user _annotations.forEach(function (ann) { 1302d6f8bd9dStracker-user if (ann._orphaned) hasOrphans = true; 1303d6f8bd9dStracker-user }); 1304d6f8bd9dStracker-user 1305d6f8bd9dStracker-user if (drawer.parentNode) drawer.parentNode.removeChild(drawer); 1306d6f8bd9dStracker-user if (hasOrphans) { 1307d6f8bd9dStracker-user renderOrphanDrawer(); 1308d6f8bd9dStracker-user repositionMarkers(); 1309d6f8bd9dStracker-user } 1310d6f8bd9dStracker-user } 1311d6f8bd9dStracker-user 1312d6f8bd9dStracker-user /** 131343d2073cStracker-user * Build and insert the orphan drawer at the bottom of the content area. 131443d2073cStracker-user */ 131543d2073cStracker-user function renderOrphanDrawer() { 131643d2073cStracker-user var content = document.getElementById(CONTENT_ID); 131743d2073cStracker-user if (!content) return; 131843d2073cStracker-user 131943d2073cStracker-user var drawer = document.createElement('div'); 132043d2073cStracker-user drawer.id = 'ann-orphan-drawer'; 132143d2073cStracker-user drawer.className = CLS_ORPHAN_DRAWER; 132243d2073cStracker-user 132343d2073cStracker-user var heading = document.createElement('h4'); 1324da56206cStracker-user heading.textContent = t('orphaned_heading', 'Orphaned annotations'); 132543d2073cStracker-user drawer.appendChild(heading); 132643d2073cStracker-user 132743d2073cStracker-user var note = document.createElement('p'); 132843d2073cStracker-user note.className = 'ann-orphan-note'; 1329da56206cStracker-user note.textContent = t('orphaned_note', 1330da56206cStracker-user 'These annotations reference text that no longer appears on the page.'); 133143d2073cStracker-user drawer.appendChild(note); 133243d2073cStracker-user 133343d2073cStracker-user var found = false; 133443d2073cStracker-user _annotations.forEach(function (ann) { 133543d2073cStracker-user if (!ann._orphaned) return; 133643d2073cStracker-user found = true; 1337*ad1073d4Stracker-user // Show the whole thread, not just the root message: wrap the root 1338*ad1073d4Stracker-user // entry and its full (read-only) reply tree in one container so 1339*ad1073d4Stracker-user // multiple orphaned threads stay visually separated. 1340*ad1073d4Stracker-user var thread = document.createElement('div'); 1341*ad1073d4Stracker-user thread.className = 'ann-orphan-thread'; 1342*ad1073d4Stracker-user thread.dataset.status = ann.status || 'open'; 1343*ad1073d4Stracker-user thread.appendChild(buildThreadEntry(ann, true)); 1344*ad1073d4Stracker-user appendReplyTree(thread, ann, buildReplyTree(ann.replies || []), 0, true); 1345*ad1073d4Stracker-user drawer.appendChild(thread); 134643d2073cStracker-user }); 134743d2073cStracker-user 134843d2073cStracker-user if (!found) { 134943d2073cStracker-user var empty = document.createElement('p'); 1350da56206cStracker-user empty.textContent = t('orphaned_none', 'None.'); 135143d2073cStracker-user drawer.appendChild(empty); 135243d2073cStracker-user } 135343d2073cStracker-user 1354563f3b4cStracker-user // Insert right below the counter bar, which lives inside .page. 1355563f3b4cStracker-user // All fallbacks also target .page so the drawer never stretches past 1356563f3b4cStracker-user // the content column. 1357563f3b4cStracker-user var bar = document.getElementById('ann-counter-bar'); 1358563f3b4cStracker-user if (bar && bar.parentNode) { 1359563f3b4cStracker-user bar.parentNode.insertBefore(drawer, bar.nextSibling); 1360563f3b4cStracker-user } else { 1361563f3b4cStracker-user var pageEl2 = document.querySelector('.' + PAGE_CLS); 1362563f3b4cStracker-user if (pageEl2) { 1363563f3b4cStracker-user pageEl2.insertBefore(drawer, pageEl2.firstChild); 1364563f3b4cStracker-user } else { 1365563f3b4cStracker-user content.insertBefore(drawer, content.firstChild); 1366563f3b4cStracker-user } 1367563f3b4cStracker-user } 136843d2073cStracker-user } 136943d2073cStracker-user 137043d2073cStracker-user // ----------------------------------------------------------------------- 137143d2073cStracker-user // Selection capture 137243d2073cStracker-user // ----------------------------------------------------------------------- 137343d2073cStracker-user 137443d2073cStracker-user /** 137543d2073cStracker-user * Wire up mouseup/touchend listeners to detect text selection. 137643d2073cStracker-user * 137743d2073cStracker-user * @param {HTMLElement} content 137843d2073cStracker-user */ 137943d2073cStracker-user function initSelectionCapture(content) { 138043d2073cStracker-user if (!_loggedIn) return; // anonymous users cannot annotate 138143d2073cStracker-user 138243d2073cStracker-user document.addEventListener('mouseup', function (e) { 138343d2073cStracker-user handleSelectionEnd(e, content); 138443d2073cStracker-user }); 138543d2073cStracker-user document.addEventListener('touchend', function (e) { 138643d2073cStracker-user // Small delay so the browser has committed the selection. 138743d2073cStracker-user setTimeout(function () { handleSelectionEnd(e, content); }, 50); 138843d2073cStracker-user }); 138943d2073cStracker-user 139050325813Stracker-user // Close tooltip on click outside (but not when clicking the new-form). 139143d2073cStracker-user document.addEventListener('mousedown', function (e) { 139243d2073cStracker-user var tooltip = document.getElementById('ann-tooltip'); 139343d2073cStracker-user if (tooltip && !tooltip.contains(e.target)) { 139450325813Stracker-user var naf = document.getElementById('ann-new-form'); 139550325813Stracker-user if (!naf || !naf.contains(e.target)) { 139643d2073cStracker-user hideTooltip(); 139743d2073cStracker-user } 139850325813Stracker-user } 139943d2073cStracker-user }); 140043d2073cStracker-user } 140143d2073cStracker-user 140243d2073cStracker-user /** 140343d2073cStracker-user * Handle end of selection: show the "Annotate" tooltip if there is a 140443d2073cStracker-user * non-empty selection inside the content area. 140543d2073cStracker-user * 140643d2073cStracker-user * @param {Event} e 140743d2073cStracker-user * @param {HTMLElement} content 140843d2073cStracker-user */ 140943d2073cStracker-user function handleSelectionEnd(e, content) { 141043d2073cStracker-user var sel = window.getSelection(); 141143d2073cStracker-user if (!sel || sel.isCollapsed) { 141250325813Stracker-user // Don't hide the tooltip if the mouseup came from inside it — 141350325813Stracker-user // the click handler is responsible for cleanup in that case. 141450325813Stracker-user var tip = document.getElementById('ann-tooltip'); 141550325813Stracker-user if (tip && tip.contains(e.target)) { 141650325813Stracker-user return; 141750325813Stracker-user } 141850325813Stracker-user // Don't hide if a new-annotation form is open (user clicked 141950325813Stracker-user // inside the form, collapsing the original selection). 142050325813Stracker-user var naf = document.getElementById('ann-new-form'); 142150325813Stracker-user if (naf && naf.contains(e.target)) { 142250325813Stracker-user return; 142350325813Stracker-user } 142443d2073cStracker-user hideTooltip(); 142543d2073cStracker-user return; 142643d2073cStracker-user } 142743d2073cStracker-user var range = sel.getRangeAt(0); 142843d2073cStracker-user if (!content.contains(range.commonAncestorContainer)) { 142943d2073cStracker-user hideTooltip(); 143043d2073cStracker-user return; 143143d2073cStracker-user } 143286c7806dStracker-user // If the selection touches any existing annotation — even by a single 143386c7806dStracker-user // character, whether wholly inside it or overrunning it on either side — 143486c7806dStracker-user // open that annotation instead of offering to create a new one. 143586c7806dStracker-user var hitSpan = selectionHitsHighlight(range); 143686c7806dStracker-user if (hitSpan) { 143786c7806dStracker-user hideTooltip(); 143886c7806dStracker-user openPanel(hitSpan.dataset.annId); 143986c7806dStracker-user return; 144086c7806dStracker-user } 144186c7806dStracker-user // Only real page prose can be annotated: skip our own UI (panels, 144286c7806dStracker-user // counter, tooltip), the TOC, the page-info line, and section-edit 144386c7806dStracker-user // buttons — all of which live inside #dokuwiki__content. 144486c7806dStracker-user if (isInExcludedRegion(range.startContainer) || 144586c7806dStracker-user isInExcludedRegion(range.endContainer) || 144686c7806dStracker-user isInExcludedRegion(range.commonAncestorContainer)) { 1447563f3b4cStracker-user hideTooltip(); 1448563f3b4cStracker-user return; 1449563f3b4cStracker-user } 145043d2073cStracker-user var text = sel.toString().trim(); 145143d2073cStracker-user if (text.length < 1) { 145243d2073cStracker-user hideTooltip(); 145343d2073cStracker-user return; 145443d2073cStracker-user } 145543d2073cStracker-user 145650325813Stracker-user // If the tooltip is already showing (e.g. user moused up after 145750325813Stracker-user // pressing the Annotate button), don't replace it with a fresh one — 145850325813Stracker-user // that would orphan the button mid-click and break the click handler. 145950325813Stracker-user if (document.getElementById('ann-tooltip')) { 146050325813Stracker-user return; 146150325813Stracker-user } 146250325813Stracker-user 146343d2073cStracker-user // Show the tooltip near the end of the selection. 146443d2073cStracker-user var rect = range.getBoundingClientRect(); 146543d2073cStracker-user showTooltip(rect, range, sel, content); 146643d2073cStracker-user } 146743d2073cStracker-user 146843d2073cStracker-user /** 146943d2073cStracker-user * Show the "Annotate" tooltip bubble. 147043d2073cStracker-user * 147143d2073cStracker-user * @param {DOMRect} rect bounding rect of the selection 147243d2073cStracker-user * @param {Range} range 147343d2073cStracker-user * @param {Selection} sel 147443d2073cStracker-user * @param {HTMLElement} content 147543d2073cStracker-user */ 147643d2073cStracker-user function showTooltip(rect, range, sel, content) { 147743d2073cStracker-user hideTooltip(); 147843d2073cStracker-user 147943d2073cStracker-user var tip = document.createElement('div'); 148043d2073cStracker-user tip.id = 'ann-tooltip'; 148143d2073cStracker-user tip.className = CLS_TOOLTIP; 148243d2073cStracker-user 148350325813Stracker-user // Capture the anchor on mousedown while the selection is guaranteed 148450325813Stracker-user // to still exist. By the time 'click' fires, many browsers have 148550325813Stracker-user // already collapsed the selection, so captureAnchor would return null. 148650325813Stracker-user // _pendingAnchor is module-level so it survives tooltip replacement. 148743d2073cStracker-user var btn = document.createElement('button'); 148843d2073cStracker-user btn.type = 'button'; 1489da56206cStracker-user btn.textContent = t('btn_annotate', 'Annotate'); 149043d2073cStracker-user btn.className = 'ann-btn ann-btn-primary'; 149143d2073cStracker-user btn.addEventListener('mousedown', function (e) { 149250325813Stracker-user e.preventDefault(); // prevent focus-change deselection 149350325813Stracker-user // Capture now, while the selection is still intact. 149450325813Stracker-user _pendingAnchor = captureAnchor(sel, range, content); 149543d2073cStracker-user }); 149643d2073cStracker-user btn.addEventListener('click', function () { 149750325813Stracker-user var anchor = _pendingAnchor; 149850325813Stracker-user _pendingAnchor = null; 149943d2073cStracker-user hideTooltip(); 150043d2073cStracker-user if (anchor) { 150143d2073cStracker-user openNewAnnotationForm(anchor, range); 150243d2073cStracker-user } 150343d2073cStracker-user }); 150443d2073cStracker-user tip.appendChild(btn); 150543d2073cStracker-user 150643d2073cStracker-user document.body.appendChild(tip); 150743d2073cStracker-user 150843d2073cStracker-user // Position below the selection's end. 150943d2073cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 151043d2073cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 151143d2073cStracker-user tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 151243d2073cStracker-user tip.style.left = (rect.left + scrollLeft) + 'px'; 151343d2073cStracker-user } 151443d2073cStracker-user 151543d2073cStracker-user /** 151643d2073cStracker-user * Remove the tooltip if it exists. 151743d2073cStracker-user */ 151843d2073cStracker-user function hideTooltip() { 151943d2073cStracker-user var tip = document.getElementById('ann-tooltip'); 152043d2073cStracker-user if (tip && tip.parentNode) { 152143d2073cStracker-user tip.parentNode.removeChild(tip); 152243d2073cStracker-user } 152350325813Stracker-user // Note: ann-new-form is NOT removed here — it has its own Cancel 152450325813Stracker-user // button and must survive the mouseup that fires after the click. 152543d2073cStracker-user } 152643d2073cStracker-user 152743d2073cStracker-user /** 152843d2073cStracker-user * Capture an anchor object from the current Selection. 152943d2073cStracker-user * 153043d2073cStracker-user * @param {Selection} sel 153143d2073cStracker-user * @param {Range} range 153243d2073cStracker-user * @param {HTMLElement} content 153343d2073cStracker-user * @returns {object|null} {exact, prefix, suffix, start} 153443d2073cStracker-user */ 153543d2073cStracker-user function captureAnchor(sel, range, content) { 153643d2073cStracker-user var exact = normalizeWS(sel.toString()); 153743d2073cStracker-user if (!exact) return null; 153843d2073cStracker-user 153943d2073cStracker-user // Get full page text for prefix/suffix and start computation. 154043d2073cStracker-user var chunks = collectTextChunks(content); 154143d2073cStracker-user var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1542da56206cStracker-user var nm = normalizeWithMap(fullRaw); 1543da56206cStracker-user var fullNorm = nm.norm; 154443d2073cStracker-user 154543d2073cStracker-user // Find where this text node + offset lands in the raw full text. 154643d2073cStracker-user var rawStart = 0; 154743d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 154843d2073cStracker-user var c = chunks[i]; 154943d2073cStracker-user if (c.node === range.startContainer) { 155043d2073cStracker-user rawStart = c.start + range.startOffset; 155143d2073cStracker-user break; 155243d2073cStracker-user } 155343d2073cStracker-user } 155443d2073cStracker-user 1555da56206cStracker-user // Map that raw offset to an offset in the normalised text, using the 1556da56206cStracker-user // same map as re-anchoring so capture and find stay in agreement. 1557da56206cStracker-user var normStart = nm.norm.length; 1558da56206cStracker-user for (var j = 0; j < nm.map.length; j++) { 1559da56206cStracker-user if (nm.map[j] >= rawStart) { 156043d2073cStracker-user normStart = j; 156143d2073cStracker-user break; 156243d2073cStracker-user } 156343d2073cStracker-user } 156443d2073cStracker-user 156586c7806dStracker-user // Context slice length comes from the plugin config (context_length), 156686c7806dStracker-user // injected into JSINFO.annotations; fall back to 30 when absent. The 156786c7806dStracker-user // PHP side caps the stored prefix/suffix to the same length. 156886c7806dStracker-user var CTX = (typeof _info.contextLen === 'number') ? _info.contextLen : 30; 156943d2073cStracker-user var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 157043d2073cStracker-user var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 157143d2073cStracker-user 157243d2073cStracker-user return { 157343d2073cStracker-user exact: exact, 157443d2073cStracker-user prefix: prefix, 157543d2073cStracker-user suffix: suffix, 157643d2073cStracker-user start: normStart, 157743d2073cStracker-user }; 157843d2073cStracker-user } 157943d2073cStracker-user 158043d2073cStracker-user /** 158143d2073cStracker-user * Open the new-annotation form below the paragraph containing the selection. 158243d2073cStracker-user * 158343d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 158443d2073cStracker-user * @param {Range} range 158543d2073cStracker-user */ 158643d2073cStracker-user function openNewAnnotationForm(anchor, range) { 158743d2073cStracker-user closePanel(); 158843d2073cStracker-user 158943d2073cStracker-user var insertAfter = findParagraph(range.commonAncestorContainer); 159043d2073cStracker-user var form = document.createElement('div'); 159143d2073cStracker-user form.id = 'ann-new-form'; 159243d2073cStracker-user form.className = 'ann-new-form'; 159343d2073cStracker-user 159443d2073cStracker-user var quote = document.createElement('blockquote'); 159543d2073cStracker-user quote.className = 'ann-quote'; 159643d2073cStracker-user quote.textContent = anchor.exact; 159743d2073cStracker-user form.appendChild(quote); 159843d2073cStracker-user 159943d2073cStracker-user var ta = document.createElement('textarea'); 160043d2073cStracker-user ta.className = 'ann-body-input'; 1601da56206cStracker-user ta.placeholder = t('placeholder_body', 'Add a comment…'); 1602563f3b4cStracker-user ta.rows = 3; 160343d2073cStracker-user form.appendChild(ta); 160443d2073cStracker-user 160543d2073cStracker-user var row = document.createElement('div'); 160643d2073cStracker-user row.className = 'ann-form-row'; 160743d2073cStracker-user 160843d2073cStracker-user var submitBtn = document.createElement('button'); 160943d2073cStracker-user submitBtn.type = 'button'; 161043d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1611da56206cStracker-user submitBtn.textContent = t('btn_annotate', 'Annotate'); 161243d2073cStracker-user submitBtn.addEventListener('click', function () { 161343d2073cStracker-user var body = ta.value.trim(); 161443d2073cStracker-user if (!body) return; 161543d2073cStracker-user doCreate(anchor, body, function () { 161643d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1617563f3b4cStracker-user }, submitBtn); 161843d2073cStracker-user }); 161943d2073cStracker-user 162043d2073cStracker-user var cancelBtn = document.createElement('button'); 162143d2073cStracker-user cancelBtn.type = 'button'; 162243d2073cStracker-user cancelBtn.className = 'ann-btn'; 1623da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 162443d2073cStracker-user cancelBtn.addEventListener('click', function () { 162543d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 162643d2073cStracker-user }); 162743d2073cStracker-user 162843d2073cStracker-user row.appendChild(submitBtn); 162943d2073cStracker-user row.appendChild(cancelBtn); 163043d2073cStracker-user form.appendChild(row); 163143d2073cStracker-user 163243d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 163343d2073cStracker-user insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 163443d2073cStracker-user } else { 163543d2073cStracker-user var content = document.getElementById(CONTENT_ID); 163643d2073cStracker-user if (content) content.appendChild(form); 163743d2073cStracker-user } 163843d2073cStracker-user 163943d2073cStracker-user ta.focus(); 164043d2073cStracker-user } 164143d2073cStracker-user 164243d2073cStracker-user // ----------------------------------------------------------------------- 164343d2073cStracker-user // AJAX actions 164443d2073cStracker-user // ----------------------------------------------------------------------- 164543d2073cStracker-user 164643d2073cStracker-user /** 164743d2073cStracker-user * POST create action and update state on success. 164843d2073cStracker-user * 164943d2073cStracker-user * @param {object} anchor 165043d2073cStracker-user * @param {string} body 165143d2073cStracker-user * @param {Function} onSuccess 1652563f3b4cStracker-user * @param {HTMLElement} [btn] button to disable while the request is in flight 165343d2073cStracker-user */ 1654563f3b4cStracker-user function doCreate(anchor, body, onSuccess, btn) { 1655563f3b4cStracker-user setBusy(btn, true); 165643d2073cStracker-user ajax({ 165743d2073cStracker-user action: 'create', 165843d2073cStracker-user id: _info.pageId, 165943d2073cStracker-user anchor: anchor, 166043d2073cStracker-user body: body, 166143d2073cStracker-user }).then(function (data) { 1662563f3b4cStracker-user setBusy(btn, false); 166343d2073cStracker-user if (!data.success) { 1664da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 166543d2073cStracker-user return; 166643d2073cStracker-user } 166743d2073cStracker-user var ann = data.annotation; 166843d2073cStracker-user _annotations.set(ann.id, ann); 166943d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(ann); 167043d2073cStracker-user renderAll(); 167143d2073cStracker-user }).catch(function () { 1672563f3b4cStracker-user setBusy(btn, false); 1673da56206cStracker-user alert(t('error_save', 'Could not save — please try again.')); 167443d2073cStracker-user }); 167543d2073cStracker-user } 167643d2073cStracker-user 167743d2073cStracker-user /** 1678563f3b4cStracker-user * Run a thread-level mutation (reply / edit annotation / edit reply / 1679563f3b4cStracker-user * delete reply): POST the payload, then on success store the returned 1680563f3b4cStracker-user * annotation — keeping the client-side render state via mergeClientProps — 1681563f3b4cStracker-user * and re-open its panel. The server returns the full updated annotation, so 1682563f3b4cStracker-user * no second GET is needed. These four actions share this exact shape; 1683563f3b4cStracker-user * create / delete-annotation / resolve differ (they re-render the whole 1684563f3b4cStracker-user * overlay) and stay separate below. 1685563f3b4cStracker-user * 1686563f3b4cStracker-user * @param {object} payload AJAX body; must carry annId 1687563f3b4cStracker-user * @param {HTMLElement} [btn] button to show the busy spinner on 1688563f3b4cStracker-user * @param {string} errKey lang key for the failure message 1689563f3b4cStracker-user * @param {string} errText English fallback for that message 1690563f3b4cStracker-user * @param {Function} [onOk] optional callback run before re-rendering 1691563f3b4cStracker-user */ 1692563f3b4cStracker-user function submitThreadAction(payload, btn, errKey, errText, onOk) { 1693563f3b4cStracker-user setBusy(btn, true); 1694563f3b4cStracker-user ajax(payload).then(function (data) { 1695563f3b4cStracker-user setBusy(btn, false); 1696563f3b4cStracker-user if (!data.success) { 1697563f3b4cStracker-user showError(t(errKey, errText), data); 1698563f3b4cStracker-user return; 1699563f3b4cStracker-user } 1700563f3b4cStracker-user _annotations.set(data.annotation.id, mergeClientProps(data.annotation)); 1701563f3b4cStracker-user if (typeof onOk === 'function') onOk(); 1702563f3b4cStracker-user reopenPanel(payload.annId); 170349d7ec0aStracker-user // If this annotation sits in the open orphan drawer, refresh it so 170449d7ec0aStracker-user // an edited body / changed reply count shows there too. (The anchor 170549d7ec0aStracker-user // is unchanged by these actions, so _orphaned flags stay valid.) 170649d7ec0aStracker-user syncOrphanDrawer(); 1707563f3b4cStracker-user }).catch(function () { 1708563f3b4cStracker-user setBusy(btn, false); 1709563f3b4cStracker-user alert(t(errKey, errText)); 1710563f3b4cStracker-user }); 1711563f3b4cStracker-user } 1712563f3b4cStracker-user 1713563f3b4cStracker-user /** 171443d2073cStracker-user * POST reply action and refresh the open panel. 171543d2073cStracker-user * 171643d2073cStracker-user * @param {string} annId 171743d2073cStracker-user * @param {string} body 171843d2073cStracker-user * @param {Function} onSuccess 1719563f3b4cStracker-user * @param {HTMLElement} [btn] 1720563f3b4cStracker-user * @param {string} [parentReplyId] id of the reply being replied to, or '' 172143d2073cStracker-user */ 1722563f3b4cStracker-user function doAddReply(annId, body, onSuccess, btn, parentReplyId) { 1723563f3b4cStracker-user submitThreadAction({ 172443d2073cStracker-user action: 'reply', 172543d2073cStracker-user id: _info.pageId, 172643d2073cStracker-user annId: annId, 172743d2073cStracker-user body: body, 1728563f3b4cStracker-user parentId: parentReplyId || '', 1729563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.', onSuccess); 173043d2073cStracker-user } 173143d2073cStracker-user 173243d2073cStracker-user /** 173343d2073cStracker-user * POST edit_annotation and re-render. 173443d2073cStracker-user * 173543d2073cStracker-user * @param {string} annId 173643d2073cStracker-user * @param {string} body 1737563f3b4cStracker-user * @param {HTMLElement} [btn] 173843d2073cStracker-user */ 1739563f3b4cStracker-user function doEditAnnotation(annId, body, btn) { 1740563f3b4cStracker-user submitThreadAction({ 174143d2073cStracker-user action: 'edit_annotation', 174243d2073cStracker-user id: _info.pageId, 174343d2073cStracker-user annId: annId, 174443d2073cStracker-user body: body, 1745563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.'); 174643d2073cStracker-user } 174743d2073cStracker-user 174843d2073cStracker-user /** 174943d2073cStracker-user * POST edit_reply and re-render. 175043d2073cStracker-user * 175143d2073cStracker-user * @param {string} annId 175243d2073cStracker-user * @param {string} replyId 175343d2073cStracker-user * @param {string} body 1754563f3b4cStracker-user * @param {HTMLElement} [btn] 175543d2073cStracker-user */ 1756563f3b4cStracker-user function doEditReply(annId, replyId, body, btn) { 1757563f3b4cStracker-user submitThreadAction({ 175843d2073cStracker-user action: 'edit_reply', 175943d2073cStracker-user id: _info.pageId, 176043d2073cStracker-user annId: annId, 176143d2073cStracker-user replyId: replyId, 176243d2073cStracker-user body: body, 1763563f3b4cStracker-user }, btn, 'error_save', 'Could not save — please try again.'); 176443d2073cStracker-user } 176543d2073cStracker-user 176643d2073cStracker-user /** 176743d2073cStracker-user * POST delete_annotation. 176843d2073cStracker-user * 176943d2073cStracker-user * @param {string} annId 1770563f3b4cStracker-user * @param {HTMLElement} [btn] 177143d2073cStracker-user */ 1772563f3b4cStracker-user function doDeleteAnnotation(annId, btn) { 1773563f3b4cStracker-user setBusy(btn, true); 177443d2073cStracker-user ajax({ 177543d2073cStracker-user action: 'delete_annotation', 177643d2073cStracker-user id: _info.pageId, 177743d2073cStracker-user annId: annId, 177843d2073cStracker-user }).then(function (data) { 1779563f3b4cStracker-user setBusy(btn, false); 178043d2073cStracker-user if (!data.success) { 1781da56206cStracker-user showError(t('error_delete', 'Could not delete — please try again.'), data); 178243d2073cStracker-user return; 178343d2073cStracker-user } 178443d2073cStracker-user _annotations.delete(annId); 178543d2073cStracker-user closePanel(); 178643d2073cStracker-user renderAll(); 1787d6f8bd9dStracker-user // If this was deleted from the open orphan drawer, refresh it — 1788d6f8bd9dStracker-user // and remove it entirely once the last orphan is gone. 1789d6f8bd9dStracker-user syncOrphanDrawer(); 1790563f3b4cStracker-user }).catch(function () { 1791563f3b4cStracker-user setBusy(btn, false); 179243d2073cStracker-user }); 179343d2073cStracker-user } 179443d2073cStracker-user 179543d2073cStracker-user /** 179643d2073cStracker-user * POST delete_reply and re-render. 179743d2073cStracker-user * 179843d2073cStracker-user * @param {string} annId 179943d2073cStracker-user * @param {string} replyId 1800563f3b4cStracker-user * @param {HTMLElement} [btn] 180143d2073cStracker-user */ 1802563f3b4cStracker-user function doDeleteReply(annId, replyId, btn) { 1803563f3b4cStracker-user submitThreadAction({ 180443d2073cStracker-user action: 'delete_reply', 180543d2073cStracker-user id: _info.pageId, 180643d2073cStracker-user annId: annId, 180743d2073cStracker-user replyId: replyId, 1808563f3b4cStracker-user }, btn, 'error_delete', 'Could not delete — please try again.'); 180943d2073cStracker-user } 181043d2073cStracker-user 181143d2073cStracker-user /** 181243d2073cStracker-user * POST resolve/reopen action. 181343d2073cStracker-user * 181443d2073cStracker-user * @param {string} annId 181543d2073cStracker-user * @param {string} status 'open' | 'resolved' 1816563f3b4cStracker-user * @param {HTMLElement} [btn] 181743d2073cStracker-user */ 1818563f3b4cStracker-user function doResolve(annId, status, btn) { 1819563f3b4cStracker-user setBusy(btn, true); 182043d2073cStracker-user ajax({ 182143d2073cStracker-user action: 'resolve', 182243d2073cStracker-user id: _info.pageId, 182343d2073cStracker-user annId: annId, 182443d2073cStracker-user status: status, 182543d2073cStracker-user }).then(function (data) { 1826563f3b4cStracker-user setBusy(btn, false); 182743d2073cStracker-user if (!data.success) { 1828da56206cStracker-user showError(t('error_status', 'Could not update the status — please try again.'), data); 182943d2073cStracker-user return; 183043d2073cStracker-user } 1831563f3b4cStracker-user _annotations.set(data.annotation.id, data.annotation); 183243d2073cStracker-user renderAll(); 183343d2073cStracker-user reopenPanel(annId); 1834563f3b4cStracker-user }).catch(function () { 1835563f3b4cStracker-user setBusy(btn, false); 183643d2073cStracker-user }); 183743d2073cStracker-user } 183843d2073cStracker-user 183943d2073cStracker-user /** 184043d2073cStracker-user * POST clear_resolved (admin). 1841d6f8bd9dStracker-user * 1842d6f8bd9dStracker-user * @param {HTMLElement} [btn] button to show the busy spinner on 184343d2073cStracker-user */ 1844d6f8bd9dStracker-user function doClearResolved(btn) { 1845da56206cStracker-user if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return; 1846d6f8bd9dStracker-user setBusy(btn, true); 184743d2073cStracker-user ajax({ 184843d2073cStracker-user action: 'clear_resolved', 184943d2073cStracker-user id: _info.pageId, 185043d2073cStracker-user }).then(function (data) { 1851d6f8bd9dStracker-user setBusy(btn, false); 185243d2073cStracker-user if (!data.success) { 1853da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 185443d2073cStracker-user return; 185543d2073cStracker-user } 185643d2073cStracker-user // Remove resolved from local state. 185743d2073cStracker-user _annotations.forEach(function (ann, id) { 185843d2073cStracker-user if (ann.status === 'resolved') _annotations.delete(id); 185943d2073cStracker-user }); 186043d2073cStracker-user closePanel(); 186143d2073cStracker-user renderAll(); 1862d6f8bd9dStracker-user // Deleting resolved orphans may empty the drawer — sync/remove it. 1863d6f8bd9dStracker-user syncOrphanDrawer(); 1864d6f8bd9dStracker-user }).catch(function () { 1865d6f8bd9dStracker-user setBusy(btn, false); 1866d6f8bd9dStracker-user alert(t('error_clear', 'Could not clear — please try again.')); 186743d2073cStracker-user }); 186843d2073cStracker-user } 186943d2073cStracker-user 187043d2073cStracker-user /** 187143d2073cStracker-user * POST clear_orphaned (admin). 1872d6f8bd9dStracker-user * 1873d6f8bd9dStracker-user * @param {HTMLElement} [btn] button to show the busy spinner on 187443d2073cStracker-user */ 1875d6f8bd9dStracker-user function doClearOrphaned(btn) { 1876da56206cStracker-user if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return; 1877d6f8bd9dStracker-user setBusy(btn, true); 187843d2073cStracker-user ajax({ 187943d2073cStracker-user action: 'clear_orphaned', 188043d2073cStracker-user id: _info.pageId, 188143d2073cStracker-user }).then(function (data) { 1882d6f8bd9dStracker-user setBusy(btn, false); 188343d2073cStracker-user if (!data.success) { 1884da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 188543d2073cStracker-user return; 188643d2073cStracker-user } 188743d2073cStracker-user _annotations.forEach(function (ann, id) { 188843d2073cStracker-user if (ann._orphaned) _annotations.delete(id); 188943d2073cStracker-user }); 189043d2073cStracker-user closePanel(); 189143d2073cStracker-user renderAll(); 1892d6f8bd9dStracker-user // All orphans are gone now — tear down the drawer if it is open. 1893d6f8bd9dStracker-user syncOrphanDrawer(); 1894d6f8bd9dStracker-user }).catch(function () { 1895d6f8bd9dStracker-user setBusy(btn, false); 1896d6f8bd9dStracker-user alert(t('error_clear', 'Could not clear — please try again.')); 189743d2073cStracker-user }); 189843d2073cStracker-user } 189943d2073cStracker-user 190043d2073cStracker-user // ----------------------------------------------------------------------- 190143d2073cStracker-user // Panel management helpers 190243d2073cStracker-user // ----------------------------------------------------------------------- 190343d2073cStracker-user 190443d2073cStracker-user /** 190543d2073cStracker-user * Close the current panel and re-open it (preserves scroll position and 190643d2073cStracker-user * re-renders the thread with fresh data). 190743d2073cStracker-user * 190843d2073cStracker-user * @param {string} annId 190943d2073cStracker-user */ 191043d2073cStracker-user function reopenPanel(annId) { 1911563f3b4cStracker-user // closePanel() first clears _openAnnId so openPanel() rebuilds instead 1912563f3b4cStracker-user // of treating the same id as a toggle. focusReply=false keeps the 1913563f3b4cStracker-user // viewport put after resolve / edit / delete actions. 191443d2073cStracker-user closePanel(); 1915563f3b4cStracker-user openPanel(annId, false); 191643d2073cStracker-user } 191743d2073cStracker-user 191843d2073cStracker-user // ----------------------------------------------------------------------- 191943d2073cStracker-user // Utilities 192043d2073cStracker-user // ----------------------------------------------------------------------- 192143d2073cStracker-user 192243d2073cStracker-user /** 1923563f3b4cStracker-user * Disable a button and show a spinner while an AJAX request is in flight; 1924563f3b4cStracker-user * restore label and width on completion. 1925563f3b4cStracker-user * 1926563f3b4cStracker-user * @param {HTMLElement|null|undefined} btn 1927563f3b4cStracker-user * @param {boolean} busy 1928563f3b4cStracker-user */ 1929563f3b4cStracker-user function setBusy(btn, busy) { 1930563f3b4cStracker-user if (!btn) return; 1931563f3b4cStracker-user if (busy) { 1932563f3b4cStracker-user btn.disabled = true; 1933563f3b4cStracker-user btn.dataset.prevText = btn.textContent; 1934563f3b4cStracker-user // Lock the width before clearing text so the button doesn't shrink. 1935563f3b4cStracker-user btn.style.minWidth = btn.offsetWidth + 'px'; 1936563f3b4cStracker-user btn.textContent = ' '; // non-breaking space keeps height 1937563f3b4cStracker-user btn.classList.add('ann-btn-busy'); 1938563f3b4cStracker-user } else { 1939563f3b4cStracker-user btn.disabled = false; 1940563f3b4cStracker-user btn.classList.remove('ann-btn-busy'); 1941563f3b4cStracker-user if (btn.dataset.prevText !== undefined) { 1942563f3b4cStracker-user btn.textContent = btn.dataset.prevText; 1943563f3b4cStracker-user delete btn.dataset.prevText; 1944563f3b4cStracker-user } 1945563f3b4cStracker-user btn.style.minWidth = ''; 1946563f3b4cStracker-user } 1947563f3b4cStracker-user } 1948563f3b4cStracker-user 1949563f3b4cStracker-user /** 1950563f3b4cStracker-user * Copy client-only runtime properties (_highlightEl, _markerEl, 1951563f3b4cStracker-user * _orphaned, _range) from the currently stored annotation onto a 1952563f3b4cStracker-user * freshly-returned server object before storing it, so that panels 1953563f3b4cStracker-user * reopen at the correct position instead of falling back to the 1954563f3b4cStracker-user * bottom of the page. 1955563f3b4cStracker-user * 1956563f3b4cStracker-user * @param {object} fresh annotation object from the server 1957563f3b4cStracker-user * @returns {object} the same object, augmented 1958563f3b4cStracker-user */ 1959563f3b4cStracker-user function mergeClientProps(fresh) { 1960563f3b4cStracker-user var existing = _annotations.get(fresh.id); 1961563f3b4cStracker-user if (existing) { 1962563f3b4cStracker-user fresh._highlightEl = existing._highlightEl; 1963563f3b4cStracker-user fresh._markerEl = existing._markerEl; 1964563f3b4cStracker-user fresh._orphaned = existing._orphaned; 1965563f3b4cStracker-user fresh._range = existing._range; 1966563f3b4cStracker-user } 1967563f3b4cStracker-user return fresh; 1968563f3b4cStracker-user } 1969563f3b4cStracker-user 1970563f3b4cStracker-user /** 1971da56206cStracker-user * The per-plugin JS language bundle, exposed by DokuWiki as 1972da56206cStracker-user * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 1973da56206cStracker-user * 1974da56206cStracker-user * @returns {object} 1975da56206cStracker-user */ 1976da56206cStracker-user function uiLang() { 1977da56206cStracker-user if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) { 1978da56206cStracker-user return LANG.plugins.annotations; 1979da56206cStracker-user } 1980da56206cStracker-user return {}; 1981da56206cStracker-user } 1982da56206cStracker-user 1983da56206cStracker-user /** 1984da56206cStracker-user * Look up a UI string by key, falling back to the supplied English text if 1985da56206cStracker-user * the bundle is missing the key (e.g. a lang file not yet updated). 1986da56206cStracker-user * 1987da56206cStracker-user * @param {string} key 1988da56206cStracker-user * @param {string} fallback English default 1989da56206cStracker-user * @returns {string} 1990da56206cStracker-user */ 1991da56206cStracker-user function t(key, fallback) { 1992da56206cStracker-user var s = _lang[key]; 1993da56206cStracker-user return (s === undefined || s === null || s === '') ? fallback : s; 1994da56206cStracker-user } 1995da56206cStracker-user 1996da56206cStracker-user /** 1997da56206cStracker-user * Substitute a single %d placeholder with a number. 1998da56206cStracker-user * 1999da56206cStracker-user * @param {string} str 2000da56206cStracker-user * @param {number} n 2001da56206cStracker-user * @returns {string} 2002da56206cStracker-user */ 2003da56206cStracker-user function fmt(str, n) { 2004da56206cStracker-user return String(str).replace('%d', n); 2005da56206cStracker-user } 2006da56206cStracker-user 2007da56206cStracker-user /** 2008da56206cStracker-user * Show a localised error, appending the server's reason in parentheses 2009da56206cStracker-user * when one is present. 2010da56206cStracker-user * 2011da56206cStracker-user * @param {string} base localised message 2012da56206cStracker-user * @param {object} data AJAX response ({error?:string}) 2013da56206cStracker-user */ 2014da56206cStracker-user function showError(base, data) { 2015da56206cStracker-user var reason = (data && data.error) ? data.error : ''; 2016da56206cStracker-user alert(reason ? base + ' (' + reason + ')' : base); 2017da56206cStracker-user } 2018da56206cStracker-user 2019da56206cStracker-user /** 202043d2073cStracker-user * Collapse consecutive whitespace to a single space and trim. 202143d2073cStracker-user * 202243d2073cStracker-user * @param {string} s 202343d2073cStracker-user * @returns {string} 202443d2073cStracker-user */ 202543d2073cStracker-user function normalizeWS(s) { 202643d2073cStracker-user return String(s || '').replace(/\s+/g, ' ').trim(); 202743d2073cStracker-user } 202843d2073cStracker-user 202943d2073cStracker-user /** 203043d2073cStracker-user * Return the current DokuWiki username from JSINFO. 203143d2073cStracker-user * 203243d2073cStracker-user * @returns {string} 203343d2073cStracker-user */ 203443d2073cStracker-user function currentUser() { 203543d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 20367d2714c7Stracker-user return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : ''; 203743d2073cStracker-user } 203843d2073cStracker-user 203943d2073cStracker-user /** 204043d2073cStracker-user * Format a Date for display. 204143d2073cStracker-user * 204243d2073cStracker-user * @param {Date} d 204343d2073cStracker-user * @returns {string} 204443d2073cStracker-user */ 204543d2073cStracker-user function formatDate(d) { 204643d2073cStracker-user var now = new Date(); 204743d2073cStracker-user var diff = (now - d) / 1000; // seconds 2048da56206cStracker-user if (diff < 60) return t('time_now', 'just now'); 2049da56206cStracker-user if (diff < 3600) return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60)); 2050da56206cStracker-user if (diff < 86400) return fmt(t('time_hours', '%dh ago'), Math.floor(diff / 3600)); 2051da56206cStracker-user if (diff < 86400 * 7) return fmt(t('time_days', '%dd ago'), Math.floor(diff / 86400)); 205243d2073cStracker-user return d.toLocaleDateString(); 205343d2073cStracker-user } 205443d2073cStracker-user 205543d2073cStracker-user // ----------------------------------------------------------------------- 205643d2073cStracker-user // Init 205743d2073cStracker-user // ----------------------------------------------------------------------- 205843d2073cStracker-user 205943d2073cStracker-user if (document.readyState === 'loading') { 206043d2073cStracker-user document.addEventListener('DOMContentLoaded', boot); 206143d2073cStracker-user } else { 206243d2073cStracker-user boot(); 206343d2073cStracker-user } 206443d2073cStracker-user 206543d2073cStracker-user}()); 2066