143d2073cStracker-user/** 243d2073cStracker-user * Annotations plugin — front-end script. 343d2073cStracker-user * 443d2073cStracker-user * Responsibilities: 543d2073cStracker-user * 643d2073cStracker-user * 1. BOOT: read JSINFO.annotations (injected by action.php); if the user 743d2073cStracker-user * has disabled annotations, exit early. 843d2073cStracker-user * 943d2073cStracker-user * 2. LOAD: fetch the page's annotation list via the AJAX endpoint, then: 1043d2073cStracker-user * a. Anchor each annotation in the DOM (re-anchoring). 1143d2073cStracker-user * b. Wrap matched text in highlight <span>s. 1243d2073cStracker-user * c. Render per-line gutter markers. 1343d2073cStracker-user * d. Update the page counter bubble. 1443d2073cStracker-user * 1543d2073cStracker-user * 3. SELECTION: detect when the user finishes a text selection inside the 1643d2073cStracker-user * wiki content area, show an "Annotate" tooltip, capture the anchor on 1743d2073cStracker-user * click, and open a new-annotation form. 1843d2073cStracker-user * 1943d2073cStracker-user * 4. PANELS: clicking a highlight opens the annotation thread inline, just 2043d2073cStracker-user * below the paragraph that contains the highlight. One open panel at a 2143d2073cStracker-user * time. The panel renders the full thread: author, timestamp, body, 2243d2073cStracker-user * replies; and permission-gated action buttons. 2343d2073cStracker-user * 2443d2073cStracker-user * 5. AJAX: all state-changing operations POST JSON to 2543d2073cStracker-user * /lib/exe/ajax.php?call=annotations (with the DokuWiki security token). 2643d2073cStracker-user * Responses update the in-memory state and re-render affected highlights 2743d2073cStracker-user * / gutter markers / counter without a page reload. 2843d2073cStracker-user * 2943d2073cStracker-user * 6. ORPHANS: annotations that cannot be re-anchored are counted and 3043d2073cStracker-user * reachable via the orphaned counter link; their panels open in a 3143d2073cStracker-user * dedicated orphan drawer at the bottom of the content area. 3243d2073cStracker-user * 3343d2073cStracker-user * FF78 ESR compatibility: 3443d2073cStracker-user * - No #private fields, ??=, ||=, &&=, Array.at, structuredClone, 3543d2073cStracker-user * Object.hasOwn, native <dialog>. 3643d2073cStracker-user * - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK. 3743d2073cStracker-user */ 3843d2073cStracker-user 3943d2073cStracker-user(function () { 4043d2073cStracker-user 'use strict'; 4143d2073cStracker-user 4243d2073cStracker-user // ----------------------------------------------------------------------- 4343d2073cStracker-user // Constants 4443d2073cStracker-user // ----------------------------------------------------------------------- 4543d2073cStracker-user 4643d2073cStracker-user var AJAX_URL = DOKU_BASE + 'lib/exe/ajax.php?call=annotations'; 4743d2073cStracker-user var CONTENT_ID = 'dokuwiki__content'; 48b8076f00Stracker-user // .page is the article area inside #dokuwiki__content. Gutter markers 49b8076f00Stracker-user // are appended here so position:relative doesn't break the sidebar nav. 50b8076f00Stracker-user var PAGE_CLS = 'page'; 5143d2073cStracker-user 5243d2073cStracker-user // Colour tokens (also defined in style.css; kept here so JS can read them) 5343d2073cStracker-user var CLS_HIGHLIGHT_OPEN = 'ann-highlight-open'; 5443d2073cStracker-user var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved'; 5543d2073cStracker-user var CLS_GUTTER_MARKER = 'ann-gutter-marker'; 5643d2073cStracker-user var CLS_PANEL = 'ann-panel'; 5743d2073cStracker-user var CLS_COUNTER = 'ann-counter'; 5843d2073cStracker-user var CLS_TOOLTIP = 'ann-tooltip'; 5943d2073cStracker-user var CLS_ORPHAN_DRAWER = 'ann-orphan-drawer'; 6043d2073cStracker-user 6143d2073cStracker-user // ----------------------------------------------------------------------- 6243d2073cStracker-user // State 6343d2073cStracker-user // ----------------------------------------------------------------------- 6443d2073cStracker-user 6543d2073cStracker-user /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */ 6643d2073cStracker-user var _annotations = new Map(); 6743d2073cStracker-user 6843d2073cStracker-user /** Currently open panel element, or null. @type {HTMLElement|null} */ 6943d2073cStracker-user var _openPanel = null; 7043d2073cStracker-user 7150325813Stracker-user /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */ 7250325813Stracker-user var _pendingAnchor = null; 7350325813Stracker-user 7443d2073cStracker-user /** ID of the annotation whose panel is open, or null. @type {string|null} */ 7543d2073cStracker-user var _openAnnId = null; 7643d2073cStracker-user 7743d2073cStracker-user /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */ 7843d2073cStracker-user var _info = {}; 7943d2073cStracker-user 8043d2073cStracker-user /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */ 8143d2073cStracker-user var _lang = {}; 8243d2073cStracker-user 8343d2073cStracker-user /** The DokuWiki security token. @type {string} */ 8443d2073cStracker-user var _token = ''; 8543d2073cStracker-user 8643d2073cStracker-user /** Whether the current user is logged in. @type {bool} */ 8743d2073cStracker-user var _loggedIn = false; 8843d2073cStracker-user 8943d2073cStracker-user /** Whether the current user is an admin. @type {bool} */ 9043d2073cStracker-user var _isAdmin = false; 9143d2073cStracker-user 9243d2073cStracker-user // ----------------------------------------------------------------------- 9343d2073cStracker-user // Boot 9443d2073cStracker-user // ----------------------------------------------------------------------- 9543d2073cStracker-user 9643d2073cStracker-user /** 9743d2073cStracker-user * Entry point: wired to DOMContentLoaded. 9843d2073cStracker-user */ 9943d2073cStracker-user function boot() { 10043d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 10143d2073cStracker-user var annInfo = jsinfo.annotations || {}; 10243d2073cStracker-user 10343d2073cStracker-user if (!annInfo.enabled) { 10443d2073cStracker-user return; // user disabled annotations 10543d2073cStracker-user } 10643d2073cStracker-user 10743d2073cStracker-user _info = annInfo; 108*da56206cStracker-user // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as 109*da56206cStracker-user // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 110*da56206cStracker-user _lang = uiLang(); 1117d2714c7Stracker-user // Token is injected into JSINFO.annotations by action.php (handleMetaHeader). 1127d2714c7Stracker-user // getSecurityToken() on the server produces it from session_id + REMOTE_USER. 1137d2714c7Stracker-user _token = annInfo.token || ''; 11443d2073cStracker-user 1157d2714c7Stracker-user // DokuWiki's JSINFO doesn't include user identity; we inject 1167d2714c7Stracker-user // user + isAdmin into JSINFO.annotations from PHP (action.php). 1177d2714c7Stracker-user _loggedIn = !!(annInfo.user && annInfo.user !== ''); 1187d2714c7Stracker-user _isAdmin = !!(annInfo.isAdmin); 11943d2073cStracker-user 12043d2073cStracker-user var content = document.getElementById(CONTENT_ID); 12143d2073cStracker-user if (!content) { 12243d2073cStracker-user return; // not a page view 12343d2073cStracker-user } 12443d2073cStracker-user 12543d2073cStracker-user renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0); 12643d2073cStracker-user loadAnnotations(); 12743d2073cStracker-user initSelectionCapture(content); 12843d2073cStracker-user } 12943d2073cStracker-user 13043d2073cStracker-user // ----------------------------------------------------------------------- 13143d2073cStracker-user // AJAX helpers 13243d2073cStracker-user // ----------------------------------------------------------------------- 13343d2073cStracker-user 13443d2073cStracker-user /** 13543d2073cStracker-user * POST a JSON payload to the AJAX endpoint. 13643d2073cStracker-user * 13743d2073cStracker-user * @param {object} payload 13843d2073cStracker-user * @returns {Promise<object>} response data 13943d2073cStracker-user */ 14043d2073cStracker-user function ajax(payload) { 14143d2073cStracker-user payload.sectok = _token; // DokuWiki security token field name for AJAX 14243d2073cStracker-user return fetch(AJAX_URL, { 14343d2073cStracker-user method: 'POST', 14443d2073cStracker-user headers: {'Content-Type': 'application/json'}, 14543d2073cStracker-user body: JSON.stringify(payload), 14643d2073cStracker-user }).then(function (res) { 14743d2073cStracker-user return res.json(); 14843d2073cStracker-user }); 14943d2073cStracker-user } 15043d2073cStracker-user 15143d2073cStracker-user // ----------------------------------------------------------------------- 15243d2073cStracker-user // Load and anchor annotations 15343d2073cStracker-user // ----------------------------------------------------------------------- 15443d2073cStracker-user 15543d2073cStracker-user /** 15643d2073cStracker-user * Fetch all annotations for the current page and render them. 15743d2073cStracker-user */ 15843d2073cStracker-user function loadAnnotations() { 15943d2073cStracker-user // We use a lightweight GET-style call: the action.php AJAX handler 16043d2073cStracker-user // is POST-only, so we pass action=load in the payload. 16143d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 16243d2073cStracker-user method: 'GET', 16343d2073cStracker-user }).then(function (res) { 16443d2073cStracker-user return res.json(); 16543d2073cStracker-user }).then(function (data) { 16643d2073cStracker-user if (!data || !Array.isArray(data.annotations)) { 16743d2073cStracker-user return; 16843d2073cStracker-user } 16943d2073cStracker-user data.annotations.forEach(function (ann) { 17043d2073cStracker-user _annotations.set(ann.id, ann); 17143d2073cStracker-user }); 17243d2073cStracker-user renderAll(); 17343d2073cStracker-user }).catch(function () { 17443d2073cStracker-user // Graceful degradation: page still works without annotations. 17543d2073cStracker-user }); 17643d2073cStracker-user } 17743d2073cStracker-user 17843d2073cStracker-user /** 17943d2073cStracker-user * Re-render everything: highlights, gutter markers, counter. 18043d2073cStracker-user */ 18143d2073cStracker-user function renderAll() { 18243d2073cStracker-user clearHighlights(); 18343d2073cStracker-user clearGutterMarkers(); 18443d2073cStracker-user 18543d2073cStracker-user var content = document.getElementById(CONTENT_ID); 18643d2073cStracker-user if (!content) return; 18743d2073cStracker-user 188*da56206cStracker-user // Snapshot the page text ONCE, before any highlight is inserted. 189*da56206cStracker-user // Re-collecting per annotation would exclude already-wrapped text 190*da56206cStracker-user // (collectTextChunks skips our own UI), shifting every later anchor. 191*da56206cStracker-user var chunks = collectTextChunks(content); 192*da56206cStracker-user var rawFull = chunks.map(function (c) { return c.text; }).join(''); 193*da56206cStracker-user var nm = normalizeWithMap(rawFull); 19443d2073cStracker-user 195*da56206cStracker-user // Phase 1 — locate every annotation against the clean snapshot. 196*da56206cStracker-user var hits = []; 19743d2073cStracker-user _annotations.forEach(function (ann) { 198*da56206cStracker-user ann._range = null; 199*da56206cStracker-user ann._highlightEl = null; 200*da56206cStracker-user var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null; 201*da56206cStracker-user if (hit) { 202*da56206cStracker-user hits.push({ann: ann, pos: hit.pos, len: hit.len}); 203*da56206cStracker-user ann._orphaned = false; 20443d2073cStracker-user } else { 205*da56206cStracker-user ann._orphaned = true; 206*da56206cStracker-user } 207*da56206cStracker-user }); 208*da56206cStracker-user 209*da56206cStracker-user // Phase 2 — wrap later matches first, so wrapping (which splits text 210*da56206cStracker-user // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones. 211*da56206cStracker-user hits.sort(function (a, b) { return b.pos - a.pos; }); 212*da56206cStracker-user hits.forEach(function (h) { 213*da56206cStracker-user var range = buildRange(chunks, nm.map, h.pos, h.len); 214*da56206cStracker-user if (range) { 215*da56206cStracker-user h.ann._range = range; // cache for panel positioning 216*da56206cStracker-user wrapHighlight(range, h.ann); 217*da56206cStracker-user } else { 218*da56206cStracker-user h.ann._orphaned = true; 21943d2073cStracker-user } 22043d2073cStracker-user }); 22143d2073cStracker-user 22243d2073cStracker-user renderGutterMarkers(); 223*da56206cStracker-user updateCounter(); // recounts orphans from the _orphaned flags set above 22443d2073cStracker-user } 22543d2073cStracker-user 22643d2073cStracker-user // ----------------------------------------------------------------------- 22743d2073cStracker-user // Text anchoring (re-anchoring) 22843d2073cStracker-user // ----------------------------------------------------------------------- 22943d2073cStracker-user 23043d2073cStracker-user /** 231*da56206cStracker-user * Locate an anchor's quoted text within the normalised page text. 23243d2073cStracker-user * 23343d2073cStracker-user * Algorithm: 234*da56206cStracker-user * 1. Search for the exact quote (normalised). 235*da56206cStracker-user * 2. If found multiple times, use prefix/suffix to disambiguate. 236*da56206cStracker-user * 3. If still ambiguous, use the start offset hint. 23743d2073cStracker-user * 238*da56206cStracker-user * Returns offsets into the normalised string; buildRange maps them back 239*da56206cStracker-user * to a DOM Range via the normalised→raw index map. 240*da56206cStracker-user * 241*da56206cStracker-user * @param {string} norm normalised page text (from normalizeWithMap) 24243d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 243*da56206cStracker-user * @returns {{pos:number, len:number}|null} 24443d2073cStracker-user */ 245*da56206cStracker-user function locate(norm, anchor) { 24643d2073cStracker-user if (!anchor || !anchor.exact) return null; 24743d2073cStracker-user 24843d2073cStracker-user var exact = normalizeWS(anchor.exact); 249*da56206cStracker-user if (exact === '') return null; 25043d2073cStracker-user var prefix = normalizeWS(anchor.prefix || ''); 25143d2073cStracker-user var suffix = normalizeWS(anchor.suffix || ''); 25243d2073cStracker-user var hint = anchor.start || 0; 25343d2073cStracker-user 25443d2073cStracker-user // Find all occurrences of exact. 25543d2073cStracker-user var positions = []; 256*da56206cStracker-user var from = 0; 25743d2073cStracker-user var idx; 258*da56206cStracker-user while ((idx = norm.indexOf(exact, from)) !== -1) { 259*da56206cStracker-user positions.push(idx); 260*da56206cStracker-user from = idx + exact.length; 26143d2073cStracker-user } 26243d2073cStracker-user 26343d2073cStracker-user if (positions.length === 0) return null; 26443d2073cStracker-user 265*da56206cStracker-user var chosen = positions[0]; 26643d2073cStracker-user 26743d2073cStracker-user if (positions.length > 1) { 268*da56206cStracker-user // Disambiguate using prefix + suffix context, tie-break on the hint. 26943d2073cStracker-user var bestScore = -1; 27043d2073cStracker-user positions.forEach(function (pos) { 271*da56206cStracker-user var pre = norm.slice(Math.max(0, pos - prefix.length), pos); 272*da56206cStracker-user var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length); 27343d2073cStracker-user var score = 0; 27443d2073cStracker-user if (prefix && pre.indexOf(prefix) !== -1) score++; 27543d2073cStracker-user if (suffix && suf.indexOf(suffix) !== -1) score++; 27643d2073cStracker-user var distToHint = Math.abs(pos - hint); 277*da56206cStracker-user if (score > bestScore || 278*da56206cStracker-user (score === bestScore && distToHint < Math.abs(chosen - hint))) { 27943d2073cStracker-user bestScore = score; 280*da56206cStracker-user chosen = pos; 28143d2073cStracker-user } 28243d2073cStracker-user }); 28343d2073cStracker-user } 28443d2073cStracker-user 285*da56206cStracker-user return {pos: chosen, len: exact.length}; 28643d2073cStracker-user } 28743d2073cStracker-user 28843d2073cStracker-user /** 28943d2073cStracker-user * Walk the text nodes under root and return an array of 29043d2073cStracker-user * {node, start, text} objects where start is the cumulative character 29143d2073cStracker-user * offset of this node's text in the joined string. 29243d2073cStracker-user * 29343d2073cStracker-user * The joined string is NOT normalised here — we normalise the full string 29443d2073cStracker-user * once above instead. 29543d2073cStracker-user * 29643d2073cStracker-user * @param {HTMLElement} root 29743d2073cStracker-user * @returns {Array<{node:Text, start:number, text:string}>} 29843d2073cStracker-user */ 29943d2073cStracker-user function collectTextChunks(root) { 30043d2073cStracker-user var walker = document.createTreeWalker( 30143d2073cStracker-user root, 30243d2073cStracker-user NodeFilter.SHOW_TEXT, 30343d2073cStracker-user null, 30443d2073cStracker-user false 30543d2073cStracker-user ); 30643d2073cStracker-user var chunks = []; 30743d2073cStracker-user var offset = 0; 30843d2073cStracker-user var node; 30943d2073cStracker-user while ((node = walker.nextNode())) { 31043d2073cStracker-user // Skip nodes inside our own UI elements. 31143d2073cStracker-user if (isAnnotationUI(node.parentNode)) continue; 31243d2073cStracker-user var text = node.nodeValue || ''; 31343d2073cStracker-user chunks.push({node: node, start: offset, text: text}); 31443d2073cStracker-user offset += text.length; 31543d2073cStracker-user } 31643d2073cStracker-user return chunks; 31743d2073cStracker-user } 31843d2073cStracker-user 31943d2073cStracker-user /** 32043d2073cStracker-user * True if the element (or its ancestor) is part of our annotation UI. 32143d2073cStracker-user * 32243d2073cStracker-user * @param {Node} el 32343d2073cStracker-user * @returns {bool} 32443d2073cStracker-user */ 32543d2073cStracker-user function isAnnotationUI(el) { 32643d2073cStracker-user while (el && el !== document.body) { 32743d2073cStracker-user if (el.nodeType === 1) { 32843d2073cStracker-user var cls = el.className || ''; 32943d2073cStracker-user if ( 33043d2073cStracker-user cls.indexOf('ann-') !== -1 || 33143d2073cStracker-user cls.indexOf(CLS_PANEL) !== -1 33243d2073cStracker-user ) { 33343d2073cStracker-user return true; 33443d2073cStracker-user } 33543d2073cStracker-user } 33643d2073cStracker-user el = el.parentNode; 33743d2073cStracker-user } 33843d2073cStracker-user return false; 33943d2073cStracker-user } 34043d2073cStracker-user 34143d2073cStracker-user /** 342*da56206cStracker-user * Turn a (start, length) offset in the normalised page text back into a 343*da56206cStracker-user * DOM Range, using the normalised→raw index map. 34443d2073cStracker-user * 34543d2073cStracker-user * @param {Array<{node:Text, start:number, text:string}>} chunks 346*da56206cStracker-user * @param {Array<number>} map normalised index → raw index (normalizeWithMap) 347*da56206cStracker-user * @param {number} startOff start offset in the normalised text 348*da56206cStracker-user * @param {number} length length in normalised characters 34943d2073cStracker-user * @returns {Range|null} 35043d2073cStracker-user */ 351*da56206cStracker-user function buildRange(chunks, map, startOff, length) { 352*da56206cStracker-user var rawStart = map[startOff]; 353*da56206cStracker-user var rawEnd = map[startOff + length - 1]; 35443d2073cStracker-user if (rawStart === undefined || rawEnd === undefined) return null; 35543d2073cStracker-user rawEnd++; // exclusive 35643d2073cStracker-user 35743d2073cStracker-user // Find which chunks contain rawStart and rawEnd. 35843d2073cStracker-user var startChunk = null, startOffset = 0; 35943d2073cStracker-user var endChunk = null, endOffset = 0; 36043d2073cStracker-user 36143d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 36243d2073cStracker-user var c = chunks[i]; 36343d2073cStracker-user var cEnd = c.start + c.text.length; 36443d2073cStracker-user 36543d2073cStracker-user if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 36643d2073cStracker-user startChunk = c.node; 36743d2073cStracker-user startOffset = rawStart - c.start; 36843d2073cStracker-user } 36943d2073cStracker-user if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 37043d2073cStracker-user endChunk = c.node; 37143d2073cStracker-user endOffset = rawEnd - c.start; 37243d2073cStracker-user } 37343d2073cStracker-user if (startChunk && endChunk) break; 37443d2073cStracker-user } 37543d2073cStracker-user 37643d2073cStracker-user if (!startChunk || !endChunk) return null; 37743d2073cStracker-user 37843d2073cStracker-user try { 37943d2073cStracker-user var range = document.createRange(); 38043d2073cStracker-user range.setStart(startChunk, startOffset); 38143d2073cStracker-user range.setEnd(endChunk, endOffset); 38243d2073cStracker-user return range; 38343d2073cStracker-user } catch (e) { 38443d2073cStracker-user return null; 38543d2073cStracker-user } 38643d2073cStracker-user } 38743d2073cStracker-user 38843d2073cStracker-user /** 389*da56206cStracker-user * Normalise raw text exactly as normalizeWS does (collapse each whitespace 390*da56206cStracker-user * run to a single space, trim both ends) while recording, for every 391*da56206cStracker-user * character of the normalised string, the index of the raw character it 392*da56206cStracker-user * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i) 393*da56206cStracker-user * (a collapsed internal space maps to the first char of its run). 394*da56206cStracker-user * 395*da56206cStracker-user * Normalisation and the index map MUST stay in lockstep: an earlier 396*da56206cStracker-user * version built the map without trimming, so a leading whitespace text 397*da56206cStracker-user * node (DokuWiki indents its content markup, so there always is one) 398*da56206cStracker-user * shifted every highlight one character to the left. 39943d2073cStracker-user * 40043d2073cStracker-user * @param {string} raw 401*da56206cStracker-user * @returns {{norm:string, map:Array<number>}} 40243d2073cStracker-user */ 403*da56206cStracker-user function normalizeWithMap(raw) { 404*da56206cStracker-user var norm = ''; 40543d2073cStracker-user var map = []; 406*da56206cStracker-user var inRun = false; 407*da56206cStracker-user var runStart = 0; 40843d2073cStracker-user for (var i = 0; i < raw.length; i++) { 409*da56206cStracker-user if (/\s/.test(raw[i])) { 410*da56206cStracker-user if (!inRun) { inRun = true; runStart = i; } 411*da56206cStracker-user continue; 41243d2073cStracker-user } 413*da56206cStracker-user if (inRun) { 414*da56206cStracker-user inRun = false; 415*da56206cStracker-user // internal run → one representative space; leading run → dropped 416*da56206cStracker-user if (norm.length > 0) { 417*da56206cStracker-user norm += ' '; 418*da56206cStracker-user map.push(runStart); 419*da56206cStracker-user } 420*da56206cStracker-user } 421*da56206cStracker-user norm += raw[i]; 42243d2073cStracker-user map.push(i); 42343d2073cStracker-user } 424*da56206cStracker-user // a trailing whitespace run is dropped (matches trim) 425*da56206cStracker-user return {norm: norm, map: map}; 42643d2073cStracker-user } 42743d2073cStracker-user 42843d2073cStracker-user // ----------------------------------------------------------------------- 42943d2073cStracker-user // Highlights 43043d2073cStracker-user // ----------------------------------------------------------------------- 43143d2073cStracker-user 43243d2073cStracker-user /** 43343d2073cStracker-user * Wrap a Range in a highlight <span> for the given annotation. 43443d2073cStracker-user * 43543d2073cStracker-user * @param {Range} range 43643d2073cStracker-user * @param {object} ann 43743d2073cStracker-user */ 43843d2073cStracker-user function wrapHighlight(range, ann) { 43943d2073cStracker-user try { 44043d2073cStracker-user var span = document.createElement('span'); 44143d2073cStracker-user span.className = ann.status === 'resolved' 44243d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 44343d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 44443d2073cStracker-user span.dataset.annId = ann.id; 44543d2073cStracker-user span.title = ann.body.slice(0, 80) + (ann.body.length > 80 ? '…' : ''); 44643d2073cStracker-user span.addEventListener('click', function (e) { 44743d2073cStracker-user e.stopPropagation(); 44843d2073cStracker-user openPanel(ann.id); 44943d2073cStracker-user }); 45043d2073cStracker-user range.surroundContents(span); 45143d2073cStracker-user ann._highlightEl = span; 45243d2073cStracker-user } catch (e) { 45343d2073cStracker-user // surroundContents throws if the range crosses element boundaries. 45443d2073cStracker-user // Fall back to insertNode with a cloned range fragment. 45543d2073cStracker-user try { 45643d2073cStracker-user var frag = range.extractContents(); 45743d2073cStracker-user var span2 = document.createElement('span'); 45843d2073cStracker-user span2.className = ann.status === 'resolved' 45943d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 46043d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 46143d2073cStracker-user span2.dataset.annId = ann.id; 46243d2073cStracker-user span2.appendChild(frag); 46343d2073cStracker-user span2.addEventListener('click', function (e) { 46443d2073cStracker-user e.stopPropagation(); 46543d2073cStracker-user openPanel(ann.id); 46643d2073cStracker-user }); 46743d2073cStracker-user range.insertNode(span2); 46843d2073cStracker-user ann._highlightEl = span2; 46943d2073cStracker-user } catch (e2) { 47043d2073cStracker-user ann._highlightEl = null; 47143d2073cStracker-user } 47243d2073cStracker-user } 47343d2073cStracker-user } 47443d2073cStracker-user 47543d2073cStracker-user /** 47643d2073cStracker-user * Remove all highlight spans, restoring the original text nodes. 47743d2073cStracker-user */ 47843d2073cStracker-user function clearHighlights() { 47943d2073cStracker-user var spans = document.querySelectorAll( 480*da56206cStracker-user '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 48143d2073cStracker-user ); 48243d2073cStracker-user Array.prototype.forEach.call(spans, function (span) { 48343d2073cStracker-user var parent = span.parentNode; 48443d2073cStracker-user if (!parent) return; 48543d2073cStracker-user while (span.firstChild) { 48643d2073cStracker-user parent.insertBefore(span.firstChild, span); 48743d2073cStracker-user } 48843d2073cStracker-user parent.removeChild(span); 48943d2073cStracker-user parent.normalize(); 49043d2073cStracker-user }); 49143d2073cStracker-user } 49243d2073cStracker-user 49343d2073cStracker-user // ----------------------------------------------------------------------- 49443d2073cStracker-user // Gutter markers 49543d2073cStracker-user // ----------------------------------------------------------------------- 49643d2073cStracker-user 49743d2073cStracker-user /** 49843d2073cStracker-user * Render a small marker in the gutter for every anchored annotation. 49943d2073cStracker-user * Markers are absolutely positioned relative to the content wrapper. 50043d2073cStracker-user */ 50143d2073cStracker-user function renderGutterMarkers() { 502b8076f00Stracker-user // Append markers to .page (position:relative), not #dokuwiki__content 503b8076f00Stracker-user // (which also wraps the sidebar nav and would capture pointer events). 504b8076f00Stracker-user var pageEl = document.querySelector('.' + PAGE_CLS); 505b8076f00Stracker-user if (!pageEl) return; 50643d2073cStracker-user 50743d2073cStracker-user _annotations.forEach(function (ann) { 50843d2073cStracker-user if (!ann._highlightEl) return; // orphan 50943d2073cStracker-user 51043d2073cStracker-user var el = ann._highlightEl; 51143d2073cStracker-user var rect = el.getBoundingClientRect(); 512b8076f00Stracker-user var pageRect = pageEl.getBoundingClientRect(); 51343d2073cStracker-user 51443d2073cStracker-user var marker = document.createElement('button'); 51543d2073cStracker-user marker.className = CLS_GUTTER_MARKER; 51643d2073cStracker-user marker.dataset.annId = ann.id; 517*da56206cStracker-user marker.setAttribute('aria-label', t('label_annotation', 'Annotation')); 51843d2073cStracker-user marker.type = 'button'; 519b8076f00Stracker-user // top is relative to .page's top edge + its current scroll offset 520b8076f00Stracker-user marker.style.top = (rect.top - pageRect.top + pageEl.scrollTop) + 'px'; 52143d2073cStracker-user marker.addEventListener('click', function (e) { 52243d2073cStracker-user e.stopPropagation(); 52343d2073cStracker-user openPanel(ann.id); 52443d2073cStracker-user }); 525b8076f00Stracker-user pageEl.appendChild(marker); 52643d2073cStracker-user ann._markerEl = marker; 52743d2073cStracker-user }); 52843d2073cStracker-user } 52943d2073cStracker-user 53043d2073cStracker-user /** 53143d2073cStracker-user * Remove all gutter markers. 53243d2073cStracker-user */ 53343d2073cStracker-user function clearGutterMarkers() { 53443d2073cStracker-user var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 53543d2073cStracker-user Array.prototype.forEach.call(markers, function (m) { 53643d2073cStracker-user if (m.parentNode) m.parentNode.removeChild(m); 53743d2073cStracker-user }); 53843d2073cStracker-user } 53943d2073cStracker-user 54043d2073cStracker-user // ----------------------------------------------------------------------- 54143d2073cStracker-user // Page counter 54243d2073cStracker-user // ----------------------------------------------------------------------- 54343d2073cStracker-user 54443d2073cStracker-user /** 54543d2073cStracker-user * Render (or update) the counter bubble above the content area. 54643d2073cStracker-user * 54743d2073cStracker-user * @param {object} stats {total, open, resolved} 54843d2073cStracker-user * @param {number} orphanCount 54943d2073cStracker-user */ 55043d2073cStracker-user function renderCounter(stats, orphanCount) { 55143d2073cStracker-user var existing = document.getElementById('ann-counter-bar'); 55243d2073cStracker-user if (existing) existing.parentNode.removeChild(existing); 55343d2073cStracker-user 55443d2073cStracker-user if (stats.total === 0 && orphanCount === 0) return; 55543d2073cStracker-user 55643d2073cStracker-user var bar = document.createElement('div'); 55743d2073cStracker-user bar.id = 'ann-counter-bar'; 55843d2073cStracker-user bar.className = CLS_COUNTER; 55943d2073cStracker-user 56043d2073cStracker-user var total = stats.total || 0; 56143d2073cStracker-user var label = total === 1 562*da56206cStracker-user ? t('counter_annotation', '1 annotation') 563*da56206cStracker-user : fmt(t('counter_annotations', '%d annotations'), total); 56443d2073cStracker-user bar.appendChild(document.createTextNode(label)); 56543d2073cStracker-user 56643d2073cStracker-user if (orphanCount > 0) { 56743d2073cStracker-user bar.appendChild(document.createTextNode(' · ')); 56843d2073cStracker-user var orphanLink = document.createElement('a'); 56943d2073cStracker-user orphanLink.href = '#ann-orphan-drawer'; 57043d2073cStracker-user orphanLink.className = 'ann-orphan-link'; 571*da56206cStracker-user orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount); 57243d2073cStracker-user orphanLink.addEventListener('click', function (e) { 57343d2073cStracker-user e.preventDefault(); 57443d2073cStracker-user toggleOrphanDrawer(); 57543d2073cStracker-user }); 57643d2073cStracker-user bar.appendChild(orphanLink); 57743d2073cStracker-user } 57843d2073cStracker-user 57943d2073cStracker-user if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 58043d2073cStracker-user if (stats.resolved > 0) { 58143d2073cStracker-user var btnCR = document.createElement('button'); 58243d2073cStracker-user btnCR.type = 'button'; 58343d2073cStracker-user btnCR.className = 'ann-btn ann-btn-admin'; 584*da56206cStracker-user btnCR.textContent = t('btn_clear_resolved', 'Clear resolved'); 58543d2073cStracker-user btnCR.addEventListener('click', doClearResolved); 58643d2073cStracker-user bar.appendChild(btnCR); 58743d2073cStracker-user } 58843d2073cStracker-user if (orphanCount > 0) { 58943d2073cStracker-user var btnCO = document.createElement('button'); 59043d2073cStracker-user btnCO.type = 'button'; 59143d2073cStracker-user btnCO.className = 'ann-btn ann-btn-admin'; 592*da56206cStracker-user btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned'); 59343d2073cStracker-user btnCO.addEventListener('click', doClearOrphaned); 59443d2073cStracker-user bar.appendChild(btnCO); 59543d2073cStracker-user } 59643d2073cStracker-user } 59743d2073cStracker-user 59843d2073cStracker-user var content = document.getElementById(CONTENT_ID); 59943d2073cStracker-user if (content && content.parentNode) { 60043d2073cStracker-user content.parentNode.insertBefore(bar, content); 60143d2073cStracker-user } 60243d2073cStracker-user } 60343d2073cStracker-user 60443d2073cStracker-user /** 60543d2073cStracker-user * Recount and re-render the counter from in-memory state. 60643d2073cStracker-user */ 60743d2073cStracker-user function updateCounter(orphanCount) { 60843d2073cStracker-user var open = 0, resolved = 0; 60943d2073cStracker-user if (orphanCount === undefined) { 61043d2073cStracker-user orphanCount = 0; 61143d2073cStracker-user } 61243d2073cStracker-user _annotations.forEach(function (ann) { 61343d2073cStracker-user if (ann._orphaned) { 61443d2073cStracker-user orphanCount++; 61543d2073cStracker-user } else if (ann.status === 'resolved') { 61643d2073cStracker-user resolved++; 61743d2073cStracker-user } else { 61843d2073cStracker-user open++; 61943d2073cStracker-user } 62043d2073cStracker-user }); 62143d2073cStracker-user renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 62243d2073cStracker-user } 62343d2073cStracker-user 62443d2073cStracker-user // ----------------------------------------------------------------------- 62543d2073cStracker-user // Annotation panel 62643d2073cStracker-user // ----------------------------------------------------------------------- 62743d2073cStracker-user 62843d2073cStracker-user /** 62943d2073cStracker-user * Open the thread panel for the given annotation id. 63043d2073cStracker-user * If that panel is already open, close it. 63143d2073cStracker-user * 63243d2073cStracker-user * @param {string} annId 63343d2073cStracker-user */ 63443d2073cStracker-user function openPanel(annId) { 63543d2073cStracker-user if (_openAnnId === annId) { 63643d2073cStracker-user closePanel(); 63743d2073cStracker-user return; 63843d2073cStracker-user } 63943d2073cStracker-user closePanel(); 64043d2073cStracker-user 64143d2073cStracker-user var ann = _annotations.get(annId); 64243d2073cStracker-user if (!ann) return; 64343d2073cStracker-user 64443d2073cStracker-user var panel = buildPanel(ann); 64543d2073cStracker-user _openPanel = panel; 64643d2073cStracker-user _openAnnId = annId; 64743d2073cStracker-user 64843d2073cStracker-user // Insert below the paragraph that contains the highlight. 64943d2073cStracker-user var anchor = ann._highlightEl || null; 65043d2073cStracker-user var insertAfter = findParagraph(anchor); 65143d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 65243d2073cStracker-user insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 65343d2073cStracker-user } else { 65443d2073cStracker-user // Orphan or no paragraph found: show at the bottom of content. 65543d2073cStracker-user var content = document.getElementById(CONTENT_ID); 65643d2073cStracker-user if (content) content.appendChild(panel); 65743d2073cStracker-user } 65843d2073cStracker-user 65943d2073cStracker-user panel.querySelector('.ann-body-input') && panel.querySelector('.ann-body-input').focus(); 66043d2073cStracker-user } 66143d2073cStracker-user 66243d2073cStracker-user /** 66343d2073cStracker-user * Close and remove the currently open panel. 66443d2073cStracker-user */ 66543d2073cStracker-user function closePanel() { 66643d2073cStracker-user if (_openPanel && _openPanel.parentNode) { 66743d2073cStracker-user _openPanel.parentNode.removeChild(_openPanel); 66843d2073cStracker-user } 66943d2073cStracker-user _openPanel = null; 67043d2073cStracker-user _openAnnId = null; 67143d2073cStracker-user } 67243d2073cStracker-user 67343d2073cStracker-user /** 67443d2073cStracker-user * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 67543d2073cStracker-user * that can receive a sibling element. 67643d2073cStracker-user * 67743d2073cStracker-user * @param {HTMLElement|null} el 67843d2073cStracker-user * @returns {HTMLElement|null} 67943d2073cStracker-user */ 68043d2073cStracker-user function findParagraph(el) { 68143d2073cStracker-user var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 68243d2073cStracker-user var node = el; 68343d2073cStracker-user while (node && node.id !== CONTENT_ID) { 68443d2073cStracker-user if (node.nodeType === 1 && block.test(node.tagName)) { 68543d2073cStracker-user return node; 68643d2073cStracker-user } 68743d2073cStracker-user node = node.parentNode; 68843d2073cStracker-user } 68943d2073cStracker-user return el; // fallback: use the element itself 69043d2073cStracker-user } 69143d2073cStracker-user 69243d2073cStracker-user /** 69343d2073cStracker-user * Build and return the panel DOM element for one annotation. 69443d2073cStracker-user * 69543d2073cStracker-user * @param {object} ann 69643d2073cStracker-user * @returns {HTMLElement} 69743d2073cStracker-user */ 69843d2073cStracker-user function buildPanel(ann) { 69943d2073cStracker-user var panel = document.createElement('div'); 70043d2073cStracker-user panel.className = CLS_PANEL; 70143d2073cStracker-user panel.dataset.annId = ann.id; 702*da56206cStracker-user panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css 70343d2073cStracker-user 70443d2073cStracker-user // Header 70543d2073cStracker-user var header = document.createElement('div'); 70643d2073cStracker-user header.className = 'ann-panel-header'; 70743d2073cStracker-user 70843d2073cStracker-user var closeBtn = document.createElement('button'); 70943d2073cStracker-user closeBtn.type = 'button'; 71043d2073cStracker-user closeBtn.className = 'ann-btn ann-close'; 711*da56206cStracker-user closeBtn.setAttribute('aria-label', t('label_close', 'Close')); 71243d2073cStracker-user closeBtn.textContent = '×'; 71343d2073cStracker-user closeBtn.addEventListener('click', closePanel); 71443d2073cStracker-user header.appendChild(closeBtn); 71543d2073cStracker-user 71643d2073cStracker-user panel.appendChild(header); 71743d2073cStracker-user 71843d2073cStracker-user // Main annotation thread entry 71943d2073cStracker-user panel.appendChild(buildThreadEntry(ann, true)); 72043d2073cStracker-user 72143d2073cStracker-user // Replies 72243d2073cStracker-user (ann.replies || []).forEach(function (reply) { 72343d2073cStracker-user panel.appendChild(buildReplyEntry(ann, reply)); 72443d2073cStracker-user }); 72543d2073cStracker-user 72643d2073cStracker-user // Reply form (if logged in and has read access — gate is server-side anyway) 72743d2073cStracker-user if (_loggedIn) { 72843d2073cStracker-user panel.appendChild(buildReplyForm(ann)); 72943d2073cStracker-user } 73043d2073cStracker-user 73143d2073cStracker-user return panel; 73243d2073cStracker-user } 73343d2073cStracker-user 73443d2073cStracker-user /** 73543d2073cStracker-user * Build the DOM for the top-level annotation entry. 73643d2073cStracker-user * 73743d2073cStracker-user * @param {object} ann 73843d2073cStracker-user * @param {boolean} isRoot true for the annotation itself, false for replies 73943d2073cStracker-user * @returns {HTMLElement} 74043d2073cStracker-user */ 74143d2073cStracker-user function buildThreadEntry(ann, isRoot) { 74243d2073cStracker-user var entry = document.createElement('div'); 74343d2073cStracker-user entry.className = 'ann-thread-entry ann-annotation'; 74443d2073cStracker-user entry.dataset.annId = ann.id; 74543d2073cStracker-user 74643d2073cStracker-user // Meta row: avatar, author, time, status pill 74743d2073cStracker-user entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 74843d2073cStracker-user 74943d2073cStracker-user // Body 75043d2073cStracker-user var bodyEl = document.createElement('div'); 75143d2073cStracker-user bodyEl.className = 'ann-body'; 75243d2073cStracker-user bodyEl.textContent = ann.body; 75343d2073cStracker-user entry.appendChild(bodyEl); 75443d2073cStracker-user 75543d2073cStracker-user // Quoted text snippet 75643d2073cStracker-user if (ann.anchor && ann.anchor.exact) { 75743d2073cStracker-user var quote = document.createElement('blockquote'); 75843d2073cStracker-user quote.className = 'ann-quote'; 75943d2073cStracker-user quote.textContent = ann.anchor.exact; 76043d2073cStracker-user entry.appendChild(quote); 76143d2073cStracker-user } 76243d2073cStracker-user 76343d2073cStracker-user // Action buttons 76443d2073cStracker-user var actions = document.createElement('div'); 76543d2073cStracker-user actions.className = 'ann-actions'; 76643d2073cStracker-user 76743d2073cStracker-user // Resolve/Reopen (any reader) 76843d2073cStracker-user if (_loggedIn) { 76943d2073cStracker-user var resolveBtn = document.createElement('button'); 77043d2073cStracker-user resolveBtn.type = 'button'; 77143d2073cStracker-user resolveBtn.className = 'ann-btn ann-btn-resolve'; 772*da56206cStracker-user resolveBtn.textContent = ann.status === 'resolved' 773*da56206cStracker-user ? t('btn_reopen', 'Reopen') 774*da56206cStracker-user : t('btn_resolve', 'Resolve'); 77543d2073cStracker-user resolveBtn.addEventListener('click', function () { 77643d2073cStracker-user doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved'); 77743d2073cStracker-user }); 77843d2073cStracker-user actions.appendChild(resolveBtn); 77943d2073cStracker-user } 78043d2073cStracker-user 78143d2073cStracker-user // Edit + Delete (own or admin) 78243d2073cStracker-user var canEdit = _isAdmin || ann.author === currentUser(); 78343d2073cStracker-user if (canEdit && _loggedIn) { 78443d2073cStracker-user var editBtn = document.createElement('button'); 78543d2073cStracker-user editBtn.type = 'button'; 78643d2073cStracker-user editBtn.className = 'ann-btn'; 787*da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 78843d2073cStracker-user editBtn.addEventListener('click', function () { 78943d2073cStracker-user showEditForm(entry, ann, 'annotation'); 79043d2073cStracker-user }); 79143d2073cStracker-user actions.appendChild(editBtn); 79243d2073cStracker-user 79343d2073cStracker-user var delBtn = document.createElement('button'); 79443d2073cStracker-user delBtn.type = 'button'; 79543d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 796*da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 79743d2073cStracker-user delBtn.addEventListener('click', function () { 798*da56206cStracker-user if (confirm(t('confirm_delete', 'Delete this annotation?'))) { 79943d2073cStracker-user doDeleteAnnotation(ann.id); 80043d2073cStracker-user } 80143d2073cStracker-user }); 80243d2073cStracker-user actions.appendChild(delBtn); 80343d2073cStracker-user } 80443d2073cStracker-user 80543d2073cStracker-user entry.appendChild(actions); 80643d2073cStracker-user return entry; 80743d2073cStracker-user } 80843d2073cStracker-user 80943d2073cStracker-user /** 81043d2073cStracker-user * Build the DOM for one reply entry. 81143d2073cStracker-user * 81243d2073cStracker-user * @param {object} ann parent annotation 81343d2073cStracker-user * @param {object} reply 81443d2073cStracker-user * @returns {HTMLElement} 81543d2073cStracker-user */ 81643d2073cStracker-user function buildReplyEntry(ann, reply) { 81743d2073cStracker-user var entry = document.createElement('div'); 81843d2073cStracker-user entry.className = 'ann-thread-entry ann-reply'; 81943d2073cStracker-user entry.dataset.replyId = reply.id; 82043d2073cStracker-user 82143d2073cStracker-user entry.appendChild(buildMeta(reply.author, reply.created, null)); 82243d2073cStracker-user 82343d2073cStracker-user var bodyEl = document.createElement('div'); 82443d2073cStracker-user bodyEl.className = 'ann-body'; 82543d2073cStracker-user bodyEl.textContent = reply.body; 82643d2073cStracker-user entry.appendChild(bodyEl); 82743d2073cStracker-user 82843d2073cStracker-user var actions = document.createElement('div'); 82943d2073cStracker-user actions.className = 'ann-actions'; 83043d2073cStracker-user 83143d2073cStracker-user var canEdit = _isAdmin || reply.author === currentUser(); 83243d2073cStracker-user if (canEdit && _loggedIn) { 83343d2073cStracker-user var editBtn = document.createElement('button'); 83443d2073cStracker-user editBtn.type = 'button'; 83543d2073cStracker-user editBtn.className = 'ann-btn'; 836*da56206cStracker-user editBtn.textContent = t('btn_edit', 'Edit'); 83743d2073cStracker-user editBtn.addEventListener('click', function () { 83843d2073cStracker-user showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 83943d2073cStracker-user }); 84043d2073cStracker-user actions.appendChild(editBtn); 84143d2073cStracker-user 84243d2073cStracker-user var delBtn = document.createElement('button'); 84343d2073cStracker-user delBtn.type = 'button'; 84443d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 845*da56206cStracker-user delBtn.textContent = t('btn_delete', 'Delete'); 84643d2073cStracker-user delBtn.addEventListener('click', function () { 847*da56206cStracker-user if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) { 84843d2073cStracker-user doDeleteReply(ann.id, reply.id); 84943d2073cStracker-user } 85043d2073cStracker-user }); 85143d2073cStracker-user actions.appendChild(delBtn); 85243d2073cStracker-user } 85343d2073cStracker-user 85443d2073cStracker-user entry.appendChild(actions); 85543d2073cStracker-user return entry; 85643d2073cStracker-user } 85743d2073cStracker-user 85843d2073cStracker-user /** 85943d2073cStracker-user * Build the meta row (avatar initials, author name, timestamp, status pill). 86043d2073cStracker-user * 86143d2073cStracker-user * @param {string} author 86243d2073cStracker-user * @param {number} timestamp Unix seconds 86343d2073cStracker-user * @param {string|null} status 'open'|'resolved'|null 86443d2073cStracker-user * @returns {HTMLElement} 86543d2073cStracker-user */ 86643d2073cStracker-user function buildMeta(author, timestamp, status) { 86743d2073cStracker-user var meta = document.createElement('div'); 86843d2073cStracker-user meta.className = 'ann-meta'; 86943d2073cStracker-user 87043d2073cStracker-user var avatar = document.createElement('span'); 87143d2073cStracker-user avatar.className = 'ann-avatar'; 87243d2073cStracker-user avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 87343d2073cStracker-user meta.appendChild(avatar); 87443d2073cStracker-user 87543d2073cStracker-user var authorEl = document.createElement('span'); 87643d2073cStracker-user authorEl.className = 'ann-author'; 877*da56206cStracker-user authorEl.textContent = author || t('label_unknown', 'Unknown'); 87843d2073cStracker-user meta.appendChild(authorEl); 87943d2073cStracker-user 88043d2073cStracker-user var timeEl = document.createElement('time'); 88143d2073cStracker-user timeEl.className = 'ann-time'; 88243d2073cStracker-user var d = new Date(timestamp * 1000); 88343d2073cStracker-user timeEl.dateTime = d.toISOString(); 88443d2073cStracker-user timeEl.textContent = formatDate(d); 88543d2073cStracker-user meta.appendChild(timeEl); 88643d2073cStracker-user 88743d2073cStracker-user if (status) { 88843d2073cStracker-user var pill = document.createElement('span'); 88943d2073cStracker-user pill.className = 'ann-status ann-status-' + status; 890*da56206cStracker-user pill.textContent = status === 'resolved' 891*da56206cStracker-user ? t('status_resolved', 'Resolved') 892*da56206cStracker-user : t('status_open', 'Open'); 89343d2073cStracker-user meta.appendChild(pill); 89443d2073cStracker-user } 89543d2073cStracker-user 89643d2073cStracker-user return meta; 89743d2073cStracker-user } 89843d2073cStracker-user 89943d2073cStracker-user /** 90043d2073cStracker-user * Build a reply form at the bottom of the panel. 90143d2073cStracker-user * 90243d2073cStracker-user * @param {object} ann 90343d2073cStracker-user * @returns {HTMLElement} 90443d2073cStracker-user */ 90543d2073cStracker-user function buildReplyForm(ann) { 90643d2073cStracker-user var form = document.createElement('div'); 90743d2073cStracker-user form.className = 'ann-reply-form'; 90843d2073cStracker-user 90943d2073cStracker-user var ta = document.createElement('textarea'); 91043d2073cStracker-user ta.className = 'ann-body-input'; 911*da56206cStracker-user ta.placeholder = t('placeholder_reply', 'Write a reply…'); 91243d2073cStracker-user ta.rows = 3; 91343d2073cStracker-user form.appendChild(ta); 91443d2073cStracker-user 91543d2073cStracker-user var row = document.createElement('div'); 91643d2073cStracker-user row.className = 'ann-form-row'; 91743d2073cStracker-user 91843d2073cStracker-user var submitBtn = document.createElement('button'); 91943d2073cStracker-user submitBtn.type = 'button'; 92043d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 921*da56206cStracker-user submitBtn.textContent = t('btn_reply', 'Reply'); 92243d2073cStracker-user submitBtn.addEventListener('click', function () { 92343d2073cStracker-user var body = ta.value.trim(); 92443d2073cStracker-user if (!body) return; 92543d2073cStracker-user doAddReply(ann.id, body, function () { 92643d2073cStracker-user ta.value = ''; 92743d2073cStracker-user }); 92843d2073cStracker-user }); 92943d2073cStracker-user row.appendChild(submitBtn); 93043d2073cStracker-user form.appendChild(row); 93143d2073cStracker-user 93243d2073cStracker-user return form; 93343d2073cStracker-user } 93443d2073cStracker-user 93543d2073cStracker-user /** 93643d2073cStracker-user * Replace the body of an entry with an inline edit form. 93743d2073cStracker-user * 93843d2073cStracker-user * @param {HTMLElement} entry 93943d2073cStracker-user * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 94043d2073cStracker-user * @param {string} type 'annotation' | 'reply' 94143d2073cStracker-user */ 94243d2073cStracker-user function showEditForm(entry, data, type) { 94343d2073cStracker-user var bodyEl = entry.querySelector('.ann-body'); 94443d2073cStracker-user if (!bodyEl) return; 94543d2073cStracker-user 94643d2073cStracker-user var ta = document.createElement('textarea'); 94743d2073cStracker-user ta.className = 'ann-body-input'; 94843d2073cStracker-user ta.value = data.body || ''; 94943d2073cStracker-user ta.rows = 4; 95043d2073cStracker-user 95143d2073cStracker-user var row = document.createElement('div'); 95243d2073cStracker-user row.className = 'ann-form-row'; 95343d2073cStracker-user 95443d2073cStracker-user var saveBtn = document.createElement('button'); 95543d2073cStracker-user saveBtn.type = 'button'; 95643d2073cStracker-user saveBtn.className = 'ann-btn ann-btn-primary'; 957*da56206cStracker-user saveBtn.textContent = t('btn_save', 'Save'); 95843d2073cStracker-user saveBtn.addEventListener('click', function () { 95943d2073cStracker-user var newBody = ta.value.trim(); 96043d2073cStracker-user if (!newBody) return; 96143d2073cStracker-user if (type === 'annotation') { 96243d2073cStracker-user doEditAnnotation(data.id || _openAnnId, newBody); 96343d2073cStracker-user } else { 96443d2073cStracker-user doEditReply(data.annId, data.replyId, newBody); 96543d2073cStracker-user } 96643d2073cStracker-user }); 96743d2073cStracker-user 96843d2073cStracker-user var cancelBtn = document.createElement('button'); 96943d2073cStracker-user cancelBtn.type = 'button'; 97043d2073cStracker-user cancelBtn.className = 'ann-btn'; 971*da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 97243d2073cStracker-user cancelBtn.addEventListener('click', function () { 97343d2073cStracker-user entry.removeChild(ta); 97443d2073cStracker-user entry.removeChild(row); 97543d2073cStracker-user bodyEl.style.display = ''; 97643d2073cStracker-user }); 97743d2073cStracker-user 97843d2073cStracker-user row.appendChild(saveBtn); 97943d2073cStracker-user row.appendChild(cancelBtn); 98043d2073cStracker-user 98143d2073cStracker-user bodyEl.style.display = 'none'; 98243d2073cStracker-user entry.insertBefore(ta, bodyEl.nextSibling); 98343d2073cStracker-user entry.insertBefore(row, ta.nextSibling); 98443d2073cStracker-user ta.focus(); 98543d2073cStracker-user } 98643d2073cStracker-user 98743d2073cStracker-user // ----------------------------------------------------------------------- 98843d2073cStracker-user // Orphan drawer 98943d2073cStracker-user // ----------------------------------------------------------------------- 99043d2073cStracker-user 99143d2073cStracker-user /** 99243d2073cStracker-user * Toggle the orphan drawer visibility. 99343d2073cStracker-user */ 99443d2073cStracker-user function toggleOrphanDrawer() { 99543d2073cStracker-user var drawer = document.getElementById('ann-orphan-drawer'); 99643d2073cStracker-user if (drawer) { 99743d2073cStracker-user drawer.parentNode.removeChild(drawer); 99843d2073cStracker-user return; 99943d2073cStracker-user } 100043d2073cStracker-user renderOrphanDrawer(); 100143d2073cStracker-user } 100243d2073cStracker-user 100343d2073cStracker-user /** 100443d2073cStracker-user * Build and insert the orphan drawer at the bottom of the content area. 100543d2073cStracker-user */ 100643d2073cStracker-user function renderOrphanDrawer() { 100743d2073cStracker-user var content = document.getElementById(CONTENT_ID); 100843d2073cStracker-user if (!content) return; 100943d2073cStracker-user 101043d2073cStracker-user var drawer = document.createElement('div'); 101143d2073cStracker-user drawer.id = 'ann-orphan-drawer'; 101243d2073cStracker-user drawer.className = CLS_ORPHAN_DRAWER; 101343d2073cStracker-user 101443d2073cStracker-user var heading = document.createElement('h4'); 1015*da56206cStracker-user heading.textContent = t('orphaned_heading', 'Orphaned annotations'); 101643d2073cStracker-user drawer.appendChild(heading); 101743d2073cStracker-user 101843d2073cStracker-user var note = document.createElement('p'); 101943d2073cStracker-user note.className = 'ann-orphan-note'; 1020*da56206cStracker-user note.textContent = t('orphaned_note', 1021*da56206cStracker-user 'These annotations reference text that no longer appears on the page.'); 102243d2073cStracker-user drawer.appendChild(note); 102343d2073cStracker-user 102443d2073cStracker-user var found = false; 102543d2073cStracker-user _annotations.forEach(function (ann) { 102643d2073cStracker-user if (!ann._orphaned) return; 102743d2073cStracker-user found = true; 102843d2073cStracker-user var entry = buildThreadEntry(ann, true); 102943d2073cStracker-user drawer.appendChild(entry); 103043d2073cStracker-user }); 103143d2073cStracker-user 103243d2073cStracker-user if (!found) { 103343d2073cStracker-user var empty = document.createElement('p'); 1034*da56206cStracker-user empty.textContent = t('orphaned_none', 'None.'); 103543d2073cStracker-user drawer.appendChild(empty); 103643d2073cStracker-user } 103743d2073cStracker-user 103843d2073cStracker-user content.appendChild(drawer); 103943d2073cStracker-user } 104043d2073cStracker-user 104143d2073cStracker-user // ----------------------------------------------------------------------- 104243d2073cStracker-user // Selection capture 104343d2073cStracker-user // ----------------------------------------------------------------------- 104443d2073cStracker-user 104543d2073cStracker-user /** 104643d2073cStracker-user * Wire up mouseup/touchend listeners to detect text selection. 104743d2073cStracker-user * 104843d2073cStracker-user * @param {HTMLElement} content 104943d2073cStracker-user */ 105043d2073cStracker-user function initSelectionCapture(content) { 105143d2073cStracker-user if (!_loggedIn) return; // anonymous users cannot annotate 105243d2073cStracker-user 105343d2073cStracker-user document.addEventListener('mouseup', function (e) { 105443d2073cStracker-user handleSelectionEnd(e, content); 105543d2073cStracker-user }); 105643d2073cStracker-user document.addEventListener('touchend', function (e) { 105743d2073cStracker-user // Small delay so the browser has committed the selection. 105843d2073cStracker-user setTimeout(function () { handleSelectionEnd(e, content); }, 50); 105943d2073cStracker-user }); 106043d2073cStracker-user 106150325813Stracker-user // Close tooltip on click outside (but not when clicking the new-form). 106243d2073cStracker-user document.addEventListener('mousedown', function (e) { 106343d2073cStracker-user var tooltip = document.getElementById('ann-tooltip'); 106443d2073cStracker-user if (tooltip && !tooltip.contains(e.target)) { 106550325813Stracker-user var naf = document.getElementById('ann-new-form'); 106650325813Stracker-user if (!naf || !naf.contains(e.target)) { 106743d2073cStracker-user hideTooltip(); 106843d2073cStracker-user } 106950325813Stracker-user } 107043d2073cStracker-user }); 107143d2073cStracker-user } 107243d2073cStracker-user 107343d2073cStracker-user /** 107443d2073cStracker-user * Handle end of selection: show the "Annotate" tooltip if there is a 107543d2073cStracker-user * non-empty selection inside the content area. 107643d2073cStracker-user * 107743d2073cStracker-user * @param {Event} e 107843d2073cStracker-user * @param {HTMLElement} content 107943d2073cStracker-user */ 108043d2073cStracker-user function handleSelectionEnd(e, content) { 108143d2073cStracker-user var sel = window.getSelection(); 108243d2073cStracker-user if (!sel || sel.isCollapsed) { 108350325813Stracker-user // Don't hide the tooltip if the mouseup came from inside it — 108450325813Stracker-user // the click handler is responsible for cleanup in that case. 108550325813Stracker-user var tip = document.getElementById('ann-tooltip'); 108650325813Stracker-user if (tip && tip.contains(e.target)) { 108750325813Stracker-user return; 108850325813Stracker-user } 108950325813Stracker-user // Don't hide if a new-annotation form is open (user clicked 109050325813Stracker-user // inside the form, collapsing the original selection). 109150325813Stracker-user var naf = document.getElementById('ann-new-form'); 109250325813Stracker-user if (naf && naf.contains(e.target)) { 109350325813Stracker-user return; 109450325813Stracker-user } 109543d2073cStracker-user hideTooltip(); 109643d2073cStracker-user return; 109743d2073cStracker-user } 109843d2073cStracker-user var range = sel.getRangeAt(0); 109943d2073cStracker-user if (!content.contains(range.commonAncestorContainer)) { 110043d2073cStracker-user hideTooltip(); 110143d2073cStracker-user return; 110243d2073cStracker-user } 110343d2073cStracker-user var text = sel.toString().trim(); 110443d2073cStracker-user if (text.length < 1) { 110543d2073cStracker-user hideTooltip(); 110643d2073cStracker-user return; 110743d2073cStracker-user } 110843d2073cStracker-user 110950325813Stracker-user // If the tooltip is already showing (e.g. user moused up after 111050325813Stracker-user // pressing the Annotate button), don't replace it with a fresh one — 111150325813Stracker-user // that would orphan the button mid-click and break the click handler. 111250325813Stracker-user if (document.getElementById('ann-tooltip')) { 111350325813Stracker-user return; 111450325813Stracker-user } 111550325813Stracker-user 111643d2073cStracker-user // Show the tooltip near the end of the selection. 111743d2073cStracker-user var rect = range.getBoundingClientRect(); 111843d2073cStracker-user showTooltip(rect, range, sel, content); 111943d2073cStracker-user } 112043d2073cStracker-user 112143d2073cStracker-user /** 112243d2073cStracker-user * Show the "Annotate" tooltip bubble. 112343d2073cStracker-user * 112443d2073cStracker-user * @param {DOMRect} rect bounding rect of the selection 112543d2073cStracker-user * @param {Range} range 112643d2073cStracker-user * @param {Selection} sel 112743d2073cStracker-user * @param {HTMLElement} content 112843d2073cStracker-user */ 112943d2073cStracker-user function showTooltip(rect, range, sel, content) { 113043d2073cStracker-user hideTooltip(); 113143d2073cStracker-user 113243d2073cStracker-user var tip = document.createElement('div'); 113343d2073cStracker-user tip.id = 'ann-tooltip'; 113443d2073cStracker-user tip.className = CLS_TOOLTIP; 113543d2073cStracker-user 113650325813Stracker-user // Capture the anchor on mousedown while the selection is guaranteed 113750325813Stracker-user // to still exist. By the time 'click' fires, many browsers have 113850325813Stracker-user // already collapsed the selection, so captureAnchor would return null. 113950325813Stracker-user // _pendingAnchor is module-level so it survives tooltip replacement. 114043d2073cStracker-user var btn = document.createElement('button'); 114143d2073cStracker-user btn.type = 'button'; 1142*da56206cStracker-user btn.textContent = t('btn_annotate', 'Annotate'); 114343d2073cStracker-user btn.className = 'ann-btn ann-btn-primary'; 114443d2073cStracker-user btn.addEventListener('mousedown', function (e) { 114550325813Stracker-user e.preventDefault(); // prevent focus-change deselection 114650325813Stracker-user // Capture now, while the selection is still intact. 114750325813Stracker-user _pendingAnchor = captureAnchor(sel, range, content); 114843d2073cStracker-user }); 114943d2073cStracker-user btn.addEventListener('click', function () { 115050325813Stracker-user var anchor = _pendingAnchor; 115150325813Stracker-user _pendingAnchor = null; 115243d2073cStracker-user hideTooltip(); 115343d2073cStracker-user if (anchor) { 115443d2073cStracker-user openNewAnnotationForm(anchor, range); 115543d2073cStracker-user } 115643d2073cStracker-user }); 115743d2073cStracker-user tip.appendChild(btn); 115843d2073cStracker-user 115943d2073cStracker-user document.body.appendChild(tip); 116043d2073cStracker-user 116143d2073cStracker-user // Position below the selection's end. 116243d2073cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 116343d2073cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 116443d2073cStracker-user tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 116543d2073cStracker-user tip.style.left = (rect.left + scrollLeft) + 'px'; 116643d2073cStracker-user } 116743d2073cStracker-user 116843d2073cStracker-user /** 116943d2073cStracker-user * Remove the tooltip if it exists. 117043d2073cStracker-user */ 117143d2073cStracker-user function hideTooltip() { 117243d2073cStracker-user var tip = document.getElementById('ann-tooltip'); 117343d2073cStracker-user if (tip && tip.parentNode) { 117443d2073cStracker-user tip.parentNode.removeChild(tip); 117543d2073cStracker-user } 117650325813Stracker-user // Note: ann-new-form is NOT removed here — it has its own Cancel 117750325813Stracker-user // button and must survive the mouseup that fires after the click. 117843d2073cStracker-user } 117943d2073cStracker-user 118043d2073cStracker-user /** 118143d2073cStracker-user * Capture an anchor object from the current Selection. 118243d2073cStracker-user * 118343d2073cStracker-user * @param {Selection} sel 118443d2073cStracker-user * @param {Range} range 118543d2073cStracker-user * @param {HTMLElement} content 118643d2073cStracker-user * @returns {object|null} {exact, prefix, suffix, start} 118743d2073cStracker-user */ 118843d2073cStracker-user function captureAnchor(sel, range, content) { 118943d2073cStracker-user var exact = normalizeWS(sel.toString()); 119043d2073cStracker-user if (!exact) return null; 119143d2073cStracker-user 119243d2073cStracker-user // Get full page text for prefix/suffix and start computation. 119343d2073cStracker-user var chunks = collectTextChunks(content); 119443d2073cStracker-user var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1195*da56206cStracker-user var nm = normalizeWithMap(fullRaw); 1196*da56206cStracker-user var fullNorm = nm.norm; 119743d2073cStracker-user 119843d2073cStracker-user // Find where this text node + offset lands in the raw full text. 119943d2073cStracker-user var rawStart = 0; 120043d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 120143d2073cStracker-user var c = chunks[i]; 120243d2073cStracker-user if (c.node === range.startContainer) { 120343d2073cStracker-user rawStart = c.start + range.startOffset; 120443d2073cStracker-user break; 120543d2073cStracker-user } 120643d2073cStracker-user } 120743d2073cStracker-user 1208*da56206cStracker-user // Map that raw offset to an offset in the normalised text, using the 1209*da56206cStracker-user // same map as re-anchoring so capture and find stay in agreement. 1210*da56206cStracker-user var normStart = nm.norm.length; 1211*da56206cStracker-user for (var j = 0; j < nm.map.length; j++) { 1212*da56206cStracker-user if (nm.map[j] >= rawStart) { 121343d2073cStracker-user normStart = j; 121443d2073cStracker-user break; 121543d2073cStracker-user } 121643d2073cStracker-user } 121743d2073cStracker-user 121843d2073cStracker-user var CTX = 30; 121943d2073cStracker-user var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 122043d2073cStracker-user var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 122143d2073cStracker-user 122243d2073cStracker-user return { 122343d2073cStracker-user exact: exact, 122443d2073cStracker-user prefix: prefix, 122543d2073cStracker-user suffix: suffix, 122643d2073cStracker-user start: normStart, 122743d2073cStracker-user }; 122843d2073cStracker-user } 122943d2073cStracker-user 123043d2073cStracker-user /** 123143d2073cStracker-user * Open the new-annotation form below the paragraph containing the selection. 123243d2073cStracker-user * 123343d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 123443d2073cStracker-user * @param {Range} range 123543d2073cStracker-user */ 123643d2073cStracker-user function openNewAnnotationForm(anchor, range) { 123743d2073cStracker-user closePanel(); 123843d2073cStracker-user 123943d2073cStracker-user var insertAfter = findParagraph(range.commonAncestorContainer); 124043d2073cStracker-user var form = document.createElement('div'); 124143d2073cStracker-user form.id = 'ann-new-form'; 124243d2073cStracker-user form.className = 'ann-new-form'; 124343d2073cStracker-user 124443d2073cStracker-user var quote = document.createElement('blockquote'); 124543d2073cStracker-user quote.className = 'ann-quote'; 124643d2073cStracker-user quote.textContent = anchor.exact; 124743d2073cStracker-user form.appendChild(quote); 124843d2073cStracker-user 124943d2073cStracker-user var ta = document.createElement('textarea'); 125043d2073cStracker-user ta.className = 'ann-body-input'; 1251*da56206cStracker-user ta.placeholder = t('placeholder_body', 'Add a comment…'); 125243d2073cStracker-user ta.rows = 4; 125343d2073cStracker-user form.appendChild(ta); 125443d2073cStracker-user 125543d2073cStracker-user var row = document.createElement('div'); 125643d2073cStracker-user row.className = 'ann-form-row'; 125743d2073cStracker-user 125843d2073cStracker-user var submitBtn = document.createElement('button'); 125943d2073cStracker-user submitBtn.type = 'button'; 126043d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1261*da56206cStracker-user submitBtn.textContent = t('btn_annotate', 'Annotate'); 126243d2073cStracker-user submitBtn.addEventListener('click', function () { 126343d2073cStracker-user var body = ta.value.trim(); 126443d2073cStracker-user if (!body) return; 126543d2073cStracker-user doCreate(anchor, body, function () { 126643d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 126743d2073cStracker-user }); 126843d2073cStracker-user }); 126943d2073cStracker-user 127043d2073cStracker-user var cancelBtn = document.createElement('button'); 127143d2073cStracker-user cancelBtn.type = 'button'; 127243d2073cStracker-user cancelBtn.className = 'ann-btn'; 1273*da56206cStracker-user cancelBtn.textContent = t('btn_cancel', 'Cancel'); 127443d2073cStracker-user cancelBtn.addEventListener('click', function () { 127543d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 127643d2073cStracker-user }); 127743d2073cStracker-user 127843d2073cStracker-user row.appendChild(submitBtn); 127943d2073cStracker-user row.appendChild(cancelBtn); 128043d2073cStracker-user form.appendChild(row); 128143d2073cStracker-user 128243d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 128343d2073cStracker-user insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 128443d2073cStracker-user } else { 128543d2073cStracker-user var content = document.getElementById(CONTENT_ID); 128643d2073cStracker-user if (content) content.appendChild(form); 128743d2073cStracker-user } 128843d2073cStracker-user 128943d2073cStracker-user ta.focus(); 129043d2073cStracker-user } 129143d2073cStracker-user 129243d2073cStracker-user // ----------------------------------------------------------------------- 129343d2073cStracker-user // AJAX actions 129443d2073cStracker-user // ----------------------------------------------------------------------- 129543d2073cStracker-user 129643d2073cStracker-user /** 129743d2073cStracker-user * POST create action and update state on success. 129843d2073cStracker-user * 129943d2073cStracker-user * @param {object} anchor 130043d2073cStracker-user * @param {string} body 130143d2073cStracker-user * @param {Function} onSuccess 130243d2073cStracker-user */ 130343d2073cStracker-user function doCreate(anchor, body, onSuccess) { 130443d2073cStracker-user ajax({ 130543d2073cStracker-user action: 'create', 130643d2073cStracker-user id: _info.pageId, 130743d2073cStracker-user anchor: anchor, 130843d2073cStracker-user body: body, 130943d2073cStracker-user }).then(function (data) { 131043d2073cStracker-user if (!data.success) { 1311*da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 131243d2073cStracker-user return; 131343d2073cStracker-user } 131443d2073cStracker-user var ann = data.annotation; 131543d2073cStracker-user _annotations.set(ann.id, ann); 131643d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(ann); 131743d2073cStracker-user renderAll(); 131843d2073cStracker-user }).catch(function () { 1319*da56206cStracker-user alert(t('error_save', 'Could not save — please try again.')); 132043d2073cStracker-user }); 132143d2073cStracker-user } 132243d2073cStracker-user 132343d2073cStracker-user /** 132443d2073cStracker-user * POST reply action and refresh the open panel. 132543d2073cStracker-user * 132643d2073cStracker-user * @param {string} annId 132743d2073cStracker-user * @param {string} body 132843d2073cStracker-user * @param {Function} onSuccess 132943d2073cStracker-user */ 133043d2073cStracker-user function doAddReply(annId, body, onSuccess) { 133143d2073cStracker-user ajax({ 133243d2073cStracker-user action: 'reply', 133343d2073cStracker-user id: _info.pageId, 133443d2073cStracker-user annId: annId, 133543d2073cStracker-user body: body, 133643d2073cStracker-user }).then(function (data) { 133743d2073cStracker-user if (!data.success) { 1338*da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 133943d2073cStracker-user return; 134043d2073cStracker-user } 134143d2073cStracker-user // Re-fetch the updated annotation from server. 134243d2073cStracker-user refreshAnnotation(annId, function () { 134343d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(); 134443d2073cStracker-user reopenPanel(annId); 134543d2073cStracker-user }); 134643d2073cStracker-user }).catch(function () { 1347*da56206cStracker-user alert(t('error_save', 'Could not save — please try again.')); 134843d2073cStracker-user }); 134943d2073cStracker-user } 135043d2073cStracker-user 135143d2073cStracker-user /** 135243d2073cStracker-user * POST edit_annotation and re-render. 135343d2073cStracker-user * 135443d2073cStracker-user * @param {string} annId 135543d2073cStracker-user * @param {string} body 135643d2073cStracker-user */ 135743d2073cStracker-user function doEditAnnotation(annId, body) { 135843d2073cStracker-user ajax({ 135943d2073cStracker-user action: 'edit_annotation', 136043d2073cStracker-user id: _info.pageId, 136143d2073cStracker-user annId: annId, 136243d2073cStracker-user body: body, 136343d2073cStracker-user }).then(function (data) { 136443d2073cStracker-user if (!data.success) { 1365*da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 136643d2073cStracker-user return; 136743d2073cStracker-user } 136843d2073cStracker-user var updated = data.annotation; 136943d2073cStracker-user _annotations.set(updated.id, updated); 137043d2073cStracker-user reopenPanel(annId); 137143d2073cStracker-user }); 137243d2073cStracker-user } 137343d2073cStracker-user 137443d2073cStracker-user /** 137543d2073cStracker-user * POST edit_reply and re-render. 137643d2073cStracker-user * 137743d2073cStracker-user * @param {string} annId 137843d2073cStracker-user * @param {string} replyId 137943d2073cStracker-user * @param {string} body 138043d2073cStracker-user */ 138143d2073cStracker-user function doEditReply(annId, replyId, body) { 138243d2073cStracker-user ajax({ 138343d2073cStracker-user action: 'edit_reply', 138443d2073cStracker-user id: _info.pageId, 138543d2073cStracker-user annId: annId, 138643d2073cStracker-user replyId: replyId, 138743d2073cStracker-user body: body, 138843d2073cStracker-user }).then(function (data) { 138943d2073cStracker-user if (!data.success) { 1390*da56206cStracker-user showError(t('error_save', 'Could not save — please try again.'), data); 139143d2073cStracker-user return; 139243d2073cStracker-user } 139343d2073cStracker-user var updated = data.annotation; 139443d2073cStracker-user _annotations.set(updated.id, updated); 139543d2073cStracker-user reopenPanel(annId); 139643d2073cStracker-user }); 139743d2073cStracker-user } 139843d2073cStracker-user 139943d2073cStracker-user /** 140043d2073cStracker-user * POST delete_annotation. 140143d2073cStracker-user * 140243d2073cStracker-user * @param {string} annId 140343d2073cStracker-user */ 140443d2073cStracker-user function doDeleteAnnotation(annId) { 140543d2073cStracker-user ajax({ 140643d2073cStracker-user action: 'delete_annotation', 140743d2073cStracker-user id: _info.pageId, 140843d2073cStracker-user annId: annId, 140943d2073cStracker-user }).then(function (data) { 141043d2073cStracker-user if (!data.success) { 1411*da56206cStracker-user showError(t('error_delete', 'Could not delete — please try again.'), data); 141243d2073cStracker-user return; 141343d2073cStracker-user } 141443d2073cStracker-user _annotations.delete(annId); 141543d2073cStracker-user closePanel(); 141643d2073cStracker-user renderAll(); 141743d2073cStracker-user }); 141843d2073cStracker-user } 141943d2073cStracker-user 142043d2073cStracker-user /** 142143d2073cStracker-user * POST delete_reply and re-render. 142243d2073cStracker-user * 142343d2073cStracker-user * @param {string} annId 142443d2073cStracker-user * @param {string} replyId 142543d2073cStracker-user */ 142643d2073cStracker-user function doDeleteReply(annId, replyId) { 142743d2073cStracker-user ajax({ 142843d2073cStracker-user action: 'delete_reply', 142943d2073cStracker-user id: _info.pageId, 143043d2073cStracker-user annId: annId, 143143d2073cStracker-user replyId: replyId, 143243d2073cStracker-user }).then(function (data) { 143343d2073cStracker-user if (!data.success) { 1434*da56206cStracker-user showError(t('error_delete', 'Could not delete — please try again.'), data); 143543d2073cStracker-user return; 143643d2073cStracker-user } 143743d2073cStracker-user var updated = data.annotation; 143843d2073cStracker-user _annotations.set(updated.id, updated); 143943d2073cStracker-user reopenPanel(annId); 144043d2073cStracker-user }); 144143d2073cStracker-user } 144243d2073cStracker-user 144343d2073cStracker-user /** 144443d2073cStracker-user * POST resolve/reopen action. 144543d2073cStracker-user * 144643d2073cStracker-user * @param {string} annId 144743d2073cStracker-user * @param {string} status 'open' | 'resolved' 144843d2073cStracker-user */ 144943d2073cStracker-user function doResolve(annId, status) { 145043d2073cStracker-user ajax({ 145143d2073cStracker-user action: 'resolve', 145243d2073cStracker-user id: _info.pageId, 145343d2073cStracker-user annId: annId, 145443d2073cStracker-user status: status, 145543d2073cStracker-user }).then(function (data) { 145643d2073cStracker-user if (!data.success) { 1457*da56206cStracker-user showError(t('error_status', 'Could not update the status — please try again.'), data); 145843d2073cStracker-user return; 145943d2073cStracker-user } 146043d2073cStracker-user var updated = data.annotation; 146143d2073cStracker-user _annotations.set(updated.id, updated); 146243d2073cStracker-user renderAll(); 146343d2073cStracker-user reopenPanel(annId); 146443d2073cStracker-user }); 146543d2073cStracker-user } 146643d2073cStracker-user 146743d2073cStracker-user /** 146843d2073cStracker-user * POST clear_resolved (admin). 146943d2073cStracker-user */ 147043d2073cStracker-user function doClearResolved() { 1471*da56206cStracker-user if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return; 147243d2073cStracker-user ajax({ 147343d2073cStracker-user action: 'clear_resolved', 147443d2073cStracker-user id: _info.pageId, 147543d2073cStracker-user }).then(function (data) { 147643d2073cStracker-user if (!data.success) { 1477*da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 147843d2073cStracker-user return; 147943d2073cStracker-user } 148043d2073cStracker-user // Remove resolved from local state. 148143d2073cStracker-user _annotations.forEach(function (ann, id) { 148243d2073cStracker-user if (ann.status === 'resolved') _annotations.delete(id); 148343d2073cStracker-user }); 148443d2073cStracker-user closePanel(); 148543d2073cStracker-user renderAll(); 148643d2073cStracker-user }); 148743d2073cStracker-user } 148843d2073cStracker-user 148943d2073cStracker-user /** 149043d2073cStracker-user * POST clear_orphaned (admin). 149143d2073cStracker-user */ 149243d2073cStracker-user function doClearOrphaned() { 1493*da56206cStracker-user if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return; 149443d2073cStracker-user ajax({ 149543d2073cStracker-user action: 'clear_orphaned', 149643d2073cStracker-user id: _info.pageId, 149743d2073cStracker-user }).then(function (data) { 149843d2073cStracker-user if (!data.success) { 1499*da56206cStracker-user showError(t('error_clear', 'Could not clear — please try again.'), data); 150043d2073cStracker-user return; 150143d2073cStracker-user } 150243d2073cStracker-user _annotations.forEach(function (ann, id) { 150343d2073cStracker-user if (ann._orphaned) _annotations.delete(id); 150443d2073cStracker-user }); 150543d2073cStracker-user closePanel(); 150643d2073cStracker-user renderAll(); 150743d2073cStracker-user }); 150843d2073cStracker-user } 150943d2073cStracker-user 151043d2073cStracker-user // ----------------------------------------------------------------------- 151143d2073cStracker-user // Panel management helpers 151243d2073cStracker-user // ----------------------------------------------------------------------- 151343d2073cStracker-user 151443d2073cStracker-user /** 151543d2073cStracker-user * Re-fetch one annotation from the server and update local state. 151643d2073cStracker-user * 151743d2073cStracker-user * Note: the AJAX endpoint doesn't have a standalone "get one" action, 151843d2073cStracker-user * so we ask the load endpoint (GET) and pull the matching entry out. 151943d2073cStracker-user * 152043d2073cStracker-user * @param {string} annId 152143d2073cStracker-user * @param {Function} cb 152243d2073cStracker-user */ 152343d2073cStracker-user function refreshAnnotation(annId, cb) { 152443d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 152543d2073cStracker-user method: 'GET', 152643d2073cStracker-user }).then(function (res) { 152743d2073cStracker-user return res.json(); 152843d2073cStracker-user }).then(function (data) { 152943d2073cStracker-user if (data && Array.isArray(data.annotations)) { 153043d2073cStracker-user data.annotations.forEach(function (ann) { 153143d2073cStracker-user _annotations.set(ann.id, ann); 153243d2073cStracker-user }); 153343d2073cStracker-user } 153443d2073cStracker-user if (typeof cb === 'function') cb(); 153543d2073cStracker-user }).catch(function () { 153643d2073cStracker-user if (typeof cb === 'function') cb(); 153743d2073cStracker-user }); 153843d2073cStracker-user } 153943d2073cStracker-user 154043d2073cStracker-user /** 154143d2073cStracker-user * Close the current panel and re-open it (preserves scroll position and 154243d2073cStracker-user * re-renders the thread with fresh data). 154343d2073cStracker-user * 154443d2073cStracker-user * @param {string} annId 154543d2073cStracker-user */ 154643d2073cStracker-user function reopenPanel(annId) { 154743d2073cStracker-user closePanel(); 154843d2073cStracker-user openPanel(annId); 154943d2073cStracker-user } 155043d2073cStracker-user 155143d2073cStracker-user // ----------------------------------------------------------------------- 155243d2073cStracker-user // Utilities 155343d2073cStracker-user // ----------------------------------------------------------------------- 155443d2073cStracker-user 155543d2073cStracker-user /** 1556*da56206cStracker-user * The per-plugin JS language bundle, exposed by DokuWiki as 1557*da56206cStracker-user * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 1558*da56206cStracker-user * 1559*da56206cStracker-user * @returns {object} 1560*da56206cStracker-user */ 1561*da56206cStracker-user function uiLang() { 1562*da56206cStracker-user if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) { 1563*da56206cStracker-user return LANG.plugins.annotations; 1564*da56206cStracker-user } 1565*da56206cStracker-user return {}; 1566*da56206cStracker-user } 1567*da56206cStracker-user 1568*da56206cStracker-user /** 1569*da56206cStracker-user * Look up a UI string by key, falling back to the supplied English text if 1570*da56206cStracker-user * the bundle is missing the key (e.g. a lang file not yet updated). 1571*da56206cStracker-user * 1572*da56206cStracker-user * @param {string} key 1573*da56206cStracker-user * @param {string} fallback English default 1574*da56206cStracker-user * @returns {string} 1575*da56206cStracker-user */ 1576*da56206cStracker-user function t(key, fallback) { 1577*da56206cStracker-user var s = _lang[key]; 1578*da56206cStracker-user return (s === undefined || s === null || s === '') ? fallback : s; 1579*da56206cStracker-user } 1580*da56206cStracker-user 1581*da56206cStracker-user /** 1582*da56206cStracker-user * Substitute a single %d placeholder with a number. 1583*da56206cStracker-user * 1584*da56206cStracker-user * @param {string} str 1585*da56206cStracker-user * @param {number} n 1586*da56206cStracker-user * @returns {string} 1587*da56206cStracker-user */ 1588*da56206cStracker-user function fmt(str, n) { 1589*da56206cStracker-user return String(str).replace('%d', n); 1590*da56206cStracker-user } 1591*da56206cStracker-user 1592*da56206cStracker-user /** 1593*da56206cStracker-user * Show a localised error, appending the server's reason in parentheses 1594*da56206cStracker-user * when one is present. 1595*da56206cStracker-user * 1596*da56206cStracker-user * @param {string} base localised message 1597*da56206cStracker-user * @param {object} data AJAX response ({error?:string}) 1598*da56206cStracker-user */ 1599*da56206cStracker-user function showError(base, data) { 1600*da56206cStracker-user var reason = (data && data.error) ? data.error : ''; 1601*da56206cStracker-user alert(reason ? base + ' (' + reason + ')' : base); 1602*da56206cStracker-user } 1603*da56206cStracker-user 1604*da56206cStracker-user /** 160543d2073cStracker-user * Collapse consecutive whitespace to a single space and trim. 160643d2073cStracker-user * 160743d2073cStracker-user * @param {string} s 160843d2073cStracker-user * @returns {string} 160943d2073cStracker-user */ 161043d2073cStracker-user function normalizeWS(s) { 161143d2073cStracker-user return String(s || '').replace(/\s+/g, ' ').trim(); 161243d2073cStracker-user } 161343d2073cStracker-user 161443d2073cStracker-user /** 161543d2073cStracker-user * Return the current DokuWiki username from JSINFO. 161643d2073cStracker-user * 161743d2073cStracker-user * @returns {string} 161843d2073cStracker-user */ 161943d2073cStracker-user function currentUser() { 162043d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 16217d2714c7Stracker-user return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : ''; 162243d2073cStracker-user } 162343d2073cStracker-user 162443d2073cStracker-user /** 162543d2073cStracker-user * Format a Date for display. 162643d2073cStracker-user * 162743d2073cStracker-user * @param {Date} d 162843d2073cStracker-user * @returns {string} 162943d2073cStracker-user */ 163043d2073cStracker-user function formatDate(d) { 163143d2073cStracker-user var now = new Date(); 163243d2073cStracker-user var diff = (now - d) / 1000; // seconds 1633*da56206cStracker-user if (diff < 60) return t('time_now', 'just now'); 1634*da56206cStracker-user if (diff < 3600) return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60)); 1635*da56206cStracker-user if (diff < 86400) return fmt(t('time_hours', '%dh ago'), Math.floor(diff / 3600)); 1636*da56206cStracker-user if (diff < 86400 * 7) return fmt(t('time_days', '%dd ago'), Math.floor(diff / 86400)); 163743d2073cStracker-user return d.toLocaleDateString(); 163843d2073cStracker-user } 163943d2073cStracker-user 164043d2073cStracker-user // ----------------------------------------------------------------------- 164143d2073cStracker-user // Init 164243d2073cStracker-user // ----------------------------------------------------------------------- 164343d2073cStracker-user 164443d2073cStracker-user if (document.readyState === 'loading') { 164543d2073cStracker-user document.addEventListener('DOMContentLoaded', boot); 164643d2073cStracker-user } else { 164743d2073cStracker-user boot(); 164843d2073cStracker-user } 164943d2073cStracker-user 165043d2073cStracker-user}()); 1651