1*43d2073cStracker-user/** 2*43d2073cStracker-user * Annotations plugin — front-end script. 3*43d2073cStracker-user * 4*43d2073cStracker-user * Responsibilities: 5*43d2073cStracker-user * 6*43d2073cStracker-user * 1. BOOT: read JSINFO.annotations (injected by action.php); if the user 7*43d2073cStracker-user * has disabled annotations, exit early. 8*43d2073cStracker-user * 9*43d2073cStracker-user * 2. LOAD: fetch the page's annotation list via the AJAX endpoint, then: 10*43d2073cStracker-user * a. Anchor each annotation in the DOM (re-anchoring). 11*43d2073cStracker-user * b. Wrap matched text in highlight <span>s. 12*43d2073cStracker-user * c. Render per-line gutter markers. 13*43d2073cStracker-user * d. Update the page counter bubble. 14*43d2073cStracker-user * 15*43d2073cStracker-user * 3. SELECTION: detect when the user finishes a text selection inside the 16*43d2073cStracker-user * wiki content area, show an "Annotate" tooltip, capture the anchor on 17*43d2073cStracker-user * click, and open a new-annotation form. 18*43d2073cStracker-user * 19*43d2073cStracker-user * 4. PANELS: clicking a highlight opens the annotation thread inline, just 20*43d2073cStracker-user * below the paragraph that contains the highlight. One open panel at a 21*43d2073cStracker-user * time. The panel renders the full thread: author, timestamp, body, 22*43d2073cStracker-user * replies; and permission-gated action buttons. 23*43d2073cStracker-user * 24*43d2073cStracker-user * 5. AJAX: all state-changing operations POST JSON to 25*43d2073cStracker-user * /lib/exe/ajax.php?call=annotations (with the DokuWiki security token). 26*43d2073cStracker-user * Responses update the in-memory state and re-render affected highlights 27*43d2073cStracker-user * / gutter markers / counter without a page reload. 28*43d2073cStracker-user * 29*43d2073cStracker-user * 6. ORPHANS: annotations that cannot be re-anchored are counted and 30*43d2073cStracker-user * reachable via the orphaned counter link; their panels open in a 31*43d2073cStracker-user * dedicated orphan drawer at the bottom of the content area. 32*43d2073cStracker-user * 33*43d2073cStracker-user * FF78 ESR compatibility: 34*43d2073cStracker-user * - No #private fields, ??=, ||=, &&=, Array.at, structuredClone, 35*43d2073cStracker-user * Object.hasOwn, native <dialog>. 36*43d2073cStracker-user * - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK. 37*43d2073cStracker-user */ 38*43d2073cStracker-user 39*43d2073cStracker-user(function () { 40*43d2073cStracker-user 'use strict'; 41*43d2073cStracker-user 42*43d2073cStracker-user // ----------------------------------------------------------------------- 43*43d2073cStracker-user // Constants 44*43d2073cStracker-user // ----------------------------------------------------------------------- 45*43d2073cStracker-user 46*43d2073cStracker-user var AJAX_URL = DOKU_BASE + 'lib/exe/ajax.php?call=annotations'; 47*43d2073cStracker-user var CONTENT_ID = 'dokuwiki__content'; 48*43d2073cStracker-user 49*43d2073cStracker-user // Colour tokens (also defined in style.css; kept here so JS can read them) 50*43d2073cStracker-user var CLS_HIGHLIGHT_OPEN = 'ann-highlight-open'; 51*43d2073cStracker-user var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved'; 52*43d2073cStracker-user var CLS_HIGHLIGHT_ORPHANED = 'ann-highlight-orphaned'; 53*43d2073cStracker-user var CLS_GUTTER_MARKER = 'ann-gutter-marker'; 54*43d2073cStracker-user var CLS_PANEL = 'ann-panel'; 55*43d2073cStracker-user var CLS_COUNTER = 'ann-counter'; 56*43d2073cStracker-user var CLS_TOOLTIP = 'ann-tooltip'; 57*43d2073cStracker-user var CLS_ORPHAN_DRAWER = 'ann-orphan-drawer'; 58*43d2073cStracker-user 59*43d2073cStracker-user // ----------------------------------------------------------------------- 60*43d2073cStracker-user // State 61*43d2073cStracker-user // ----------------------------------------------------------------------- 62*43d2073cStracker-user 63*43d2073cStracker-user /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */ 64*43d2073cStracker-user var _annotations = new Map(); 65*43d2073cStracker-user 66*43d2073cStracker-user /** Currently open panel element, or null. @type {HTMLElement|null} */ 67*43d2073cStracker-user var _openPanel = null; 68*43d2073cStracker-user 69*43d2073cStracker-user /** ID of the annotation whose panel is open, or null. @type {string|null} */ 70*43d2073cStracker-user var _openAnnId = null; 71*43d2073cStracker-user 72*43d2073cStracker-user /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */ 73*43d2073cStracker-user var _info = {}; 74*43d2073cStracker-user 75*43d2073cStracker-user /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */ 76*43d2073cStracker-user var _lang = {}; 77*43d2073cStracker-user 78*43d2073cStracker-user /** The DokuWiki security token. @type {string} */ 79*43d2073cStracker-user var _token = ''; 80*43d2073cStracker-user 81*43d2073cStracker-user /** Whether the current user is logged in. @type {bool} */ 82*43d2073cStracker-user var _loggedIn = false; 83*43d2073cStracker-user 84*43d2073cStracker-user /** Whether the current user is an admin. @type {bool} */ 85*43d2073cStracker-user var _isAdmin = false; 86*43d2073cStracker-user 87*43d2073cStracker-user // ----------------------------------------------------------------------- 88*43d2073cStracker-user // Boot 89*43d2073cStracker-user // ----------------------------------------------------------------------- 90*43d2073cStracker-user 91*43d2073cStracker-user /** 92*43d2073cStracker-user * Entry point: wired to DOMContentLoaded. 93*43d2073cStracker-user */ 94*43d2073cStracker-user function boot() { 95*43d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 96*43d2073cStracker-user var annInfo = jsinfo.annotations || {}; 97*43d2073cStracker-user 98*43d2073cStracker-user if (!annInfo.enabled) { 99*43d2073cStracker-user return; // user disabled annotations 100*43d2073cStracker-user } 101*43d2073cStracker-user 102*43d2073cStracker-user _info = annInfo; 103*43d2073cStracker-user _lang = annInfo.lang || {}; 104*43d2073cStracker-user _token = (typeof DOKU_XMLRPC !== 'undefined') ? '' : (jsinfo.token || ''); 105*43d2073cStracker-user 106*43d2073cStracker-user // DokuWiki puts the security token in a hidden field on every page. 107*43d2073cStracker-user var tokenField = document.getElementById('dw__token'); 108*43d2073cStracker-user if (tokenField) { 109*43d2073cStracker-user _token = tokenField.value; 110*43d2073cStracker-user } 111*43d2073cStracker-user 112*43d2073cStracker-user _loggedIn = !!(jsinfo.userinfo && jsinfo.userinfo.user); 113*43d2073cStracker-user _isAdmin = !!(jsinfo.userinfo && jsinfo.userinfo.isadmin); 114*43d2073cStracker-user 115*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 116*43d2073cStracker-user if (!content) { 117*43d2073cStracker-user return; // not a page view 118*43d2073cStracker-user } 119*43d2073cStracker-user 120*43d2073cStracker-user renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0); 121*43d2073cStracker-user loadAnnotations(); 122*43d2073cStracker-user initSelectionCapture(content); 123*43d2073cStracker-user } 124*43d2073cStracker-user 125*43d2073cStracker-user // ----------------------------------------------------------------------- 126*43d2073cStracker-user // AJAX helpers 127*43d2073cStracker-user // ----------------------------------------------------------------------- 128*43d2073cStracker-user 129*43d2073cStracker-user /** 130*43d2073cStracker-user * POST a JSON payload to the AJAX endpoint. 131*43d2073cStracker-user * 132*43d2073cStracker-user * @param {object} payload 133*43d2073cStracker-user * @returns {Promise<object>} response data 134*43d2073cStracker-user */ 135*43d2073cStracker-user function ajax(payload) { 136*43d2073cStracker-user payload.sectok = _token; // DokuWiki security token field name for AJAX 137*43d2073cStracker-user return fetch(AJAX_URL, { 138*43d2073cStracker-user method: 'POST', 139*43d2073cStracker-user headers: {'Content-Type': 'application/json'}, 140*43d2073cStracker-user body: JSON.stringify(payload), 141*43d2073cStracker-user }).then(function (res) { 142*43d2073cStracker-user return res.json(); 143*43d2073cStracker-user }); 144*43d2073cStracker-user } 145*43d2073cStracker-user 146*43d2073cStracker-user // ----------------------------------------------------------------------- 147*43d2073cStracker-user // Load and anchor annotations 148*43d2073cStracker-user // ----------------------------------------------------------------------- 149*43d2073cStracker-user 150*43d2073cStracker-user /** 151*43d2073cStracker-user * Fetch all annotations for the current page and render them. 152*43d2073cStracker-user */ 153*43d2073cStracker-user function loadAnnotations() { 154*43d2073cStracker-user // We use a lightweight GET-style call: the action.php AJAX handler 155*43d2073cStracker-user // is POST-only, so we pass action=load in the payload. 156*43d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 157*43d2073cStracker-user method: 'GET', 158*43d2073cStracker-user }).then(function (res) { 159*43d2073cStracker-user return res.json(); 160*43d2073cStracker-user }).then(function (data) { 161*43d2073cStracker-user if (!data || !Array.isArray(data.annotations)) { 162*43d2073cStracker-user return; 163*43d2073cStracker-user } 164*43d2073cStracker-user data.annotations.forEach(function (ann) { 165*43d2073cStracker-user _annotations.set(ann.id, ann); 166*43d2073cStracker-user }); 167*43d2073cStracker-user renderAll(); 168*43d2073cStracker-user }).catch(function () { 169*43d2073cStracker-user // Graceful degradation: page still works without annotations. 170*43d2073cStracker-user }); 171*43d2073cStracker-user } 172*43d2073cStracker-user 173*43d2073cStracker-user /** 174*43d2073cStracker-user * Re-render everything: highlights, gutter markers, counter. 175*43d2073cStracker-user */ 176*43d2073cStracker-user function renderAll() { 177*43d2073cStracker-user clearHighlights(); 178*43d2073cStracker-user clearGutterMarkers(); 179*43d2073cStracker-user 180*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 181*43d2073cStracker-user if (!content) return; 182*43d2073cStracker-user 183*43d2073cStracker-user var orphanCount = 0; 184*43d2073cStracker-user 185*43d2073cStracker-user _annotations.forEach(function (ann) { 186*43d2073cStracker-user var range = findRange(content, ann.anchor); 187*43d2073cStracker-user ann._range = range; // cache for panel positioning 188*43d2073cStracker-user ann._orphaned = !range; 189*43d2073cStracker-user 190*43d2073cStracker-user if (range) { 191*43d2073cStracker-user wrapHighlight(range, ann); 192*43d2073cStracker-user } else { 193*43d2073cStracker-user orphanCount++; 194*43d2073cStracker-user } 195*43d2073cStracker-user }); 196*43d2073cStracker-user 197*43d2073cStracker-user renderGutterMarkers(); 198*43d2073cStracker-user updateCounter(orphanCount); 199*43d2073cStracker-user } 200*43d2073cStracker-user 201*43d2073cStracker-user // ----------------------------------------------------------------------- 202*43d2073cStracker-user // Text anchoring (re-anchoring) 203*43d2073cStracker-user // ----------------------------------------------------------------------- 204*43d2073cStracker-user 205*43d2073cStracker-user /** 206*43d2073cStracker-user * Find the DOM Range for an anchor's quoted text. 207*43d2073cStracker-user * 208*43d2073cStracker-user * Algorithm: 209*43d2073cStracker-user * 1. Collect the page text via TreeWalker. 210*43d2073cStracker-user * 2. Search for the exact quote (normalised). 211*43d2073cStracker-user * 3. If found multiple times, use prefix/suffix to disambiguate. 212*43d2073cStracker-user * 4. If still ambiguous, use the start offset hint. 213*43d2073cStracker-user * 5. Map the character offset back to a DOM Range. 214*43d2073cStracker-user * 215*43d2073cStracker-user * @param {HTMLElement} root 216*43d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 217*43d2073cStracker-user * @returns {Range|null} 218*43d2073cStracker-user */ 219*43d2073cStracker-user function findRange(root, anchor) { 220*43d2073cStracker-user if (!anchor || !anchor.exact) return null; 221*43d2073cStracker-user 222*43d2073cStracker-user var exact = normalizeWS(anchor.exact); 223*43d2073cStracker-user var prefix = normalizeWS(anchor.prefix || ''); 224*43d2073cStracker-user var suffix = normalizeWS(anchor.suffix || ''); 225*43d2073cStracker-user var hint = anchor.start || 0; 226*43d2073cStracker-user 227*43d2073cStracker-user if (exact === '') return null; 228*43d2073cStracker-user 229*43d2073cStracker-user // Collect all text nodes in document order with their cumulative offsets. 230*43d2073cStracker-user var chunks = collectTextChunks(root); 231*43d2073cStracker-user var fullText = chunks.map(function (c) { return c.text; }).join(''); 232*43d2073cStracker-user fullText = normalizeWS(fullText); 233*43d2073cStracker-user 234*43d2073cStracker-user // Find all occurrences of exact. 235*43d2073cStracker-user var positions = []; 236*43d2073cStracker-user var search = fullText; 237*43d2073cStracker-user var base = 0; 238*43d2073cStracker-user var idx; 239*43d2073cStracker-user while ((idx = search.indexOf(exact)) !== -1) { 240*43d2073cStracker-user positions.push(base + idx); 241*43d2073cStracker-user base += idx + exact.length; 242*43d2073cStracker-user search = search.slice(idx + exact.length); 243*43d2073cStracker-user } 244*43d2073cStracker-user 245*43d2073cStracker-user if (positions.length === 0) return null; 246*43d2073cStracker-user 247*43d2073cStracker-user var chosenPos = positions[0]; 248*43d2073cStracker-user 249*43d2073cStracker-user if (positions.length > 1) { 250*43d2073cStracker-user // Disambiguate using prefix + suffix context. 251*43d2073cStracker-user var best = null; 252*43d2073cStracker-user var bestScore = -1; 253*43d2073cStracker-user positions.forEach(function (pos) { 254*43d2073cStracker-user var pre = fullText.slice(Math.max(0, pos - prefix.length), pos); 255*43d2073cStracker-user var suf = fullText.slice(pos + exact.length, pos + exact.length + suffix.length); 256*43d2073cStracker-user var score = 0; 257*43d2073cStracker-user if (prefix && pre.indexOf(prefix) !== -1) score++; 258*43d2073cStracker-user if (suffix && suf.indexOf(suffix) !== -1) score++; 259*43d2073cStracker-user // Use start offset as tiebreaker. 260*43d2073cStracker-user var distToHint = Math.abs(pos - hint); 261*43d2073cStracker-user if (score > bestScore || (score === bestScore && distToHint < Math.abs(chosenPos - hint))) { 262*43d2073cStracker-user bestScore = score; 263*43d2073cStracker-user best = pos; 264*43d2073cStracker-user chosenPos = pos; 265*43d2073cStracker-user } 266*43d2073cStracker-user }); 267*43d2073cStracker-user if (best !== null) chosenPos = best; 268*43d2073cStracker-user } 269*43d2073cStracker-user 270*43d2073cStracker-user return buildRange(chunks, chosenPos, exact.length); 271*43d2073cStracker-user } 272*43d2073cStracker-user 273*43d2073cStracker-user /** 274*43d2073cStracker-user * Walk the text nodes under root and return an array of 275*43d2073cStracker-user * {node, start, text} objects where start is the cumulative character 276*43d2073cStracker-user * offset of this node's text in the joined string. 277*43d2073cStracker-user * 278*43d2073cStracker-user * The joined string is NOT normalised here — we normalise the full string 279*43d2073cStracker-user * once above instead. 280*43d2073cStracker-user * 281*43d2073cStracker-user * @param {HTMLElement} root 282*43d2073cStracker-user * @returns {Array<{node:Text, start:number, text:string}>} 283*43d2073cStracker-user */ 284*43d2073cStracker-user function collectTextChunks(root) { 285*43d2073cStracker-user var walker = document.createTreeWalker( 286*43d2073cStracker-user root, 287*43d2073cStracker-user NodeFilter.SHOW_TEXT, 288*43d2073cStracker-user null, 289*43d2073cStracker-user false 290*43d2073cStracker-user ); 291*43d2073cStracker-user var chunks = []; 292*43d2073cStracker-user var offset = 0; 293*43d2073cStracker-user var node; 294*43d2073cStracker-user while ((node = walker.nextNode())) { 295*43d2073cStracker-user // Skip nodes inside our own UI elements. 296*43d2073cStracker-user if (isAnnotationUI(node.parentNode)) continue; 297*43d2073cStracker-user var text = node.nodeValue || ''; 298*43d2073cStracker-user chunks.push({node: node, start: offset, text: text}); 299*43d2073cStracker-user offset += text.length; 300*43d2073cStracker-user } 301*43d2073cStracker-user return chunks; 302*43d2073cStracker-user } 303*43d2073cStracker-user 304*43d2073cStracker-user /** 305*43d2073cStracker-user * True if the element (or its ancestor) is part of our annotation UI. 306*43d2073cStracker-user * 307*43d2073cStracker-user * @param {Node} el 308*43d2073cStracker-user * @returns {bool} 309*43d2073cStracker-user */ 310*43d2073cStracker-user function isAnnotationUI(el) { 311*43d2073cStracker-user while (el && el !== document.body) { 312*43d2073cStracker-user if (el.nodeType === 1) { 313*43d2073cStracker-user var cls = el.className || ''; 314*43d2073cStracker-user if ( 315*43d2073cStracker-user cls.indexOf('ann-') !== -1 || 316*43d2073cStracker-user cls.indexOf(CLS_PANEL) !== -1 317*43d2073cStracker-user ) { 318*43d2073cStracker-user return true; 319*43d2073cStracker-user } 320*43d2073cStracker-user } 321*43d2073cStracker-user el = el.parentNode; 322*43d2073cStracker-user } 323*43d2073cStracker-user return false; 324*43d2073cStracker-user } 325*43d2073cStracker-user 326*43d2073cStracker-user /** 327*43d2073cStracker-user * Turn character offsets (in the normalised full string) back into a 328*43d2073cStracker-user * DOM Range. 329*43d2073cStracker-user * 330*43d2073cStracker-user * @param {Array<{node:Text, start:number, text:string}>} chunks 331*43d2073cStracker-user * @param {number} startOff start char offset in joined (raw) text 332*43d2073cStracker-user * @param {number} length length of selection in normalised text 333*43d2073cStracker-user * @returns {Range|null} 334*43d2073cStracker-user */ 335*43d2073cStracker-user function buildRange(chunks, startOff, length) { 336*43d2073cStracker-user // The fullText we searched is normalised (multiple spaces → one), but 337*43d2073cStracker-user // chunk offsets are raw. We need to find the raw offset that corresponds 338*43d2073cStracker-user // to startOff in the normalised string. 339*43d2073cStracker-user // 340*43d2073cStracker-user // Simple approach: walk chunks until we've "consumed" startOff 341*43d2073cStracker-user // normalised characters (counting consecutive spaces as 1). 342*43d2073cStracker-user // This works well enough for typical wiki prose. 343*43d2073cStracker-user 344*43d2073cStracker-user var rawFull = chunks.map(function (c) { return c.text; }).join(''); 345*43d2073cStracker-user var normFull = normalizeWS(rawFull); 346*43d2073cStracker-user 347*43d2073cStracker-user // Build a map: normFull[i] → rawFull[j] 348*43d2073cStracker-user var normToRaw = buildNormToRaw(rawFull); 349*43d2073cStracker-user 350*43d2073cStracker-user var rawStart = normToRaw[startOff]; 351*43d2073cStracker-user var rawEnd = normToRaw[startOff + length - 1]; 352*43d2073cStracker-user if (rawStart === undefined || rawEnd === undefined) return null; 353*43d2073cStracker-user rawEnd++; // exclusive 354*43d2073cStracker-user 355*43d2073cStracker-user // Find which chunks contain rawStart and rawEnd. 356*43d2073cStracker-user var startChunk = null, startOffset = 0; 357*43d2073cStracker-user var endChunk = null, endOffset = 0; 358*43d2073cStracker-user 359*43d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 360*43d2073cStracker-user var c = chunks[i]; 361*43d2073cStracker-user var cEnd = c.start + c.text.length; 362*43d2073cStracker-user 363*43d2073cStracker-user if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 364*43d2073cStracker-user startChunk = c.node; 365*43d2073cStracker-user startOffset = rawStart - c.start; 366*43d2073cStracker-user } 367*43d2073cStracker-user if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 368*43d2073cStracker-user endChunk = c.node; 369*43d2073cStracker-user endOffset = rawEnd - c.start; 370*43d2073cStracker-user } 371*43d2073cStracker-user if (startChunk && endChunk) break; 372*43d2073cStracker-user } 373*43d2073cStracker-user 374*43d2073cStracker-user if (!startChunk || !endChunk) return null; 375*43d2073cStracker-user 376*43d2073cStracker-user try { 377*43d2073cStracker-user var range = document.createRange(); 378*43d2073cStracker-user range.setStart(startChunk, startOffset); 379*43d2073cStracker-user range.setEnd(endChunk, endOffset); 380*43d2073cStracker-user return range; 381*43d2073cStracker-user } catch (e) { 382*43d2073cStracker-user return null; 383*43d2073cStracker-user } 384*43d2073cStracker-user } 385*43d2073cStracker-user 386*43d2073cStracker-user /** 387*43d2073cStracker-user * Build an array mapping normalised-string index → raw-string index. 388*43d2073cStracker-user * Consecutive whitespace is collapsed to a single space; the mapping 389*43d2073cStracker-user * records the index of the first character in each run. 390*43d2073cStracker-user * 391*43d2073cStracker-user * @param {string} raw 392*43d2073cStracker-user * @returns {Array<number>} 393*43d2073cStracker-user */ 394*43d2073cStracker-user function buildNormToRaw(raw) { 395*43d2073cStracker-user var map = []; 396*43d2073cStracker-user var inSpace = false; 397*43d2073cStracker-user for (var i = 0; i < raw.length; i++) { 398*43d2073cStracker-user var ch = raw[i]; 399*43d2073cStracker-user if (/\s/.test(ch)) { 400*43d2073cStracker-user if (!inSpace) { 401*43d2073cStracker-user map.push(i); // one representative space 402*43d2073cStracker-user inSpace = true; 403*43d2073cStracker-user } 404*43d2073cStracker-user // else: extra whitespace chars are skipped 405*43d2073cStracker-user } else { 406*43d2073cStracker-user map.push(i); 407*43d2073cStracker-user inSpace = false; 408*43d2073cStracker-user } 409*43d2073cStracker-user } 410*43d2073cStracker-user return map; 411*43d2073cStracker-user } 412*43d2073cStracker-user 413*43d2073cStracker-user // ----------------------------------------------------------------------- 414*43d2073cStracker-user // Highlights 415*43d2073cStracker-user // ----------------------------------------------------------------------- 416*43d2073cStracker-user 417*43d2073cStracker-user /** 418*43d2073cStracker-user * Wrap a Range in a highlight <span> for the given annotation. 419*43d2073cStracker-user * 420*43d2073cStracker-user * @param {Range} range 421*43d2073cStracker-user * @param {object} ann 422*43d2073cStracker-user */ 423*43d2073cStracker-user function wrapHighlight(range, ann) { 424*43d2073cStracker-user try { 425*43d2073cStracker-user var span = document.createElement('span'); 426*43d2073cStracker-user span.className = ann.status === 'resolved' 427*43d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 428*43d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 429*43d2073cStracker-user span.dataset.annId = ann.id; 430*43d2073cStracker-user span.title = ann.body.slice(0, 80) + (ann.body.length > 80 ? '…' : ''); 431*43d2073cStracker-user span.addEventListener('click', function (e) { 432*43d2073cStracker-user e.stopPropagation(); 433*43d2073cStracker-user openPanel(ann.id); 434*43d2073cStracker-user }); 435*43d2073cStracker-user range.surroundContents(span); 436*43d2073cStracker-user ann._highlightEl = span; 437*43d2073cStracker-user } catch (e) { 438*43d2073cStracker-user // surroundContents throws if the range crosses element boundaries. 439*43d2073cStracker-user // Fall back to insertNode with a cloned range fragment. 440*43d2073cStracker-user try { 441*43d2073cStracker-user var frag = range.extractContents(); 442*43d2073cStracker-user var span2 = document.createElement('span'); 443*43d2073cStracker-user span2.className = ann.status === 'resolved' 444*43d2073cStracker-user ? CLS_HIGHLIGHT_RESOLVED 445*43d2073cStracker-user : CLS_HIGHLIGHT_OPEN; 446*43d2073cStracker-user span2.dataset.annId = ann.id; 447*43d2073cStracker-user span2.appendChild(frag); 448*43d2073cStracker-user span2.addEventListener('click', function (e) { 449*43d2073cStracker-user e.stopPropagation(); 450*43d2073cStracker-user openPanel(ann.id); 451*43d2073cStracker-user }); 452*43d2073cStracker-user range.insertNode(span2); 453*43d2073cStracker-user ann._highlightEl = span2; 454*43d2073cStracker-user } catch (e2) { 455*43d2073cStracker-user ann._highlightEl = null; 456*43d2073cStracker-user } 457*43d2073cStracker-user } 458*43d2073cStracker-user } 459*43d2073cStracker-user 460*43d2073cStracker-user /** 461*43d2073cStracker-user * Remove all highlight spans, restoring the original text nodes. 462*43d2073cStracker-user */ 463*43d2073cStracker-user function clearHighlights() { 464*43d2073cStracker-user var spans = document.querySelectorAll( 465*43d2073cStracker-user '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED + ', .' + CLS_HIGHLIGHT_ORPHANED 466*43d2073cStracker-user ); 467*43d2073cStracker-user Array.prototype.forEach.call(spans, function (span) { 468*43d2073cStracker-user var parent = span.parentNode; 469*43d2073cStracker-user if (!parent) return; 470*43d2073cStracker-user while (span.firstChild) { 471*43d2073cStracker-user parent.insertBefore(span.firstChild, span); 472*43d2073cStracker-user } 473*43d2073cStracker-user parent.removeChild(span); 474*43d2073cStracker-user parent.normalize(); 475*43d2073cStracker-user }); 476*43d2073cStracker-user } 477*43d2073cStracker-user 478*43d2073cStracker-user // ----------------------------------------------------------------------- 479*43d2073cStracker-user // Gutter markers 480*43d2073cStracker-user // ----------------------------------------------------------------------- 481*43d2073cStracker-user 482*43d2073cStracker-user /** 483*43d2073cStracker-user * Render a small marker in the gutter for every anchored annotation. 484*43d2073cStracker-user * Markers are absolutely positioned relative to the content wrapper. 485*43d2073cStracker-user */ 486*43d2073cStracker-user function renderGutterMarkers() { 487*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 488*43d2073cStracker-user if (!content) return; 489*43d2073cStracker-user 490*43d2073cStracker-user _annotations.forEach(function (ann) { 491*43d2073cStracker-user if (!ann._highlightEl) return; // orphan 492*43d2073cStracker-user 493*43d2073cStracker-user var el = ann._highlightEl; 494*43d2073cStracker-user var rect = el.getBoundingClientRect(); 495*43d2073cStracker-user var contentRect = content.getBoundingClientRect(); 496*43d2073cStracker-user 497*43d2073cStracker-user var marker = document.createElement('button'); 498*43d2073cStracker-user marker.className = CLS_GUTTER_MARKER; 499*43d2073cStracker-user marker.dataset.annId = ann.id; 500*43d2073cStracker-user marker.setAttribute('aria-label', 'Annotation'); 501*43d2073cStracker-user marker.type = 'button'; 502*43d2073cStracker-user marker.style.top = (rect.top - contentRect.top + content.scrollTop) + 'px'; 503*43d2073cStracker-user marker.addEventListener('click', function (e) { 504*43d2073cStracker-user e.stopPropagation(); 505*43d2073cStracker-user openPanel(ann.id); 506*43d2073cStracker-user }); 507*43d2073cStracker-user content.appendChild(marker); 508*43d2073cStracker-user ann._markerEl = marker; 509*43d2073cStracker-user }); 510*43d2073cStracker-user } 511*43d2073cStracker-user 512*43d2073cStracker-user /** 513*43d2073cStracker-user * Remove all gutter markers. 514*43d2073cStracker-user */ 515*43d2073cStracker-user function clearGutterMarkers() { 516*43d2073cStracker-user var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 517*43d2073cStracker-user Array.prototype.forEach.call(markers, function (m) { 518*43d2073cStracker-user if (m.parentNode) m.parentNode.removeChild(m); 519*43d2073cStracker-user }); 520*43d2073cStracker-user } 521*43d2073cStracker-user 522*43d2073cStracker-user // ----------------------------------------------------------------------- 523*43d2073cStracker-user // Page counter 524*43d2073cStracker-user // ----------------------------------------------------------------------- 525*43d2073cStracker-user 526*43d2073cStracker-user /** 527*43d2073cStracker-user * Render (or update) the counter bubble above the content area. 528*43d2073cStracker-user * 529*43d2073cStracker-user * @param {object} stats {total, open, resolved} 530*43d2073cStracker-user * @param {number} orphanCount 531*43d2073cStracker-user */ 532*43d2073cStracker-user function renderCounter(stats, orphanCount) { 533*43d2073cStracker-user var existing = document.getElementById('ann-counter-bar'); 534*43d2073cStracker-user if (existing) existing.parentNode.removeChild(existing); 535*43d2073cStracker-user 536*43d2073cStracker-user if (stats.total === 0 && orphanCount === 0) return; 537*43d2073cStracker-user 538*43d2073cStracker-user var bar = document.createElement('div'); 539*43d2073cStracker-user bar.id = 'ann-counter-bar'; 540*43d2073cStracker-user bar.className = CLS_COUNTER; 541*43d2073cStracker-user 542*43d2073cStracker-user var total = stats.total || 0; 543*43d2073cStracker-user var label = total === 1 544*43d2073cStracker-user ? '1 annotation' 545*43d2073cStracker-user : total + ' annotations'; 546*43d2073cStracker-user bar.appendChild(document.createTextNode(label)); 547*43d2073cStracker-user 548*43d2073cStracker-user if (orphanCount > 0) { 549*43d2073cStracker-user bar.appendChild(document.createTextNode(' · ')); 550*43d2073cStracker-user var orphanLink = document.createElement('a'); 551*43d2073cStracker-user orphanLink.href = '#ann-orphan-drawer'; 552*43d2073cStracker-user orphanLink.className = 'ann-orphan-link'; 553*43d2073cStracker-user orphanLink.textContent = orphanCount + ' orphaned'; 554*43d2073cStracker-user orphanLink.addEventListener('click', function (e) { 555*43d2073cStracker-user e.preventDefault(); 556*43d2073cStracker-user toggleOrphanDrawer(); 557*43d2073cStracker-user }); 558*43d2073cStracker-user bar.appendChild(orphanLink); 559*43d2073cStracker-user } 560*43d2073cStracker-user 561*43d2073cStracker-user if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 562*43d2073cStracker-user if (stats.resolved > 0) { 563*43d2073cStracker-user var btnCR = document.createElement('button'); 564*43d2073cStracker-user btnCR.type = 'button'; 565*43d2073cStracker-user btnCR.className = 'ann-btn ann-btn-admin'; 566*43d2073cStracker-user btnCR.textContent = 'Clear resolved'; 567*43d2073cStracker-user btnCR.addEventListener('click', doClearResolved); 568*43d2073cStracker-user bar.appendChild(btnCR); 569*43d2073cStracker-user } 570*43d2073cStracker-user if (orphanCount > 0) { 571*43d2073cStracker-user var btnCO = document.createElement('button'); 572*43d2073cStracker-user btnCO.type = 'button'; 573*43d2073cStracker-user btnCO.className = 'ann-btn ann-btn-admin'; 574*43d2073cStracker-user btnCO.textContent = 'Clear orphaned'; 575*43d2073cStracker-user btnCO.addEventListener('click', doClearOrphaned); 576*43d2073cStracker-user bar.appendChild(btnCO); 577*43d2073cStracker-user } 578*43d2073cStracker-user } 579*43d2073cStracker-user 580*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 581*43d2073cStracker-user if (content && content.parentNode) { 582*43d2073cStracker-user content.parentNode.insertBefore(bar, content); 583*43d2073cStracker-user } 584*43d2073cStracker-user } 585*43d2073cStracker-user 586*43d2073cStracker-user /** 587*43d2073cStracker-user * Recount and re-render the counter from in-memory state. 588*43d2073cStracker-user */ 589*43d2073cStracker-user function updateCounter(orphanCount) { 590*43d2073cStracker-user var open = 0, resolved = 0; 591*43d2073cStracker-user if (orphanCount === undefined) { 592*43d2073cStracker-user orphanCount = 0; 593*43d2073cStracker-user } 594*43d2073cStracker-user _annotations.forEach(function (ann) { 595*43d2073cStracker-user if (ann._orphaned) { 596*43d2073cStracker-user orphanCount++; 597*43d2073cStracker-user } else if (ann.status === 'resolved') { 598*43d2073cStracker-user resolved++; 599*43d2073cStracker-user } else { 600*43d2073cStracker-user open++; 601*43d2073cStracker-user } 602*43d2073cStracker-user }); 603*43d2073cStracker-user renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 604*43d2073cStracker-user } 605*43d2073cStracker-user 606*43d2073cStracker-user // ----------------------------------------------------------------------- 607*43d2073cStracker-user // Annotation panel 608*43d2073cStracker-user // ----------------------------------------------------------------------- 609*43d2073cStracker-user 610*43d2073cStracker-user /** 611*43d2073cStracker-user * Open the thread panel for the given annotation id. 612*43d2073cStracker-user * If that panel is already open, close it. 613*43d2073cStracker-user * 614*43d2073cStracker-user * @param {string} annId 615*43d2073cStracker-user */ 616*43d2073cStracker-user function openPanel(annId) { 617*43d2073cStracker-user if (_openAnnId === annId) { 618*43d2073cStracker-user closePanel(); 619*43d2073cStracker-user return; 620*43d2073cStracker-user } 621*43d2073cStracker-user closePanel(); 622*43d2073cStracker-user 623*43d2073cStracker-user var ann = _annotations.get(annId); 624*43d2073cStracker-user if (!ann) return; 625*43d2073cStracker-user 626*43d2073cStracker-user var panel = buildPanel(ann); 627*43d2073cStracker-user _openPanel = panel; 628*43d2073cStracker-user _openAnnId = annId; 629*43d2073cStracker-user 630*43d2073cStracker-user // Insert below the paragraph that contains the highlight. 631*43d2073cStracker-user var anchor = ann._highlightEl || null; 632*43d2073cStracker-user var insertAfter = findParagraph(anchor); 633*43d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 634*43d2073cStracker-user insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 635*43d2073cStracker-user } else { 636*43d2073cStracker-user // Orphan or no paragraph found: show at the bottom of content. 637*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 638*43d2073cStracker-user if (content) content.appendChild(panel); 639*43d2073cStracker-user } 640*43d2073cStracker-user 641*43d2073cStracker-user panel.querySelector('.ann-body-input') && panel.querySelector('.ann-body-input').focus(); 642*43d2073cStracker-user } 643*43d2073cStracker-user 644*43d2073cStracker-user /** 645*43d2073cStracker-user * Close and remove the currently open panel. 646*43d2073cStracker-user */ 647*43d2073cStracker-user function closePanel() { 648*43d2073cStracker-user if (_openPanel && _openPanel.parentNode) { 649*43d2073cStracker-user _openPanel.parentNode.removeChild(_openPanel); 650*43d2073cStracker-user } 651*43d2073cStracker-user _openPanel = null; 652*43d2073cStracker-user _openAnnId = null; 653*43d2073cStracker-user } 654*43d2073cStracker-user 655*43d2073cStracker-user /** 656*43d2073cStracker-user * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 657*43d2073cStracker-user * that can receive a sibling element. 658*43d2073cStracker-user * 659*43d2073cStracker-user * @param {HTMLElement|null} el 660*43d2073cStracker-user * @returns {HTMLElement|null} 661*43d2073cStracker-user */ 662*43d2073cStracker-user function findParagraph(el) { 663*43d2073cStracker-user var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 664*43d2073cStracker-user var node = el; 665*43d2073cStracker-user while (node && node.id !== CONTENT_ID) { 666*43d2073cStracker-user if (node.nodeType === 1 && block.test(node.tagName)) { 667*43d2073cStracker-user return node; 668*43d2073cStracker-user } 669*43d2073cStracker-user node = node.parentNode; 670*43d2073cStracker-user } 671*43d2073cStracker-user return el; // fallback: use the element itself 672*43d2073cStracker-user } 673*43d2073cStracker-user 674*43d2073cStracker-user /** 675*43d2073cStracker-user * Build and return the panel DOM element for one annotation. 676*43d2073cStracker-user * 677*43d2073cStracker-user * @param {object} ann 678*43d2073cStracker-user * @returns {HTMLElement} 679*43d2073cStracker-user */ 680*43d2073cStracker-user function buildPanel(ann) { 681*43d2073cStracker-user var panel = document.createElement('div'); 682*43d2073cStracker-user panel.className = CLS_PANEL; 683*43d2073cStracker-user panel.dataset.annId = ann.id; 684*43d2073cStracker-user 685*43d2073cStracker-user // Header 686*43d2073cStracker-user var header = document.createElement('div'); 687*43d2073cStracker-user header.className = 'ann-panel-header'; 688*43d2073cStracker-user 689*43d2073cStracker-user var closeBtn = document.createElement('button'); 690*43d2073cStracker-user closeBtn.type = 'button'; 691*43d2073cStracker-user closeBtn.className = 'ann-btn ann-close'; 692*43d2073cStracker-user closeBtn.setAttribute('aria-label', 'Close'); 693*43d2073cStracker-user closeBtn.textContent = '×'; 694*43d2073cStracker-user closeBtn.addEventListener('click', closePanel); 695*43d2073cStracker-user header.appendChild(closeBtn); 696*43d2073cStracker-user 697*43d2073cStracker-user panel.appendChild(header); 698*43d2073cStracker-user 699*43d2073cStracker-user // Main annotation thread entry 700*43d2073cStracker-user panel.appendChild(buildThreadEntry(ann, true)); 701*43d2073cStracker-user 702*43d2073cStracker-user // Replies 703*43d2073cStracker-user (ann.replies || []).forEach(function (reply) { 704*43d2073cStracker-user panel.appendChild(buildReplyEntry(ann, reply)); 705*43d2073cStracker-user }); 706*43d2073cStracker-user 707*43d2073cStracker-user // Reply form (if logged in and has read access — gate is server-side anyway) 708*43d2073cStracker-user if (_loggedIn) { 709*43d2073cStracker-user panel.appendChild(buildReplyForm(ann)); 710*43d2073cStracker-user } 711*43d2073cStracker-user 712*43d2073cStracker-user return panel; 713*43d2073cStracker-user } 714*43d2073cStracker-user 715*43d2073cStracker-user /** 716*43d2073cStracker-user * Build the DOM for the top-level annotation entry. 717*43d2073cStracker-user * 718*43d2073cStracker-user * @param {object} ann 719*43d2073cStracker-user * @param {boolean} isRoot true for the annotation itself, false for replies 720*43d2073cStracker-user * @returns {HTMLElement} 721*43d2073cStracker-user */ 722*43d2073cStracker-user function buildThreadEntry(ann, isRoot) { 723*43d2073cStracker-user var entry = document.createElement('div'); 724*43d2073cStracker-user entry.className = 'ann-thread-entry ann-annotation'; 725*43d2073cStracker-user entry.dataset.annId = ann.id; 726*43d2073cStracker-user 727*43d2073cStracker-user // Meta row: avatar, author, time, status pill 728*43d2073cStracker-user entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 729*43d2073cStracker-user 730*43d2073cStracker-user // Body 731*43d2073cStracker-user var bodyEl = document.createElement('div'); 732*43d2073cStracker-user bodyEl.className = 'ann-body'; 733*43d2073cStracker-user bodyEl.textContent = ann.body; 734*43d2073cStracker-user entry.appendChild(bodyEl); 735*43d2073cStracker-user 736*43d2073cStracker-user // Quoted text snippet 737*43d2073cStracker-user if (ann.anchor && ann.anchor.exact) { 738*43d2073cStracker-user var quote = document.createElement('blockquote'); 739*43d2073cStracker-user quote.className = 'ann-quote'; 740*43d2073cStracker-user quote.textContent = ann.anchor.exact; 741*43d2073cStracker-user entry.appendChild(quote); 742*43d2073cStracker-user } 743*43d2073cStracker-user 744*43d2073cStracker-user // Action buttons 745*43d2073cStracker-user var actions = document.createElement('div'); 746*43d2073cStracker-user actions.className = 'ann-actions'; 747*43d2073cStracker-user 748*43d2073cStracker-user // Resolve/Reopen (any reader) 749*43d2073cStracker-user if (_loggedIn) { 750*43d2073cStracker-user var resolveBtn = document.createElement('button'); 751*43d2073cStracker-user resolveBtn.type = 'button'; 752*43d2073cStracker-user resolveBtn.className = 'ann-btn ann-btn-resolve'; 753*43d2073cStracker-user resolveBtn.textContent = ann.status === 'resolved' ? 'Reopen' : 'Resolve'; 754*43d2073cStracker-user resolveBtn.addEventListener('click', function () { 755*43d2073cStracker-user doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved'); 756*43d2073cStracker-user }); 757*43d2073cStracker-user actions.appendChild(resolveBtn); 758*43d2073cStracker-user } 759*43d2073cStracker-user 760*43d2073cStracker-user // Edit + Delete (own or admin) 761*43d2073cStracker-user var canEdit = _isAdmin || ann.author === currentUser(); 762*43d2073cStracker-user if (canEdit && _loggedIn) { 763*43d2073cStracker-user var editBtn = document.createElement('button'); 764*43d2073cStracker-user editBtn.type = 'button'; 765*43d2073cStracker-user editBtn.className = 'ann-btn'; 766*43d2073cStracker-user editBtn.textContent = 'Edit'; 767*43d2073cStracker-user editBtn.addEventListener('click', function () { 768*43d2073cStracker-user showEditForm(entry, ann, 'annotation'); 769*43d2073cStracker-user }); 770*43d2073cStracker-user actions.appendChild(editBtn); 771*43d2073cStracker-user 772*43d2073cStracker-user var delBtn = document.createElement('button'); 773*43d2073cStracker-user delBtn.type = 'button'; 774*43d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 775*43d2073cStracker-user delBtn.textContent = 'Delete'; 776*43d2073cStracker-user delBtn.addEventListener('click', function () { 777*43d2073cStracker-user if (confirm('Delete this annotation?')) { 778*43d2073cStracker-user doDeleteAnnotation(ann.id); 779*43d2073cStracker-user } 780*43d2073cStracker-user }); 781*43d2073cStracker-user actions.appendChild(delBtn); 782*43d2073cStracker-user } 783*43d2073cStracker-user 784*43d2073cStracker-user entry.appendChild(actions); 785*43d2073cStracker-user return entry; 786*43d2073cStracker-user } 787*43d2073cStracker-user 788*43d2073cStracker-user /** 789*43d2073cStracker-user * Build the DOM for one reply entry. 790*43d2073cStracker-user * 791*43d2073cStracker-user * @param {object} ann parent annotation 792*43d2073cStracker-user * @param {object} reply 793*43d2073cStracker-user * @returns {HTMLElement} 794*43d2073cStracker-user */ 795*43d2073cStracker-user function buildReplyEntry(ann, reply) { 796*43d2073cStracker-user var entry = document.createElement('div'); 797*43d2073cStracker-user entry.className = 'ann-thread-entry ann-reply'; 798*43d2073cStracker-user entry.dataset.replyId = reply.id; 799*43d2073cStracker-user 800*43d2073cStracker-user entry.appendChild(buildMeta(reply.author, reply.created, null)); 801*43d2073cStracker-user 802*43d2073cStracker-user var bodyEl = document.createElement('div'); 803*43d2073cStracker-user bodyEl.className = 'ann-body'; 804*43d2073cStracker-user bodyEl.textContent = reply.body; 805*43d2073cStracker-user entry.appendChild(bodyEl); 806*43d2073cStracker-user 807*43d2073cStracker-user var actions = document.createElement('div'); 808*43d2073cStracker-user actions.className = 'ann-actions'; 809*43d2073cStracker-user 810*43d2073cStracker-user var canEdit = _isAdmin || reply.author === currentUser(); 811*43d2073cStracker-user if (canEdit && _loggedIn) { 812*43d2073cStracker-user var editBtn = document.createElement('button'); 813*43d2073cStracker-user editBtn.type = 'button'; 814*43d2073cStracker-user editBtn.className = 'ann-btn'; 815*43d2073cStracker-user editBtn.textContent = 'Edit'; 816*43d2073cStracker-user editBtn.addEventListener('click', function () { 817*43d2073cStracker-user showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 818*43d2073cStracker-user }); 819*43d2073cStracker-user actions.appendChild(editBtn); 820*43d2073cStracker-user 821*43d2073cStracker-user var delBtn = document.createElement('button'); 822*43d2073cStracker-user delBtn.type = 'button'; 823*43d2073cStracker-user delBtn.className = 'ann-btn ann-btn-danger'; 824*43d2073cStracker-user delBtn.textContent = 'Delete'; 825*43d2073cStracker-user delBtn.addEventListener('click', function () { 826*43d2073cStracker-user if (confirm('Delete this reply?')) { 827*43d2073cStracker-user doDeleteReply(ann.id, reply.id); 828*43d2073cStracker-user } 829*43d2073cStracker-user }); 830*43d2073cStracker-user actions.appendChild(delBtn); 831*43d2073cStracker-user } 832*43d2073cStracker-user 833*43d2073cStracker-user entry.appendChild(actions); 834*43d2073cStracker-user return entry; 835*43d2073cStracker-user } 836*43d2073cStracker-user 837*43d2073cStracker-user /** 838*43d2073cStracker-user * Build the meta row (avatar initials, author name, timestamp, status pill). 839*43d2073cStracker-user * 840*43d2073cStracker-user * @param {string} author 841*43d2073cStracker-user * @param {number} timestamp Unix seconds 842*43d2073cStracker-user * @param {string|null} status 'open'|'resolved'|null 843*43d2073cStracker-user * @returns {HTMLElement} 844*43d2073cStracker-user */ 845*43d2073cStracker-user function buildMeta(author, timestamp, status) { 846*43d2073cStracker-user var meta = document.createElement('div'); 847*43d2073cStracker-user meta.className = 'ann-meta'; 848*43d2073cStracker-user 849*43d2073cStracker-user var avatar = document.createElement('span'); 850*43d2073cStracker-user avatar.className = 'ann-avatar'; 851*43d2073cStracker-user avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 852*43d2073cStracker-user meta.appendChild(avatar); 853*43d2073cStracker-user 854*43d2073cStracker-user var authorEl = document.createElement('span'); 855*43d2073cStracker-user authorEl.className = 'ann-author'; 856*43d2073cStracker-user authorEl.textContent = author || 'Unknown'; 857*43d2073cStracker-user meta.appendChild(authorEl); 858*43d2073cStracker-user 859*43d2073cStracker-user var timeEl = document.createElement('time'); 860*43d2073cStracker-user timeEl.className = 'ann-time'; 861*43d2073cStracker-user var d = new Date(timestamp * 1000); 862*43d2073cStracker-user timeEl.dateTime = d.toISOString(); 863*43d2073cStracker-user timeEl.textContent = formatDate(d); 864*43d2073cStracker-user meta.appendChild(timeEl); 865*43d2073cStracker-user 866*43d2073cStracker-user if (status) { 867*43d2073cStracker-user var pill = document.createElement('span'); 868*43d2073cStracker-user pill.className = 'ann-status ann-status-' + status; 869*43d2073cStracker-user pill.textContent = status === 'resolved' ? 'Resolved' : 'Open'; 870*43d2073cStracker-user meta.appendChild(pill); 871*43d2073cStracker-user } 872*43d2073cStracker-user 873*43d2073cStracker-user return meta; 874*43d2073cStracker-user } 875*43d2073cStracker-user 876*43d2073cStracker-user /** 877*43d2073cStracker-user * Build a reply form at the bottom of the panel. 878*43d2073cStracker-user * 879*43d2073cStracker-user * @param {object} ann 880*43d2073cStracker-user * @returns {HTMLElement} 881*43d2073cStracker-user */ 882*43d2073cStracker-user function buildReplyForm(ann) { 883*43d2073cStracker-user var form = document.createElement('div'); 884*43d2073cStracker-user form.className = 'ann-reply-form'; 885*43d2073cStracker-user 886*43d2073cStracker-user var ta = document.createElement('textarea'); 887*43d2073cStracker-user ta.className = 'ann-body-input'; 888*43d2073cStracker-user ta.placeholder = 'Write a reply…'; 889*43d2073cStracker-user ta.rows = 3; 890*43d2073cStracker-user form.appendChild(ta); 891*43d2073cStracker-user 892*43d2073cStracker-user var row = document.createElement('div'); 893*43d2073cStracker-user row.className = 'ann-form-row'; 894*43d2073cStracker-user 895*43d2073cStracker-user var submitBtn = document.createElement('button'); 896*43d2073cStracker-user submitBtn.type = 'button'; 897*43d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 898*43d2073cStracker-user submitBtn.textContent = 'Reply'; 899*43d2073cStracker-user submitBtn.addEventListener('click', function () { 900*43d2073cStracker-user var body = ta.value.trim(); 901*43d2073cStracker-user if (!body) return; 902*43d2073cStracker-user doAddReply(ann.id, body, function () { 903*43d2073cStracker-user ta.value = ''; 904*43d2073cStracker-user }); 905*43d2073cStracker-user }); 906*43d2073cStracker-user row.appendChild(submitBtn); 907*43d2073cStracker-user form.appendChild(row); 908*43d2073cStracker-user 909*43d2073cStracker-user return form; 910*43d2073cStracker-user } 911*43d2073cStracker-user 912*43d2073cStracker-user /** 913*43d2073cStracker-user * Replace the body of an entry with an inline edit form. 914*43d2073cStracker-user * 915*43d2073cStracker-user * @param {HTMLElement} entry 916*43d2073cStracker-user * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 917*43d2073cStracker-user * @param {string} type 'annotation' | 'reply' 918*43d2073cStracker-user */ 919*43d2073cStracker-user function showEditForm(entry, data, type) { 920*43d2073cStracker-user var bodyEl = entry.querySelector('.ann-body'); 921*43d2073cStracker-user if (!bodyEl) return; 922*43d2073cStracker-user 923*43d2073cStracker-user var ta = document.createElement('textarea'); 924*43d2073cStracker-user ta.className = 'ann-body-input'; 925*43d2073cStracker-user ta.value = data.body || ''; 926*43d2073cStracker-user ta.rows = 4; 927*43d2073cStracker-user 928*43d2073cStracker-user var row = document.createElement('div'); 929*43d2073cStracker-user row.className = 'ann-form-row'; 930*43d2073cStracker-user 931*43d2073cStracker-user var saveBtn = document.createElement('button'); 932*43d2073cStracker-user saveBtn.type = 'button'; 933*43d2073cStracker-user saveBtn.className = 'ann-btn ann-btn-primary'; 934*43d2073cStracker-user saveBtn.textContent = 'Save'; 935*43d2073cStracker-user saveBtn.addEventListener('click', function () { 936*43d2073cStracker-user var newBody = ta.value.trim(); 937*43d2073cStracker-user if (!newBody) return; 938*43d2073cStracker-user if (type === 'annotation') { 939*43d2073cStracker-user doEditAnnotation(data.id || _openAnnId, newBody); 940*43d2073cStracker-user } else { 941*43d2073cStracker-user doEditReply(data.annId, data.replyId, newBody); 942*43d2073cStracker-user } 943*43d2073cStracker-user }); 944*43d2073cStracker-user 945*43d2073cStracker-user var cancelBtn = document.createElement('button'); 946*43d2073cStracker-user cancelBtn.type = 'button'; 947*43d2073cStracker-user cancelBtn.className = 'ann-btn'; 948*43d2073cStracker-user cancelBtn.textContent = 'Cancel'; 949*43d2073cStracker-user cancelBtn.addEventListener('click', function () { 950*43d2073cStracker-user entry.removeChild(ta); 951*43d2073cStracker-user entry.removeChild(row); 952*43d2073cStracker-user bodyEl.style.display = ''; 953*43d2073cStracker-user }); 954*43d2073cStracker-user 955*43d2073cStracker-user row.appendChild(saveBtn); 956*43d2073cStracker-user row.appendChild(cancelBtn); 957*43d2073cStracker-user 958*43d2073cStracker-user bodyEl.style.display = 'none'; 959*43d2073cStracker-user entry.insertBefore(ta, bodyEl.nextSibling); 960*43d2073cStracker-user entry.insertBefore(row, ta.nextSibling); 961*43d2073cStracker-user ta.focus(); 962*43d2073cStracker-user } 963*43d2073cStracker-user 964*43d2073cStracker-user // ----------------------------------------------------------------------- 965*43d2073cStracker-user // Orphan drawer 966*43d2073cStracker-user // ----------------------------------------------------------------------- 967*43d2073cStracker-user 968*43d2073cStracker-user /** 969*43d2073cStracker-user * Toggle the orphan drawer visibility. 970*43d2073cStracker-user */ 971*43d2073cStracker-user function toggleOrphanDrawer() { 972*43d2073cStracker-user var drawer = document.getElementById('ann-orphan-drawer'); 973*43d2073cStracker-user if (drawer) { 974*43d2073cStracker-user drawer.parentNode.removeChild(drawer); 975*43d2073cStracker-user return; 976*43d2073cStracker-user } 977*43d2073cStracker-user renderOrphanDrawer(); 978*43d2073cStracker-user } 979*43d2073cStracker-user 980*43d2073cStracker-user /** 981*43d2073cStracker-user * Build and insert the orphan drawer at the bottom of the content area. 982*43d2073cStracker-user */ 983*43d2073cStracker-user function renderOrphanDrawer() { 984*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 985*43d2073cStracker-user if (!content) return; 986*43d2073cStracker-user 987*43d2073cStracker-user var drawer = document.createElement('div'); 988*43d2073cStracker-user drawer.id = 'ann-orphan-drawer'; 989*43d2073cStracker-user drawer.className = CLS_ORPHAN_DRAWER; 990*43d2073cStracker-user 991*43d2073cStracker-user var heading = document.createElement('h4'); 992*43d2073cStracker-user heading.textContent = 'Orphaned annotations'; 993*43d2073cStracker-user drawer.appendChild(heading); 994*43d2073cStracker-user 995*43d2073cStracker-user var note = document.createElement('p'); 996*43d2073cStracker-user note.className = 'ann-orphan-note'; 997*43d2073cStracker-user note.textContent = 'These annotations reference text that no longer appears on the page.'; 998*43d2073cStracker-user drawer.appendChild(note); 999*43d2073cStracker-user 1000*43d2073cStracker-user var found = false; 1001*43d2073cStracker-user _annotations.forEach(function (ann) { 1002*43d2073cStracker-user if (!ann._orphaned) return; 1003*43d2073cStracker-user found = true; 1004*43d2073cStracker-user var entry = buildThreadEntry(ann, true); 1005*43d2073cStracker-user drawer.appendChild(entry); 1006*43d2073cStracker-user }); 1007*43d2073cStracker-user 1008*43d2073cStracker-user if (!found) { 1009*43d2073cStracker-user var empty = document.createElement('p'); 1010*43d2073cStracker-user empty.textContent = 'None.'; 1011*43d2073cStracker-user drawer.appendChild(empty); 1012*43d2073cStracker-user } 1013*43d2073cStracker-user 1014*43d2073cStracker-user content.appendChild(drawer); 1015*43d2073cStracker-user } 1016*43d2073cStracker-user 1017*43d2073cStracker-user // ----------------------------------------------------------------------- 1018*43d2073cStracker-user // Selection capture 1019*43d2073cStracker-user // ----------------------------------------------------------------------- 1020*43d2073cStracker-user 1021*43d2073cStracker-user /** 1022*43d2073cStracker-user * Wire up mouseup/touchend listeners to detect text selection. 1023*43d2073cStracker-user * 1024*43d2073cStracker-user * @param {HTMLElement} content 1025*43d2073cStracker-user */ 1026*43d2073cStracker-user function initSelectionCapture(content) { 1027*43d2073cStracker-user if (!_loggedIn) return; // anonymous users cannot annotate 1028*43d2073cStracker-user 1029*43d2073cStracker-user document.addEventListener('mouseup', function (e) { 1030*43d2073cStracker-user handleSelectionEnd(e, content); 1031*43d2073cStracker-user }); 1032*43d2073cStracker-user document.addEventListener('touchend', function (e) { 1033*43d2073cStracker-user // Small delay so the browser has committed the selection. 1034*43d2073cStracker-user setTimeout(function () { handleSelectionEnd(e, content); }, 50); 1035*43d2073cStracker-user }); 1036*43d2073cStracker-user 1037*43d2073cStracker-user // Close tooltip/panel on click outside. 1038*43d2073cStracker-user document.addEventListener('mousedown', function (e) { 1039*43d2073cStracker-user var tooltip = document.getElementById('ann-tooltip'); 1040*43d2073cStracker-user if (tooltip && !tooltip.contains(e.target)) { 1041*43d2073cStracker-user hideTooltip(); 1042*43d2073cStracker-user } 1043*43d2073cStracker-user }); 1044*43d2073cStracker-user } 1045*43d2073cStracker-user 1046*43d2073cStracker-user /** 1047*43d2073cStracker-user * Handle end of selection: show the "Annotate" tooltip if there is a 1048*43d2073cStracker-user * non-empty selection inside the content area. 1049*43d2073cStracker-user * 1050*43d2073cStracker-user * @param {Event} e 1051*43d2073cStracker-user * @param {HTMLElement} content 1052*43d2073cStracker-user */ 1053*43d2073cStracker-user function handleSelectionEnd(e, content) { 1054*43d2073cStracker-user var sel = window.getSelection(); 1055*43d2073cStracker-user if (!sel || sel.isCollapsed) { 1056*43d2073cStracker-user hideTooltip(); 1057*43d2073cStracker-user return; 1058*43d2073cStracker-user } 1059*43d2073cStracker-user var range = sel.getRangeAt(0); 1060*43d2073cStracker-user if (!content.contains(range.commonAncestorContainer)) { 1061*43d2073cStracker-user hideTooltip(); 1062*43d2073cStracker-user return; 1063*43d2073cStracker-user } 1064*43d2073cStracker-user var text = sel.toString().trim(); 1065*43d2073cStracker-user if (text.length < 1) { 1066*43d2073cStracker-user hideTooltip(); 1067*43d2073cStracker-user return; 1068*43d2073cStracker-user } 1069*43d2073cStracker-user 1070*43d2073cStracker-user // Show the tooltip near the end of the selection. 1071*43d2073cStracker-user var rect = range.getBoundingClientRect(); 1072*43d2073cStracker-user showTooltip(rect, range, sel, content); 1073*43d2073cStracker-user } 1074*43d2073cStracker-user 1075*43d2073cStracker-user /** 1076*43d2073cStracker-user * Show the "Annotate" tooltip bubble. 1077*43d2073cStracker-user * 1078*43d2073cStracker-user * @param {DOMRect} rect bounding rect of the selection 1079*43d2073cStracker-user * @param {Range} range 1080*43d2073cStracker-user * @param {Selection} sel 1081*43d2073cStracker-user * @param {HTMLElement} content 1082*43d2073cStracker-user */ 1083*43d2073cStracker-user function showTooltip(rect, range, sel, content) { 1084*43d2073cStracker-user hideTooltip(); 1085*43d2073cStracker-user 1086*43d2073cStracker-user var tip = document.createElement('div'); 1087*43d2073cStracker-user tip.id = 'ann-tooltip'; 1088*43d2073cStracker-user tip.className = CLS_TOOLTIP; 1089*43d2073cStracker-user 1090*43d2073cStracker-user var btn = document.createElement('button'); 1091*43d2073cStracker-user btn.type = 'button'; 1092*43d2073cStracker-user btn.textContent = 'Annotate'; 1093*43d2073cStracker-user btn.className = 'ann-btn ann-btn-primary'; 1094*43d2073cStracker-user btn.addEventListener('mousedown', function (e) { 1095*43d2073cStracker-user e.preventDefault(); // don't lose the selection 1096*43d2073cStracker-user }); 1097*43d2073cStracker-user btn.addEventListener('click', function () { 1098*43d2073cStracker-user var anchor = captureAnchor(sel, range, content); 1099*43d2073cStracker-user hideTooltip(); 1100*43d2073cStracker-user sel.removeAllRanges(); 1101*43d2073cStracker-user if (anchor) { 1102*43d2073cStracker-user openNewAnnotationForm(anchor, range); 1103*43d2073cStracker-user } 1104*43d2073cStracker-user }); 1105*43d2073cStracker-user tip.appendChild(btn); 1106*43d2073cStracker-user 1107*43d2073cStracker-user document.body.appendChild(tip); 1108*43d2073cStracker-user 1109*43d2073cStracker-user // Position below the selection's end. 1110*43d2073cStracker-user var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 1111*43d2073cStracker-user var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 1112*43d2073cStracker-user tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 1113*43d2073cStracker-user tip.style.left = (rect.left + scrollLeft) + 'px'; 1114*43d2073cStracker-user } 1115*43d2073cStracker-user 1116*43d2073cStracker-user /** 1117*43d2073cStracker-user * Remove the tooltip if it exists. 1118*43d2073cStracker-user */ 1119*43d2073cStracker-user function hideTooltip() { 1120*43d2073cStracker-user var tip = document.getElementById('ann-tooltip'); 1121*43d2073cStracker-user if (tip && tip.parentNode) { 1122*43d2073cStracker-user tip.parentNode.removeChild(tip); 1123*43d2073cStracker-user } 1124*43d2073cStracker-user // Also remove any floating new-annotation form. 1125*43d2073cStracker-user var naf = document.getElementById('ann-new-form'); 1126*43d2073cStracker-user if (naf && naf.parentNode) { 1127*43d2073cStracker-user naf.parentNode.removeChild(naf); 1128*43d2073cStracker-user } 1129*43d2073cStracker-user } 1130*43d2073cStracker-user 1131*43d2073cStracker-user /** 1132*43d2073cStracker-user * Capture an anchor object from the current Selection. 1133*43d2073cStracker-user * 1134*43d2073cStracker-user * @param {Selection} sel 1135*43d2073cStracker-user * @param {Range} range 1136*43d2073cStracker-user * @param {HTMLElement} content 1137*43d2073cStracker-user * @returns {object|null} {exact, prefix, suffix, start} 1138*43d2073cStracker-user */ 1139*43d2073cStracker-user function captureAnchor(sel, range, content) { 1140*43d2073cStracker-user var exact = normalizeWS(sel.toString()); 1141*43d2073cStracker-user if (!exact) return null; 1142*43d2073cStracker-user 1143*43d2073cStracker-user // Get full page text for prefix/suffix and start computation. 1144*43d2073cStracker-user var chunks = collectTextChunks(content); 1145*43d2073cStracker-user var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1146*43d2073cStracker-user var fullNorm = normalizeWS(fullRaw); 1147*43d2073cStracker-user 1148*43d2073cStracker-user // Find where this text node + offset lands in the raw full text. 1149*43d2073cStracker-user var rawStart = 0; 1150*43d2073cStracker-user for (var i = 0; i < chunks.length; i++) { 1151*43d2073cStracker-user var c = chunks[i]; 1152*43d2073cStracker-user if (c.node === range.startContainer) { 1153*43d2073cStracker-user rawStart = c.start + range.startOffset; 1154*43d2073cStracker-user break; 1155*43d2073cStracker-user } 1156*43d2073cStracker-user } 1157*43d2073cStracker-user 1158*43d2073cStracker-user // Map raw offset to normalised offset. 1159*43d2073cStracker-user var normToRaw = buildNormToRaw(fullRaw); 1160*43d2073cStracker-user var normStart = 0; 1161*43d2073cStracker-user for (var j = 0; j < normToRaw.length; j++) { 1162*43d2073cStracker-user if (normToRaw[j] >= rawStart) { 1163*43d2073cStracker-user normStart = j; 1164*43d2073cStracker-user break; 1165*43d2073cStracker-user } 1166*43d2073cStracker-user } 1167*43d2073cStracker-user 1168*43d2073cStracker-user var CTX = 30; 1169*43d2073cStracker-user var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 1170*43d2073cStracker-user var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 1171*43d2073cStracker-user 1172*43d2073cStracker-user return { 1173*43d2073cStracker-user exact: exact, 1174*43d2073cStracker-user prefix: prefix, 1175*43d2073cStracker-user suffix: suffix, 1176*43d2073cStracker-user start: normStart, 1177*43d2073cStracker-user }; 1178*43d2073cStracker-user } 1179*43d2073cStracker-user 1180*43d2073cStracker-user /** 1181*43d2073cStracker-user * Open the new-annotation form below the paragraph containing the selection. 1182*43d2073cStracker-user * 1183*43d2073cStracker-user * @param {object} anchor {exact, prefix, suffix, start} 1184*43d2073cStracker-user * @param {Range} range 1185*43d2073cStracker-user */ 1186*43d2073cStracker-user function openNewAnnotationForm(anchor, range) { 1187*43d2073cStracker-user closePanel(); 1188*43d2073cStracker-user 1189*43d2073cStracker-user var insertAfter = findParagraph(range.commonAncestorContainer); 1190*43d2073cStracker-user var form = document.createElement('div'); 1191*43d2073cStracker-user form.id = 'ann-new-form'; 1192*43d2073cStracker-user form.className = 'ann-new-form'; 1193*43d2073cStracker-user 1194*43d2073cStracker-user var quote = document.createElement('blockquote'); 1195*43d2073cStracker-user quote.className = 'ann-quote'; 1196*43d2073cStracker-user quote.textContent = anchor.exact; 1197*43d2073cStracker-user form.appendChild(quote); 1198*43d2073cStracker-user 1199*43d2073cStracker-user var ta = document.createElement('textarea'); 1200*43d2073cStracker-user ta.className = 'ann-body-input'; 1201*43d2073cStracker-user ta.placeholder = 'Add a comment…'; 1202*43d2073cStracker-user ta.rows = 4; 1203*43d2073cStracker-user form.appendChild(ta); 1204*43d2073cStracker-user 1205*43d2073cStracker-user var row = document.createElement('div'); 1206*43d2073cStracker-user row.className = 'ann-form-row'; 1207*43d2073cStracker-user 1208*43d2073cStracker-user var submitBtn = document.createElement('button'); 1209*43d2073cStracker-user submitBtn.type = 'button'; 1210*43d2073cStracker-user submitBtn.className = 'ann-btn ann-btn-primary'; 1211*43d2073cStracker-user submitBtn.textContent = 'Annotate'; 1212*43d2073cStracker-user submitBtn.addEventListener('click', function () { 1213*43d2073cStracker-user var body = ta.value.trim(); 1214*43d2073cStracker-user if (!body) return; 1215*43d2073cStracker-user doCreate(anchor, body, function () { 1216*43d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1217*43d2073cStracker-user }); 1218*43d2073cStracker-user }); 1219*43d2073cStracker-user 1220*43d2073cStracker-user var cancelBtn = document.createElement('button'); 1221*43d2073cStracker-user cancelBtn.type = 'button'; 1222*43d2073cStracker-user cancelBtn.className = 'ann-btn'; 1223*43d2073cStracker-user cancelBtn.textContent = 'Cancel'; 1224*43d2073cStracker-user cancelBtn.addEventListener('click', function () { 1225*43d2073cStracker-user if (form.parentNode) form.parentNode.removeChild(form); 1226*43d2073cStracker-user }); 1227*43d2073cStracker-user 1228*43d2073cStracker-user row.appendChild(submitBtn); 1229*43d2073cStracker-user row.appendChild(cancelBtn); 1230*43d2073cStracker-user form.appendChild(row); 1231*43d2073cStracker-user 1232*43d2073cStracker-user if (insertAfter && insertAfter.parentNode) { 1233*43d2073cStracker-user insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 1234*43d2073cStracker-user } else { 1235*43d2073cStracker-user var content = document.getElementById(CONTENT_ID); 1236*43d2073cStracker-user if (content) content.appendChild(form); 1237*43d2073cStracker-user } 1238*43d2073cStracker-user 1239*43d2073cStracker-user ta.focus(); 1240*43d2073cStracker-user } 1241*43d2073cStracker-user 1242*43d2073cStracker-user // ----------------------------------------------------------------------- 1243*43d2073cStracker-user // AJAX actions 1244*43d2073cStracker-user // ----------------------------------------------------------------------- 1245*43d2073cStracker-user 1246*43d2073cStracker-user /** 1247*43d2073cStracker-user * POST create action and update state on success. 1248*43d2073cStracker-user * 1249*43d2073cStracker-user * @param {object} anchor 1250*43d2073cStracker-user * @param {string} body 1251*43d2073cStracker-user * @param {Function} onSuccess 1252*43d2073cStracker-user */ 1253*43d2073cStracker-user function doCreate(anchor, body, onSuccess) { 1254*43d2073cStracker-user ajax({ 1255*43d2073cStracker-user action: 'create', 1256*43d2073cStracker-user id: _info.pageId, 1257*43d2073cStracker-user anchor: anchor, 1258*43d2073cStracker-user body: body, 1259*43d2073cStracker-user }).then(function (data) { 1260*43d2073cStracker-user if (!data.success) { 1261*43d2073cStracker-user alert('Could not save annotation: ' + (data.error || 'Unknown error')); 1262*43d2073cStracker-user return; 1263*43d2073cStracker-user } 1264*43d2073cStracker-user var ann = data.annotation; 1265*43d2073cStracker-user _annotations.set(ann.id, ann); 1266*43d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(ann); 1267*43d2073cStracker-user renderAll(); 1268*43d2073cStracker-user }).catch(function () { 1269*43d2073cStracker-user alert('Could not save annotation.'); 1270*43d2073cStracker-user }); 1271*43d2073cStracker-user } 1272*43d2073cStracker-user 1273*43d2073cStracker-user /** 1274*43d2073cStracker-user * POST reply action and refresh the open panel. 1275*43d2073cStracker-user * 1276*43d2073cStracker-user * @param {string} annId 1277*43d2073cStracker-user * @param {string} body 1278*43d2073cStracker-user * @param {Function} onSuccess 1279*43d2073cStracker-user */ 1280*43d2073cStracker-user function doAddReply(annId, body, onSuccess) { 1281*43d2073cStracker-user ajax({ 1282*43d2073cStracker-user action: 'reply', 1283*43d2073cStracker-user id: _info.pageId, 1284*43d2073cStracker-user annId: annId, 1285*43d2073cStracker-user body: body, 1286*43d2073cStracker-user }).then(function (data) { 1287*43d2073cStracker-user if (!data.success) { 1288*43d2073cStracker-user alert('Could not save reply: ' + (data.error || '')); 1289*43d2073cStracker-user return; 1290*43d2073cStracker-user } 1291*43d2073cStracker-user // Re-fetch the updated annotation from server. 1292*43d2073cStracker-user refreshAnnotation(annId, function () { 1293*43d2073cStracker-user if (typeof onSuccess === 'function') onSuccess(); 1294*43d2073cStracker-user reopenPanel(annId); 1295*43d2073cStracker-user }); 1296*43d2073cStracker-user }).catch(function () { 1297*43d2073cStracker-user alert('Could not save reply.'); 1298*43d2073cStracker-user }); 1299*43d2073cStracker-user } 1300*43d2073cStracker-user 1301*43d2073cStracker-user /** 1302*43d2073cStracker-user * POST edit_annotation and re-render. 1303*43d2073cStracker-user * 1304*43d2073cStracker-user * @param {string} annId 1305*43d2073cStracker-user * @param {string} body 1306*43d2073cStracker-user */ 1307*43d2073cStracker-user function doEditAnnotation(annId, body) { 1308*43d2073cStracker-user ajax({ 1309*43d2073cStracker-user action: 'edit_annotation', 1310*43d2073cStracker-user id: _info.pageId, 1311*43d2073cStracker-user annId: annId, 1312*43d2073cStracker-user body: body, 1313*43d2073cStracker-user }).then(function (data) { 1314*43d2073cStracker-user if (!data.success) { 1315*43d2073cStracker-user alert('Could not save: ' + (data.error || '')); 1316*43d2073cStracker-user return; 1317*43d2073cStracker-user } 1318*43d2073cStracker-user var updated = data.annotation; 1319*43d2073cStracker-user _annotations.set(updated.id, updated); 1320*43d2073cStracker-user reopenPanel(annId); 1321*43d2073cStracker-user }); 1322*43d2073cStracker-user } 1323*43d2073cStracker-user 1324*43d2073cStracker-user /** 1325*43d2073cStracker-user * POST edit_reply and re-render. 1326*43d2073cStracker-user * 1327*43d2073cStracker-user * @param {string} annId 1328*43d2073cStracker-user * @param {string} replyId 1329*43d2073cStracker-user * @param {string} body 1330*43d2073cStracker-user */ 1331*43d2073cStracker-user function doEditReply(annId, replyId, body) { 1332*43d2073cStracker-user ajax({ 1333*43d2073cStracker-user action: 'edit_reply', 1334*43d2073cStracker-user id: _info.pageId, 1335*43d2073cStracker-user annId: annId, 1336*43d2073cStracker-user replyId: replyId, 1337*43d2073cStracker-user body: body, 1338*43d2073cStracker-user }).then(function (data) { 1339*43d2073cStracker-user if (!data.success) { 1340*43d2073cStracker-user alert('Could not save: ' + (data.error || '')); 1341*43d2073cStracker-user return; 1342*43d2073cStracker-user } 1343*43d2073cStracker-user var updated = data.annotation; 1344*43d2073cStracker-user _annotations.set(updated.id, updated); 1345*43d2073cStracker-user reopenPanel(annId); 1346*43d2073cStracker-user }); 1347*43d2073cStracker-user } 1348*43d2073cStracker-user 1349*43d2073cStracker-user /** 1350*43d2073cStracker-user * POST delete_annotation. 1351*43d2073cStracker-user * 1352*43d2073cStracker-user * @param {string} annId 1353*43d2073cStracker-user */ 1354*43d2073cStracker-user function doDeleteAnnotation(annId) { 1355*43d2073cStracker-user ajax({ 1356*43d2073cStracker-user action: 'delete_annotation', 1357*43d2073cStracker-user id: _info.pageId, 1358*43d2073cStracker-user annId: annId, 1359*43d2073cStracker-user }).then(function (data) { 1360*43d2073cStracker-user if (!data.success) { 1361*43d2073cStracker-user alert('Could not delete: ' + (data.error || '')); 1362*43d2073cStracker-user return; 1363*43d2073cStracker-user } 1364*43d2073cStracker-user _annotations.delete(annId); 1365*43d2073cStracker-user closePanel(); 1366*43d2073cStracker-user renderAll(); 1367*43d2073cStracker-user }); 1368*43d2073cStracker-user } 1369*43d2073cStracker-user 1370*43d2073cStracker-user /** 1371*43d2073cStracker-user * POST delete_reply and re-render. 1372*43d2073cStracker-user * 1373*43d2073cStracker-user * @param {string} annId 1374*43d2073cStracker-user * @param {string} replyId 1375*43d2073cStracker-user */ 1376*43d2073cStracker-user function doDeleteReply(annId, replyId) { 1377*43d2073cStracker-user ajax({ 1378*43d2073cStracker-user action: 'delete_reply', 1379*43d2073cStracker-user id: _info.pageId, 1380*43d2073cStracker-user annId: annId, 1381*43d2073cStracker-user replyId: replyId, 1382*43d2073cStracker-user }).then(function (data) { 1383*43d2073cStracker-user if (!data.success) { 1384*43d2073cStracker-user alert('Could not delete: ' + (data.error || '')); 1385*43d2073cStracker-user return; 1386*43d2073cStracker-user } 1387*43d2073cStracker-user var updated = data.annotation; 1388*43d2073cStracker-user _annotations.set(updated.id, updated); 1389*43d2073cStracker-user reopenPanel(annId); 1390*43d2073cStracker-user }); 1391*43d2073cStracker-user } 1392*43d2073cStracker-user 1393*43d2073cStracker-user /** 1394*43d2073cStracker-user * POST resolve/reopen action. 1395*43d2073cStracker-user * 1396*43d2073cStracker-user * @param {string} annId 1397*43d2073cStracker-user * @param {string} status 'open' | 'resolved' 1398*43d2073cStracker-user */ 1399*43d2073cStracker-user function doResolve(annId, status) { 1400*43d2073cStracker-user ajax({ 1401*43d2073cStracker-user action: 'resolve', 1402*43d2073cStracker-user id: _info.pageId, 1403*43d2073cStracker-user annId: annId, 1404*43d2073cStracker-user status: status, 1405*43d2073cStracker-user }).then(function (data) { 1406*43d2073cStracker-user if (!data.success) { 1407*43d2073cStracker-user alert('Could not update status: ' + (data.error || '')); 1408*43d2073cStracker-user return; 1409*43d2073cStracker-user } 1410*43d2073cStracker-user var updated = data.annotation; 1411*43d2073cStracker-user _annotations.set(updated.id, updated); 1412*43d2073cStracker-user renderAll(); 1413*43d2073cStracker-user reopenPanel(annId); 1414*43d2073cStracker-user }); 1415*43d2073cStracker-user } 1416*43d2073cStracker-user 1417*43d2073cStracker-user /** 1418*43d2073cStracker-user * POST clear_resolved (admin). 1419*43d2073cStracker-user */ 1420*43d2073cStracker-user function doClearResolved() { 1421*43d2073cStracker-user if (!confirm('Delete all resolved annotations on this page?')) return; 1422*43d2073cStracker-user ajax({ 1423*43d2073cStracker-user action: 'clear_resolved', 1424*43d2073cStracker-user id: _info.pageId, 1425*43d2073cStracker-user }).then(function (data) { 1426*43d2073cStracker-user if (!data.success) { 1427*43d2073cStracker-user alert('Could not clear: ' + (data.error || '')); 1428*43d2073cStracker-user return; 1429*43d2073cStracker-user } 1430*43d2073cStracker-user // Remove resolved from local state. 1431*43d2073cStracker-user _annotations.forEach(function (ann, id) { 1432*43d2073cStracker-user if (ann.status === 'resolved') _annotations.delete(id); 1433*43d2073cStracker-user }); 1434*43d2073cStracker-user closePanel(); 1435*43d2073cStracker-user renderAll(); 1436*43d2073cStracker-user }); 1437*43d2073cStracker-user } 1438*43d2073cStracker-user 1439*43d2073cStracker-user /** 1440*43d2073cStracker-user * POST clear_orphaned (admin). 1441*43d2073cStracker-user */ 1442*43d2073cStracker-user function doClearOrphaned() { 1443*43d2073cStracker-user if (!confirm('Delete all orphaned annotations on this page?')) return; 1444*43d2073cStracker-user ajax({ 1445*43d2073cStracker-user action: 'clear_orphaned', 1446*43d2073cStracker-user id: _info.pageId, 1447*43d2073cStracker-user }).then(function (data) { 1448*43d2073cStracker-user if (!data.success) { 1449*43d2073cStracker-user alert('Could not clear: ' + (data.error || '')); 1450*43d2073cStracker-user return; 1451*43d2073cStracker-user } 1452*43d2073cStracker-user _annotations.forEach(function (ann, id) { 1453*43d2073cStracker-user if (ann._orphaned) _annotations.delete(id); 1454*43d2073cStracker-user }); 1455*43d2073cStracker-user closePanel(); 1456*43d2073cStracker-user renderAll(); 1457*43d2073cStracker-user }); 1458*43d2073cStracker-user } 1459*43d2073cStracker-user 1460*43d2073cStracker-user // ----------------------------------------------------------------------- 1461*43d2073cStracker-user // Panel management helpers 1462*43d2073cStracker-user // ----------------------------------------------------------------------- 1463*43d2073cStracker-user 1464*43d2073cStracker-user /** 1465*43d2073cStracker-user * Re-fetch one annotation from the server and update local state. 1466*43d2073cStracker-user * 1467*43d2073cStracker-user * Note: the AJAX endpoint doesn't have a standalone "get one" action, 1468*43d2073cStracker-user * so we ask the load endpoint (GET) and pull the matching entry out. 1469*43d2073cStracker-user * 1470*43d2073cStracker-user * @param {string} annId 1471*43d2073cStracker-user * @param {Function} cb 1472*43d2073cStracker-user */ 1473*43d2073cStracker-user function refreshAnnotation(annId, cb) { 1474*43d2073cStracker-user fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 1475*43d2073cStracker-user method: 'GET', 1476*43d2073cStracker-user }).then(function (res) { 1477*43d2073cStracker-user return res.json(); 1478*43d2073cStracker-user }).then(function (data) { 1479*43d2073cStracker-user if (data && Array.isArray(data.annotations)) { 1480*43d2073cStracker-user data.annotations.forEach(function (ann) { 1481*43d2073cStracker-user _annotations.set(ann.id, ann); 1482*43d2073cStracker-user }); 1483*43d2073cStracker-user } 1484*43d2073cStracker-user if (typeof cb === 'function') cb(); 1485*43d2073cStracker-user }).catch(function () { 1486*43d2073cStracker-user if (typeof cb === 'function') cb(); 1487*43d2073cStracker-user }); 1488*43d2073cStracker-user } 1489*43d2073cStracker-user 1490*43d2073cStracker-user /** 1491*43d2073cStracker-user * Close the current panel and re-open it (preserves scroll position and 1492*43d2073cStracker-user * re-renders the thread with fresh data). 1493*43d2073cStracker-user * 1494*43d2073cStracker-user * @param {string} annId 1495*43d2073cStracker-user */ 1496*43d2073cStracker-user function reopenPanel(annId) { 1497*43d2073cStracker-user closePanel(); 1498*43d2073cStracker-user openPanel(annId); 1499*43d2073cStracker-user } 1500*43d2073cStracker-user 1501*43d2073cStracker-user // ----------------------------------------------------------------------- 1502*43d2073cStracker-user // Utilities 1503*43d2073cStracker-user // ----------------------------------------------------------------------- 1504*43d2073cStracker-user 1505*43d2073cStracker-user /** 1506*43d2073cStracker-user * Collapse consecutive whitespace to a single space and trim. 1507*43d2073cStracker-user * 1508*43d2073cStracker-user * @param {string} s 1509*43d2073cStracker-user * @returns {string} 1510*43d2073cStracker-user */ 1511*43d2073cStracker-user function normalizeWS(s) { 1512*43d2073cStracker-user return String(s || '').replace(/\s+/g, ' ').trim(); 1513*43d2073cStracker-user } 1514*43d2073cStracker-user 1515*43d2073cStracker-user /** 1516*43d2073cStracker-user * Return the current DokuWiki username from JSINFO. 1517*43d2073cStracker-user * 1518*43d2073cStracker-user * @returns {string} 1519*43d2073cStracker-user */ 1520*43d2073cStracker-user function currentUser() { 1521*43d2073cStracker-user var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 1522*43d2073cStracker-user return (jsinfo.userinfo && jsinfo.userinfo.user) ? jsinfo.userinfo.user : ''; 1523*43d2073cStracker-user } 1524*43d2073cStracker-user 1525*43d2073cStracker-user /** 1526*43d2073cStracker-user * Format a Date for display. 1527*43d2073cStracker-user * 1528*43d2073cStracker-user * @param {Date} d 1529*43d2073cStracker-user * @returns {string} 1530*43d2073cStracker-user */ 1531*43d2073cStracker-user function formatDate(d) { 1532*43d2073cStracker-user var now = new Date(); 1533*43d2073cStracker-user var diff = (now - d) / 1000; // seconds 1534*43d2073cStracker-user if (diff < 60) return 'just now'; 1535*43d2073cStracker-user if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; 1536*43d2073cStracker-user if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; 1537*43d2073cStracker-user if (diff < 86400 * 7) return Math.floor(diff / 86400) + 'd ago'; 1538*43d2073cStracker-user return d.toLocaleDateString(); 1539*43d2073cStracker-user } 1540*43d2073cStracker-user 1541*43d2073cStracker-user // ----------------------------------------------------------------------- 1542*43d2073cStracker-user // Init 1543*43d2073cStracker-user // ----------------------------------------------------------------------- 1544*43d2073cStracker-user 1545*43d2073cStracker-user if (document.readyState === 'loading') { 1546*43d2073cStracker-user document.addEventListener('DOMContentLoaded', boot); 1547*43d2073cStracker-user } else { 1548*43d2073cStracker-user boot(); 1549*43d2073cStracker-user } 1550*43d2073cStracker-user 1551*43d2073cStracker-user}()); 1552