1/** 2 * Annotations plugin — front-end script. 3 * 4 * Responsibilities: 5 * 6 * 1. BOOT: read JSINFO.annotations (injected by action.php); if the user 7 * has disabled annotations, exit early. 8 * 9 * 2. LOAD: fetch the page's annotation list via the AJAX endpoint, then: 10 * a. Anchor each annotation in the DOM (re-anchoring). 11 * b. Wrap matched text in highlight <span>s. 12 * c. Render per-line gutter markers. 13 * d. Update the page counter bubble. 14 * 15 * 3. SELECTION: detect when the user finishes a text selection inside the 16 * wiki content area, show an "Annotate" tooltip, capture the anchor on 17 * click, and open a new-annotation form. 18 * 19 * 4. PANELS: clicking a highlight opens the annotation thread inline, just 20 * below the paragraph that contains the highlight. One open panel at a 21 * time. The panel renders the full thread: author, timestamp, body, 22 * replies; and permission-gated action buttons. 23 * 24 * 5. AJAX: all state-changing operations POST JSON to 25 * /lib/exe/ajax.php?call=annotations (with the DokuWiki security token). 26 * Responses update the in-memory state and re-render affected highlights 27 * / gutter markers / counter without a page reload. 28 * 29 * 6. ORPHANS: annotations that cannot be re-anchored are counted and 30 * reachable via the orphaned counter link; their full threads (root 31 * message plus the read-only reply tree) render in a dedicated orphan 32 * drawer at the bottom of the content area. 33 * 34 * FF78 ESR compatibility: 35 * - No #private fields, ??=, ||=, &&=, Array.at, structuredClone, 36 * Object.hasOwn, native <dialog>. 37 * - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK. 38 */ 39 40(function () { 41 'use strict'; 42 43 // ----------------------------------------------------------------------- 44 // Constants 45 // ----------------------------------------------------------------------- 46 47 var AJAX_URL = DOKU_BASE + 'lib/exe/ajax.php?call=annotations'; 48 var CONTENT_ID = 'dokuwiki__content'; 49 // .page is the article area inside #dokuwiki__content. Gutter markers 50 // are appended here so position:relative doesn't break the sidebar nav. 51 var PAGE_CLS = 'page'; 52 53 // Colour tokens (also defined in style.css; kept here so JS can read them) 54 var CLS_HIGHLIGHT_OPEN = 'ann-highlight-open'; 55 var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved'; 56 var CLS_GUTTER_MARKER = 'ann-gutter-marker'; 57 var CLS_PANEL = 'ann-panel'; 58 var CLS_COUNTER = 'ann-counter'; 59 var CLS_TOOLTIP = 'ann-tooltip'; 60 var CLS_ORPHAN_DRAWER = 'ann-orphan-drawer'; 61 62 // ----------------------------------------------------------------------- 63 // State 64 // ----------------------------------------------------------------------- 65 66 /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */ 67 var _annotations = new Map(); 68 69 /** Currently open panel element, or null. @type {HTMLElement|null} */ 70 var _openPanel = null; 71 72 /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */ 73 var _pendingAnchor = null; 74 75 /** ID of the annotation whose panel is open, or null. @type {string|null} */ 76 var _openAnnId = null; 77 78 /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */ 79 var _info = {}; 80 81 /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */ 82 var _lang = {}; 83 84 /** The DokuWiki security token. @type {string} */ 85 var _token = ''; 86 87 /** Whether the current user is logged in. @type {bool} */ 88 var _loggedIn = false; 89 90 /** Whether the current user is an admin. @type {bool} */ 91 var _isAdmin = false; 92 93 // ----------------------------------------------------------------------- 94 // Boot 95 // ----------------------------------------------------------------------- 96 97 /** 98 * Entry point: wired to DOMContentLoaded. 99 */ 100 function boot() { 101 var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 102 var annInfo = jsinfo.annotations || {}; 103 104 if (!annInfo.enabled) { 105 return; // user disabled annotations 106 } 107 108 _info = annInfo; 109 // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as 110 // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 111 _lang = uiLang(); 112 // Token is injected into JSINFO.annotations by action.php (handleMetaHeader). 113 // getSecurityToken() on the server produces it from session_id + REMOTE_USER. 114 _token = annInfo.token || ''; 115 116 // DokuWiki's JSINFO doesn't include user identity; we inject 117 // user + isAdmin into JSINFO.annotations from PHP (action.php). 118 _loggedIn = !!(annInfo.user && annInfo.user !== ''); 119 _isAdmin = !!(annInfo.isAdmin); 120 121 var content = document.getElementById(CONTENT_ID); 122 if (!content) { 123 return; // not a page view 124 } 125 126 renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0); 127 loadAnnotations(); 128 initSelectionCapture(content); 129 130 // Close the open panel when the user presses Escape. 131 document.addEventListener('keydown', function (e) { 132 if ((e.key === 'Escape' || e.key === 'Esc') && _openPanel) { 133 closePanel(); 134 } 135 }); 136 137 // Keep gutter markers aligned with their highlights when the viewport 138 // width changes: both the .page column and the highlights reflow. 139 window.addEventListener('resize', repositionMarkers); 140 141 // Annotations now render at DOMContentLoaded (the list ships inline), 142 // so late-loading images/web fonts can still shift the layout under the 143 // already-placed markers. Re-align them once everything has loaded. 144 window.addEventListener('load', repositionMarkers); 145 } 146 147 // ----------------------------------------------------------------------- 148 // AJAX helpers 149 // ----------------------------------------------------------------------- 150 151 /** 152 * POST a JSON payload to the AJAX endpoint. 153 * 154 * @param {object} payload 155 * @returns {Promise<object>} response data 156 */ 157 function ajax(payload) { 158 payload.sectok = _token; // DokuWiki security token field name for AJAX 159 return fetch(AJAX_URL, { 160 method: 'POST', 161 headers: {'Content-Type': 'application/json'}, 162 body: JSON.stringify(payload), 163 }).then(function (res) { 164 return res.json(); 165 }); 166 } 167 168 // ----------------------------------------------------------------------- 169 // Load and anchor annotations 170 // ----------------------------------------------------------------------- 171 172 /** 173 * Load all annotations for the current page and render them. 174 * 175 * Fast path: action.php normally ships the list inline with the page (in 176 * JSINFO.annotations.annotations), so we render straight away with no 177 * round-trip. Only heavily-annotated pages omit the inline list, in which 178 * case we fall back to the GET 'load' endpoint. 179 */ 180 function loadAnnotations() { 181 if (Array.isArray(_info.annotations)) { 182 ingestAnnotations(_info.annotations); 183 return; 184 } 185 186 // Fallback: the inline list was too large to embed. Fetch it instead. 187 // action.php's AJAX handler accepts action=load as a GET query. 188 fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 189 method: 'GET', 190 }).then(function (res) { 191 return res.json(); 192 }).then(function (data) { 193 if (!data || !Array.isArray(data.annotations)) { 194 return; 195 } 196 ingestAnnotations(data.annotations); 197 }).catch(function () { 198 // Graceful degradation: page still works without annotations. 199 }); 200 } 201 202 /** 203 * Store a loaded annotation list (inline or fetched) and render everything. 204 * 205 * @param {Array} list annotation objects from the server 206 */ 207 function ingestAnnotations(list) { 208 list.forEach(function (ann) { 209 _annotations.set(ann.id, ann); 210 }); 211 renderAll(); 212 } 213 214 /** 215 * Re-render everything: highlights, gutter markers, counter. 216 */ 217 function renderAll() { 218 clearHighlights(); 219 clearGutterMarkers(); 220 221 var content = document.getElementById(CONTENT_ID); 222 if (!content) return; 223 224 // Snapshot the page text ONCE, before any highlight is inserted. 225 // Re-collecting per annotation would exclude already-wrapped text 226 // (collectTextChunks skips our own UI), shifting every later anchor. 227 var chunks = collectTextChunks(content); 228 var rawFull = chunks.map(function (c) { return c.text; }).join(''); 229 var nm = normalizeWithMap(rawFull); 230 231 // Phase 1 — locate every annotation against the clean snapshot. 232 var hits = []; 233 _annotations.forEach(function (ann) { 234 ann._range = null; 235 ann._highlightEl = null; 236 var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null; 237 if (hit) { 238 hits.push({ann: ann, pos: hit.pos, len: hit.len}); 239 ann._orphaned = false; 240 } else { 241 ann._orphaned = true; 242 } 243 }); 244 245 // Phase 2 — wrap later matches first, so wrapping (which splits text 246 // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones. 247 hits.sort(function (a, b) { return b.pos - a.pos; }); 248 hits.forEach(function (h) { 249 var range = buildRange(chunks, nm.map, h.pos, h.len); 250 if (range) { 251 h.ann._range = range; // cache for panel positioning 252 wrapHighlight(range, h.ann); 253 } else { 254 h.ann._orphaned = true; 255 } 256 }); 257 258 renderGutterMarkers(); 259 updateCounter(); // recounts orphans from the _orphaned flags set above 260 } 261 262 // ----------------------------------------------------------------------- 263 // Text anchoring (re-anchoring) 264 // ----------------------------------------------------------------------- 265 266 /** 267 * Locate an anchor's quoted text within the normalised page text. 268 * 269 * Algorithm: 270 * 1. Search for the exact quote (normalised). 271 * 2. If found multiple times, use prefix/suffix to disambiguate. 272 * 3. If still ambiguous, use the start offset hint. 273 * 274 * Returns offsets into the normalised string; buildRange maps them back 275 * to a DOM Range via the normalised→raw index map. 276 * 277 * @param {string} norm normalised page text (from normalizeWithMap) 278 * @param {object} anchor {exact, prefix, suffix, start} 279 * @returns {{pos:number, len:number}|null} 280 */ 281 function locate(norm, anchor) { 282 if (!anchor || !anchor.exact) return null; 283 284 var exact = normalizeWS(anchor.exact); 285 if (exact === '') return null; 286 var prefix = normalizeWS(anchor.prefix || ''); 287 var suffix = normalizeWS(anchor.suffix || ''); 288 var hint = anchor.start || 0; 289 290 // Find all occurrences of exact. 291 var positions = []; 292 var from = 0; 293 var idx; 294 while ((idx = norm.indexOf(exact, from)) !== -1) { 295 positions.push(idx); 296 from = idx + exact.length; 297 } 298 299 if (positions.length === 0) return null; 300 301 var chosen = positions[0]; 302 303 if (positions.length > 1) { 304 // Disambiguate using prefix + suffix context, tie-break on the hint. 305 var bestScore = -1; 306 positions.forEach(function (pos) { 307 var pre = norm.slice(Math.max(0, pos - prefix.length), pos); 308 var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length); 309 var score = 0; 310 if (prefix && pre.indexOf(prefix) !== -1) score++; 311 if (suffix && suf.indexOf(suffix) !== -1) score++; 312 var distToHint = Math.abs(pos - hint); 313 if (score > bestScore || 314 (score === bestScore && distToHint < Math.abs(chosen - hint))) { 315 bestScore = score; 316 chosen = pos; 317 } 318 }); 319 } 320 321 return {pos: chosen, len: exact.length}; 322 } 323 324 /** 325 * Walk the text nodes under root and return an array of 326 * {node, start, text} objects where start is the cumulative character 327 * offset of this node's text in the joined string. 328 * 329 * The joined string is NOT normalised here — we normalise the full string 330 * once above instead. 331 * 332 * @param {HTMLElement} root 333 * @returns {Array<{node:Text, start:number, text:string}>} 334 */ 335 function collectTextChunks(root) { 336 var walker = document.createTreeWalker( 337 root, 338 NodeFilter.SHOW_TEXT, 339 null, 340 false 341 ); 342 var chunks = []; 343 var offset = 0; 344 var node; 345 while ((node = walker.nextNode())) { 346 // Skip nodes inside our own UI elements. 347 if (isAnnotationUI(node.parentNode)) continue; 348 var text = node.nodeValue || ''; 349 chunks.push({node: node, start: offset, text: text}); 350 offset += text.length; 351 } 352 return chunks; 353 } 354 355 /** 356 * The first existing highlight span the given range overlaps, or null. 357 * Used to redirect a selection that touches an annotation into opening it, 358 * rather than offering to create a new (overlapping) one. intersectsNode is 359 * supported in Firefox 78 ESR. 360 * 361 * @param {Range} range 362 * @returns {HTMLElement|null} 363 */ 364 function selectionHitsHighlight(range) { 365 var spans = document.querySelectorAll( 366 '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 367 ); 368 for (var i = 0; i < spans.length; i++) { 369 if (range.intersectsNode(spans[i])) { 370 return spans[i]; 371 } 372 } 373 return null; 374 } 375 376 /** 377 * True if the node sits in a region that must never receive a new 378 * annotation: our own annotation UI (panels, counter bar, tooltip, 379 * highlights), the table of contents (#dw__toc), the page-info line 380 * (.docInfo), or a section-edit button (.secedit). These all live inside 381 * #dokuwiki__content, so plain containment is not enough to gate selection. 382 * 383 * @param {Node} node 384 * @returns {bool} 385 */ 386 function isInExcludedRegion(node) { 387 var el = (node && node.nodeType === 1) ? node : (node ? node.parentNode : null); 388 while (el && el !== document.body) { 389 if (el.nodeType === 1) { 390 var cls = el.className; 391 if (typeof cls === 'string') { 392 if (cls.indexOf('ann-') !== -1 || // our own UI + highlights 393 cls.indexOf('docInfo') !== -1 || 394 cls.indexOf('secedit') !== -1) { 395 return true; 396 } 397 } 398 if (el.id === 'dw__toc') return true; 399 } 400 el = el.parentNode; 401 } 402 return false; 403 } 404 405 /** 406 * True if the element (or its ancestor) is part of our annotation UI. 407 * 408 * @param {Node} el 409 * @returns {bool} 410 */ 411 function isAnnotationUI(el) { 412 while (el && el !== document.body) { 413 if (el.nodeType === 1) { 414 var cls = el.className || ''; 415 if ( 416 cls.indexOf('ann-') !== -1 || 417 cls.indexOf(CLS_PANEL) !== -1 418 ) { 419 return true; 420 } 421 } 422 el = el.parentNode; 423 } 424 return false; 425 } 426 427 /** 428 * Turn a (start, length) offset in the normalised page text back into a 429 * DOM Range, using the normalised→raw index map. 430 * 431 * @param {Array<{node:Text, start:number, text:string}>} chunks 432 * @param {Array<number>} map normalised index → raw index (normalizeWithMap) 433 * @param {number} startOff start offset in the normalised text 434 * @param {number} length length in normalised characters 435 * @returns {Range|null} 436 */ 437 function buildRange(chunks, map, startOff, length) { 438 var rawStart = map[startOff]; 439 var rawEnd = map[startOff + length - 1]; 440 if (rawStart === undefined || rawEnd === undefined) return null; 441 rawEnd++; // exclusive 442 443 // Find which chunks contain rawStart and rawEnd. 444 var startChunk = null, startOffset = 0; 445 var endChunk = null, endOffset = 0; 446 447 for (var i = 0; i < chunks.length; i++) { 448 var c = chunks[i]; 449 var cEnd = c.start + c.text.length; 450 451 if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 452 startChunk = c.node; 453 startOffset = rawStart - c.start; 454 } 455 if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 456 endChunk = c.node; 457 endOffset = rawEnd - c.start; 458 } 459 if (startChunk && endChunk) break; 460 } 461 462 if (!startChunk || !endChunk) return null; 463 464 try { 465 var range = document.createRange(); 466 range.setStart(startChunk, startOffset); 467 range.setEnd(endChunk, endOffset); 468 return range; 469 } catch (e) { 470 return null; 471 } 472 } 473 474 /** 475 * Normalise raw text exactly as normalizeWS does (collapse each whitespace 476 * run to a single space, trim both ends) while recording, for every 477 * character of the normalised string, the index of the raw character it 478 * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i) 479 * (a collapsed internal space maps to the first char of its run). 480 * 481 * Normalisation and the index map MUST stay in lockstep: an earlier 482 * version built the map without trimming, so a leading whitespace text 483 * node (DokuWiki indents its content markup, so there always is one) 484 * shifted every highlight one character to the left. 485 * 486 * @param {string} raw 487 * @returns {{norm:string, map:Array<number>}} 488 */ 489 function normalizeWithMap(raw) { 490 var norm = ''; 491 var map = []; 492 var inRun = false; 493 var runStart = 0; 494 for (var i = 0; i < raw.length; i++) { 495 if (/\s/.test(raw[i])) { 496 if (!inRun) { inRun = true; runStart = i; } 497 continue; 498 } 499 if (inRun) { 500 inRun = false; 501 // internal run → one representative space; leading run → dropped 502 if (norm.length > 0) { 503 norm += ' '; 504 map.push(runStart); 505 } 506 } 507 norm += raw[i]; 508 map.push(i); 509 } 510 // a trailing whitespace run is dropped (matches trim) 511 return {norm: norm, map: map}; 512 } 513 514 // ----------------------------------------------------------------------- 515 // Highlights 516 // ----------------------------------------------------------------------- 517 518 /** 519 * Wrap a Range in a highlight <span> for the given annotation. 520 * 521 * @param {Range} range 522 * @param {object} ann 523 */ 524 function wrapHighlight(range, ann) { 525 var preview = ann.body || ''; 526 var span = document.createElement('span'); 527 span.className = ann.status === 'resolved' 528 ? CLS_HIGHLIGHT_RESOLVED 529 : CLS_HIGHLIGHT_OPEN; 530 span.dataset.annId = ann.id; 531 span.title = preview.slice(0, 80) + (preview.length > 80 ? '…' : ''); 532 span.addEventListener('click', function (e) { 533 e.stopPropagation(); 534 openPanel(ann.id); 535 }); 536 537 try { 538 range.surroundContents(span); 539 ann._highlightEl = span; 540 } catch (e) { 541 // surroundContents throws if the range crosses element boundaries; 542 // fall back to extract + insert, reusing the same (still-empty) span. 543 try { 544 span.appendChild(range.extractContents()); 545 range.insertNode(span); 546 ann._highlightEl = span; 547 } catch (e2) { 548 ann._highlightEl = null; 549 } 550 } 551 } 552 553 /** 554 * Remove all highlight spans, restoring the original text nodes. 555 */ 556 function clearHighlights() { 557 var spans = document.querySelectorAll( 558 '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 559 ); 560 Array.prototype.forEach.call(spans, function (span) { 561 var parent = span.parentNode; 562 if (!parent) return; 563 while (span.firstChild) { 564 parent.insertBefore(span.firstChild, span); 565 } 566 parent.removeChild(span); 567 parent.normalize(); 568 }); 569 } 570 571 // ----------------------------------------------------------------------- 572 // Gutter markers 573 // ----------------------------------------------------------------------- 574 575 /** 576 * Render a small marker for every anchored annotation. Markers are 577 * appended to document.body as absolutely-positioned elements so that 578 * template overflow rules on inner containers cannot clip them. 579 * 580 * All markers share the same X position — just to the left of the .page 581 * content column — so they form a tidy vertical column in the margin. 582 */ 583 function renderGutterMarkers() { 584 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 585 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 586 var markerLeft = gutterMarkerLeft(scrollLeft); 587 588 // Speech bubble SVG — clearly communicates "annotation here". 589 var ICON_SVG = 590 '<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" aria-hidden="true">' + 591 '<rect x="1" y="1" width="14" height="10" rx="2"/>' + 592 '<path d="M4 14 L4 11 L8 11 Z"/>' + 593 '</svg>'; 594 595 _annotations.forEach(function (ann) { 596 if (!ann._highlightEl) return; // orphan 597 598 var rect = ann._highlightEl.getBoundingClientRect(); 599 600 var marker = document.createElement('button'); 601 marker.className = CLS_GUTTER_MARKER; 602 marker.dataset.annId = ann.id; 603 marker.dataset.status = ann.status || 'open'; // drives CSS amber/green colour 604 marker.setAttribute('aria-label', t('label_annotation', 'Annotation')); 605 marker.type = 'button'; 606 marker.innerHTML = ICON_SVG; 607 // Align vertically with the first line of the highlight. 608 marker.style.top = (rect.top + scrollTop + 3) + 'px'; 609 marker.style.left = markerLeft + 'px'; 610 marker.addEventListener('click', function (e) { 611 e.stopPropagation(); 612 openPanel(ann.id); 613 }); 614 document.body.appendChild(marker); 615 ann._markerEl = marker; 616 }); 617 } 618 619 /** 620 * Remove all gutter markers. 621 */ 622 function clearGutterMarkers() { 623 var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 624 Array.prototype.forEach.call(markers, function (m) { 625 if (m.parentNode) m.parentNode.removeChild(m); 626 }); 627 } 628 629 /** 630 * The shared X position (document coordinates) for every gutter marker: 631 * just inside the left padding of the .page content column, so the markers 632 * form a tidy vertical strip in the margin. Falls back to 4px when the 633 * column cannot be measured. Reads the theme's computed padding so it 634 * adapts to the template. 635 * 636 * @param {number} scrollLeft current horizontal scroll offset 637 * @returns {number} 638 */ 639 function gutterMarkerLeft(scrollLeft) { 640 var pageEl = document.querySelector('.' + PAGE_CLS) || document.getElementById(CONTENT_ID); 641 if (!pageEl) return 4; 642 var pageRect = pageEl.getBoundingClientRect(); 643 var padLeft = parseInt(window.getComputedStyle(pageEl).paddingLeft, 10) || 32; 644 return pageRect.left + scrollLeft + Math.max(2, Math.floor(padLeft * 0.25)); 645 } 646 647 /** 648 * Re-align every existing marker with its highlight without rebuilding the 649 * DOM. Highlights shift when a panel is inserted/removed or the window is 650 * resized, but markers live in document.body at absolute coordinates, so 651 * they would otherwise drift out of line. Cheap — only touches inline 652 * top/left on the handful of markers present. 653 */ 654 function repositionMarkers() { 655 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 656 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 657 var markerLeft = gutterMarkerLeft(scrollLeft); 658 _annotations.forEach(function (ann) { 659 if (!ann._markerEl || !ann._highlightEl) return; 660 var rect = ann._highlightEl.getBoundingClientRect(); 661 ann._markerEl.style.top = (rect.top + scrollTop + 3) + 'px'; 662 ann._markerEl.style.left = markerLeft + 'px'; 663 }); 664 } 665 666 // ----------------------------------------------------------------------- 667 // Page counter 668 // ----------------------------------------------------------------------- 669 670 /** 671 * Render (or update) the counter bubble above the content area. 672 * 673 * @param {object} stats {total, open, resolved} 674 * @param {number} orphanCount 675 */ 676 function renderCounter(stats, orphanCount) { 677 var existing = document.getElementById('ann-counter-bar'); 678 if (existing) existing.parentNode.removeChild(existing); 679 680 if (stats.total === 0 && orphanCount === 0) return; 681 682 var bar = document.createElement('div'); 683 bar.id = 'ann-counter-bar'; 684 bar.className = CLS_COUNTER; 685 686 var total = stats.total || 0; 687 var label = total === 1 688 ? t('counter_annotation', '1 annotation') 689 : fmt(t('counter_annotations', '%d annotations'), total); 690 bar.appendChild(document.createTextNode(label)); 691 692 if (orphanCount > 0) { 693 bar.appendChild(document.createTextNode(' · ')); 694 var orphanLink = document.createElement('a'); 695 orphanLink.href = '#ann-orphan-drawer'; 696 orphanLink.className = 'ann-orphan-link'; 697 orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount); 698 orphanLink.addEventListener('click', function (e) { 699 e.preventDefault(); 700 toggleOrphanDrawer(); 701 repositionMarkers(); 702 }); 703 bar.appendChild(orphanLink); 704 } 705 706 if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 707 if (stats.resolved > 0) { 708 var btnCR = document.createElement('button'); 709 btnCR.type = 'button'; 710 btnCR.className = 'ann-btn ann-btn-admin'; 711 btnCR.textContent = t('btn_clear_resolved', 'Clear resolved'); 712 btnCR.addEventListener('click', function () { doClearResolved(btnCR); }); 713 bar.appendChild(btnCR); 714 } 715 if (orphanCount > 0) { 716 var btnCO = document.createElement('button'); 717 btnCO.type = 'button'; 718 btnCO.className = 'ann-btn ann-btn-admin'; 719 btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned'); 720 btnCO.addEventListener('click', function () { doClearOrphaned(btnCO); }); 721 bar.appendChild(btnCO); 722 } 723 } 724 725 // Insert inside .page, right after #dw__toc if present. 726 // The TOC is float:right so placing the bar after it (not before) lets 727 // it sit to the left of the float instead of pushing the TOC down. 728 var pageEl = document.querySelector('.' + PAGE_CLS); 729 if (pageEl) { 730 var toc = pageEl.querySelector('#dw__toc'); 731 if (toc && toc.nextSibling) { 732 pageEl.insertBefore(bar, toc.nextSibling); 733 } else if (toc) { 734 pageEl.appendChild(bar); 735 } else { 736 pageEl.insertBefore(bar, pageEl.firstChild); 737 } 738 } else { 739 var content = document.getElementById(CONTENT_ID); 740 if (content) content.insertBefore(bar, content.firstChild); 741 } 742 } 743 744 /** 745 * Recount and re-render the counter from in-memory state. 746 */ 747 function updateCounter(orphanCount) { 748 var open = 0, resolved = 0; 749 if (orphanCount === undefined) { 750 orphanCount = 0; 751 } 752 _annotations.forEach(function (ann) { 753 if (ann._orphaned) { 754 orphanCount++; 755 } else if (ann.status === 'resolved') { 756 resolved++; 757 } else { 758 open++; 759 } 760 }); 761 renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 762 } 763 764 // ----------------------------------------------------------------------- 765 // Annotation panel 766 // ----------------------------------------------------------------------- 767 768 /** 769 * Open the thread panel for the given annotation id. 770 * If that panel is already open, close it. 771 * 772 * @param {string} annId 773 * @param {boolean} [focusReply] focus the reply box once open (default true); 774 * reopenPanel passes false so re-rendering after 775 * an action doesn't yank the viewport to the form. 776 */ 777 function openPanel(annId, focusReply) { 778 if (_openAnnId === annId) { 779 closePanel(); 780 return; 781 } 782 closePanel(); 783 784 var ann = _annotations.get(annId); 785 if (!ann) return; 786 787 var panel = buildPanel(ann); 788 _openPanel = panel; 789 _openAnnId = annId; 790 791 // Insert below the paragraph that contains the highlight. 792 var anchor = ann._highlightEl || null; 793 var insertAfter = findParagraph(anchor); 794 if (insertAfter && insertAfter.parentNode) { 795 insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 796 } else { 797 // Orphan or no paragraph found: show at the bottom of content. 798 var content = document.getElementById(CONTENT_ID); 799 if (content) content.appendChild(panel); 800 } 801 802 if (focusReply !== false) { 803 var input = panel.querySelector('.ann-body-input'); 804 if (input) input.focus(); 805 } 806 807 // The panel grew the document; nudge markers below it back into line. 808 repositionMarkers(); 809 } 810 811 /** 812 * Close and remove the currently open panel. 813 */ 814 function closePanel() { 815 if (_openPanel && _openPanel.parentNode) { 816 _openPanel.parentNode.removeChild(_openPanel); 817 } 818 _openPanel = null; 819 _openAnnId = null; 820 repositionMarkers(); 821 } 822 823 /** 824 * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 825 * that can receive a sibling element. 826 * 827 * @param {HTMLElement|null} el 828 * @returns {HTMLElement|null} 829 */ 830 function findParagraph(el) { 831 var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 832 var node = el; 833 while (node && node.id !== CONTENT_ID) { 834 if (node.nodeType === 1 && block.test(node.tagName)) { 835 return node; 836 } 837 node = node.parentNode; 838 } 839 return el; // fallback: use the element itself 840 } 841 842 /** 843 * Build and return the panel DOM element for one annotation. 844 * 845 * @param {object} ann 846 * @returns {HTMLElement} 847 */ 848 function buildPanel(ann) { 849 var panel = document.createElement('div'); 850 panel.className = CLS_PANEL; 851 panel.dataset.annId = ann.id; 852 panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css 853 854 // Main annotation thread entry (close button lives in its meta row). 855 var rootEntry = buildThreadEntry(ann, true); 856 var meta = rootEntry.querySelector('.ann-meta'); 857 if (meta) { 858 var closeBtn = document.createElement('button'); 859 closeBtn.type = 'button'; 860 closeBtn.className = 'ann-btn ann-close'; 861 closeBtn.setAttribute('aria-label', t('label_close', 'Close')); 862 closeBtn.textContent = '×'; // × 863 closeBtn.style.marginLeft = 'auto'; 864 closeBtn.addEventListener('click', closePanel); 865 meta.appendChild(closeBtn); 866 } 867 panel.appendChild(rootEntry); 868 869 // Replies: build hierarchy from flat list and render depth-indented. 870 appendReplyTree(panel, ann, buildReplyTree(ann.replies || []), 0); 871 872 // Reply form at the bottom for root-level replies. 873 if (_loggedIn) { 874 panel.appendChild(buildReplyForm(ann)); 875 } 876 877 return panel; 878 } 879 880 /** 881 * Build the DOM for the top-level annotation entry. 882 * 883 * @param {object} ann 884 * @param {boolean} isRoot true for the annotation itself, false for replies 885 * @returns {HTMLElement} 886 */ 887 function buildThreadEntry(ann, isRoot) { 888 var entry = document.createElement('div'); 889 entry.className = 'ann-thread-entry ann-annotation'; 890 entry.dataset.annId = ann.id; 891 892 // Meta row: avatar, author, time, status pill 893 entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 894 895 // Body 896 var bodyEl = document.createElement('div'); 897 bodyEl.className = 'ann-body'; 898 bodyEl.textContent = ann.body; 899 entry.appendChild(bodyEl); 900 901 // Quoted text snippet 902 if (ann.anchor && ann.anchor.exact) { 903 var quote = document.createElement('blockquote'); 904 quote.className = 'ann-quote'; 905 quote.textContent = ann.anchor.exact; 906 entry.appendChild(quote); 907 } 908 909 // Action buttons 910 var actions = document.createElement('div'); 911 actions.className = 'ann-actions'; 912 913 // An orphaned annotation is read-only: its quoted text is gone from the 914 // page, so resolving/reopening and editing the body no longer make sense. 915 // It keeps only the Delete button (for its author or an admin), so the 916 // only remaining action is to remove it. 917 var isOrphan = !!ann._orphaned; 918 919 // Resolve/Reopen (any reader) — not for orphans. 920 if (_loggedIn && !isOrphan) { 921 var resolveBtn = document.createElement('button'); 922 resolveBtn.type = 'button'; 923 resolveBtn.className = 'ann-btn ann-btn-primary'; 924 resolveBtn.textContent = ann.status === 'resolved' 925 ? t('btn_reopen', 'Reopen') 926 : t('btn_resolve', 'Resolve'); 927 resolveBtn.addEventListener('click', function () { 928 doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved', resolveBtn); 929 }); 930 actions.appendChild(resolveBtn); 931 } 932 933 // Edit (own or admin) — not for orphans; Delete stays available. 934 var canEdit = _isAdmin || ann.author === currentUser(); 935 if (canEdit && _loggedIn) { 936 if (!isOrphan) { 937 var editBtn = document.createElement('button'); 938 editBtn.type = 'button'; 939 editBtn.className = 'ann-btn'; 940 editBtn.textContent = t('btn_edit', 'Edit'); 941 editBtn.addEventListener('click', function () { 942 showEditForm(entry, ann, 'annotation'); 943 }); 944 actions.appendChild(editBtn); 945 } 946 947 var delBtn = document.createElement('button'); 948 delBtn.type = 'button'; 949 delBtn.className = 'ann-btn ann-btn-danger'; 950 delBtn.textContent = t('btn_delete', 'Delete'); 951 delBtn.addEventListener('click', function () { 952 if (confirm(t('confirm_delete', 'Delete this annotation?'))) { 953 doDeleteAnnotation(ann.id, delBtn); 954 } 955 }); 956 actions.appendChild(delBtn); 957 } 958 959 entry.appendChild(actions); 960 return entry; 961 } 962 963 /** 964 * Build the DOM for one reply entry, indented according to its nesting depth. 965 * 966 * @param {object} ann parent annotation 967 * @param {object} reply 968 * @param {number} depth 0 = direct reply to annotation; 1+ = nested 969 * @param {boolean} [readOnly] omit the action buttons (reply/edit/delete); 970 * used by the orphan drawer, where the thread is 971 * shown for reading only 972 * @returns {HTMLElement} 973 */ 974 function buildReplyEntry(ann, reply, depth, readOnly) { 975 var entry = document.createElement('div'); 976 entry.className = 'ann-thread-entry ann-reply'; 977 entry.dataset.replyId = reply.id; 978 // Indent nested replies up to 4 levels (1.5 em each). 979 var indent = Math.min(depth, 4) * 1.5 + 1.5; 980 if (indent > 0) { 981 entry.style.marginLeft = indent + 'em'; 982 } 983 984 entry.appendChild(buildMeta(reply.author, reply.created, null)); 985 986 var bodyEl = document.createElement('div'); 987 bodyEl.className = 'ann-body'; 988 bodyEl.textContent = reply.body; 989 entry.appendChild(bodyEl); 990 991 // The orphan drawer renders the thread read-only: no reply, edit, or 992 // delete buttons on replies, mirroring the read-only root entry there. 993 if (readOnly) { 994 return entry; 995 } 996 997 var actions = document.createElement('div'); 998 actions.className = 'ann-actions'; 999 1000 // "Reply to this reply" button for logged-in users. 1001 if (_loggedIn) { 1002 var replyToBtn = document.createElement('button'); 1003 replyToBtn.type = 'button'; 1004 replyToBtn.className = 'ann-btn ann-btn-primary'; 1005 replyToBtn.textContent = t('btn_reply', 'Reply'); 1006 replyToBtn.addEventListener('click', function () { 1007 // Toggle an inline reply form directly after this entry. 1008 var next = entry.nextSibling; 1009 if (next && next.classList && next.classList.contains('ann-inline-reply')) { 1010 next.parentNode.removeChild(next); 1011 return; 1012 } 1013 var form = buildInlineReplyForm(ann, reply.id, depth + 1); 1014 entry.parentNode.insertBefore(form, entry.nextSibling); 1015 var ta = form.querySelector('.ann-body-input'); 1016 if (ta) ta.focus(); 1017 }); 1018 actions.appendChild(replyToBtn); 1019 } 1020 1021 var canEdit = _isAdmin || reply.author === currentUser(); 1022 if (canEdit && _loggedIn) { 1023 var editBtn = document.createElement('button'); 1024 editBtn.type = 'button'; 1025 editBtn.className = 'ann-btn'; 1026 editBtn.textContent = t('btn_edit', 'Edit'); 1027 editBtn.addEventListener('click', function () { 1028 showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 1029 }); 1030 actions.appendChild(editBtn); 1031 1032 var delBtn = document.createElement('button'); 1033 delBtn.type = 'button'; 1034 delBtn.className = 'ann-btn ann-btn-danger'; 1035 delBtn.textContent = t('btn_delete', 'Delete'); 1036 delBtn.addEventListener('click', function () { 1037 if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) { 1038 doDeleteReply(ann.id, reply.id, delBtn); 1039 } 1040 }); 1041 actions.appendChild(delBtn); 1042 } 1043 1044 entry.appendChild(actions); 1045 return entry; 1046 } 1047 1048 /** 1049 * Build a nested tree structure from a flat reply list. Replies without a 1050 * known parentId (including legacy replies with no parentId field) are 1051 * treated as root-level. 1052 * 1053 * @param {Array} replies flat array of reply objects 1054 * @returns {Array} array of {reply, children} nodes 1055 */ 1056 function buildReplyTree(replies) { 1057 var map = {}; 1058 var roots = []; 1059 replies.forEach(function (r) { 1060 map[r.id] = {reply: r, children: []}; 1061 }); 1062 replies.forEach(function (r) { 1063 var pid = r.parentId || ''; 1064 if (pid && map[pid]) { 1065 map[pid].children.push(map[r.id]); 1066 } else { 1067 roots.push(map[r.id]); 1068 } 1069 }); 1070 return roots; 1071 } 1072 1073 /** 1074 * Recursively append reply entries into the panel. 1075 * 1076 * @param {HTMLElement} panel 1077 * @param {object} ann 1078 * @param {Array} nodes array of {reply, children} tree nodes 1079 * @param {number} depth 1080 * @param {boolean} [readOnly] render entries without action buttons 1081 */ 1082 function appendReplyTree(panel, ann, nodes, depth, readOnly) { 1083 nodes.forEach(function (node) { 1084 panel.appendChild(buildReplyEntry(ann, node.reply, depth, readOnly)); 1085 if (node.children.length > 0) { 1086 appendReplyTree(panel, ann, node.children, depth + 1, readOnly); 1087 } 1088 }); 1089 } 1090 1091 /** 1092 * Build an inline reply form that appears directly below a reply entry. 1093 * 1094 * @param {object} ann parent annotation 1095 * @param {string} parentReplyId id of the reply being replied to 1096 * @param {number} depth visual nesting depth for the new reply 1097 * @returns {HTMLElement} 1098 */ 1099 function buildInlineReplyForm(ann, parentReplyId, depth) { 1100 var form = document.createElement('div'); 1101 form.className = 'ann-thread-entry ann-reply ann-inline-reply'; 1102 var indent = Math.min(depth, 4) * 1.5 + 1.5; 1103 if (indent > 0) { 1104 form.style.marginLeft = indent + 'em'; 1105 } 1106 1107 var ta = document.createElement('textarea'); 1108 ta.className = 'ann-body-input'; 1109 ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1110 ta.rows = 3; 1111 form.appendChild(ta); 1112 1113 var row = document.createElement('div'); 1114 row.className = 'ann-form-row'; 1115 1116 var submitBtn = document.createElement('button'); 1117 submitBtn.type = 'button'; 1118 submitBtn.className = 'ann-btn ann-btn-primary'; 1119 submitBtn.textContent = t('btn_reply', 'Reply'); 1120 submitBtn.addEventListener('click', function () { 1121 var body = ta.value.trim(); 1122 if (!body) return; 1123 doAddReply(ann.id, body, function () { 1124 if (form.parentNode) form.parentNode.removeChild(form); 1125 }, submitBtn, parentReplyId); 1126 }); 1127 1128 var cancelBtn = document.createElement('button'); 1129 cancelBtn.type = 'button'; 1130 cancelBtn.className = 'ann-btn'; 1131 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1132 cancelBtn.addEventListener('click', function () { 1133 if (form.parentNode) form.parentNode.removeChild(form); 1134 }); 1135 1136 row.appendChild(submitBtn); 1137 row.appendChild(cancelBtn); 1138 form.appendChild(row); 1139 return form; 1140 } 1141 1142 /** 1143 * Build the meta row (avatar initials, author name, timestamp, status pill). 1144 * 1145 * @param {string} author 1146 * @param {number} timestamp Unix seconds 1147 * @param {string|null} status 'open'|'resolved'|null 1148 * @returns {HTMLElement} 1149 */ 1150 function buildMeta(author, timestamp, status) { 1151 var meta = document.createElement('div'); 1152 meta.className = 'ann-meta'; 1153 1154 var avatar = document.createElement('span'); 1155 avatar.className = 'ann-avatar'; 1156 avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 1157 meta.appendChild(avatar); 1158 1159 var authorEl = document.createElement('span'); 1160 authorEl.className = 'ann-author'; 1161 authorEl.textContent = author || t('label_unknown', 'Unknown'); 1162 meta.appendChild(authorEl); 1163 1164 var timeEl = document.createElement('time'); 1165 timeEl.className = 'ann-time'; 1166 var d = new Date(timestamp * 1000); 1167 timeEl.dateTime = d.toISOString(); 1168 timeEl.textContent = formatDate(d); 1169 meta.appendChild(timeEl); 1170 1171 if (status) { 1172 var pill = document.createElement('span'); 1173 pill.className = 'ann-status ann-status-' + status; 1174 pill.textContent = status === 'resolved' 1175 ? t('status_resolved', 'Resolved') 1176 : t('status_open', 'Open'); 1177 meta.appendChild(pill); 1178 } 1179 1180 return meta; 1181 } 1182 1183 /** 1184 * Build a reply form at the bottom of the panel. 1185 * 1186 * @param {object} ann 1187 * @returns {HTMLElement} 1188 */ 1189 function buildReplyForm(ann) { 1190 var form = document.createElement('div'); 1191 form.className = 'ann-reply-form'; 1192 1193 var ta = document.createElement('textarea'); 1194 ta.className = 'ann-body-input'; 1195 ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1196 ta.rows = 3; 1197 form.appendChild(ta); 1198 1199 var row = document.createElement('div'); 1200 row.className = 'ann-form-row'; 1201 1202 var submitBtn = document.createElement('button'); 1203 submitBtn.type = 'button'; 1204 submitBtn.className = 'ann-btn ann-btn-primary'; 1205 submitBtn.textContent = t('btn_reply', 'Reply'); 1206 submitBtn.addEventListener('click', function () { 1207 var body = ta.value.trim(); 1208 if (!body) return; 1209 doAddReply(ann.id, body, function () { 1210 ta.value = ''; 1211 }, submitBtn); 1212 }); 1213 row.appendChild(submitBtn); 1214 form.appendChild(row); 1215 1216 return form; 1217 } 1218 1219 /** 1220 * Replace the body of an entry with an inline edit form. 1221 * 1222 * @param {HTMLElement} entry 1223 * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 1224 * @param {string} type 'annotation' | 'reply' 1225 */ 1226 function showEditForm(entry, data, type) { 1227 var bodyEl = entry.querySelector('.ann-body'); 1228 if (!bodyEl) return; 1229 1230 var ta = document.createElement('textarea'); 1231 ta.className = 'ann-body-input'; 1232 ta.value = data.body || ''; 1233 ta.rows = 3; 1234 1235 var row = document.createElement('div'); 1236 row.className = 'ann-form-row'; 1237 1238 var saveBtn = document.createElement('button'); 1239 saveBtn.type = 'button'; 1240 saveBtn.className = 'ann-btn ann-btn-primary'; 1241 saveBtn.textContent = t('btn_save', 'Save'); 1242 saveBtn.addEventListener('click', function () { 1243 var newBody = ta.value.trim(); 1244 if (!newBody) return; 1245 if (type === 'annotation') { 1246 doEditAnnotation(data.id || _openAnnId, newBody, saveBtn); 1247 } else { 1248 doEditReply(data.annId, data.replyId, newBody, saveBtn); 1249 } 1250 }); 1251 1252 var cancelBtn = document.createElement('button'); 1253 cancelBtn.type = 'button'; 1254 cancelBtn.className = 'ann-btn'; 1255 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1256 cancelBtn.addEventListener('click', function () { 1257 entry.removeChild(ta); 1258 entry.removeChild(row); 1259 bodyEl.style.display = ''; 1260 }); 1261 1262 row.appendChild(saveBtn); 1263 row.appendChild(cancelBtn); 1264 1265 bodyEl.style.display = 'none'; 1266 entry.insertBefore(ta, bodyEl.nextSibling); 1267 entry.insertBefore(row, ta.nextSibling); 1268 ta.focus(); 1269 } 1270 1271 // ----------------------------------------------------------------------- 1272 // Orphan drawer 1273 // ----------------------------------------------------------------------- 1274 1275 /** 1276 * Toggle the orphan drawer visibility. 1277 */ 1278 function toggleOrphanDrawer() { 1279 var drawer = document.getElementById('ann-orphan-drawer'); 1280 if (drawer) { 1281 drawer.parentNode.removeChild(drawer); 1282 return; 1283 } 1284 renderOrphanDrawer(); 1285 } 1286 1287 /** 1288 * Keep the orphan drawer in step with the current orphan set after a 1289 * mutation (delete / clear). No-op when the drawer is closed. When it is 1290 * open, rebuild it from the live _orphaned flags so deleted entries 1291 * disappear; if no orphans remain, remove the drawer entirely instead of 1292 * leaving an empty shell behind. 1293 * 1294 * Must run after renderAll(), which recomputes every ann._orphaned flag. 1295 */ 1296 function syncOrphanDrawer() { 1297 var drawer = document.getElementById('ann-orphan-drawer'); 1298 if (!drawer) return; // drawer not open — nothing to do 1299 1300 var hasOrphans = false; 1301 _annotations.forEach(function (ann) { 1302 if (ann._orphaned) hasOrphans = true; 1303 }); 1304 1305 if (drawer.parentNode) drawer.parentNode.removeChild(drawer); 1306 if (hasOrphans) { 1307 renderOrphanDrawer(); 1308 repositionMarkers(); 1309 } 1310 } 1311 1312 /** 1313 * Build and insert the orphan drawer at the bottom of the content area. 1314 */ 1315 function renderOrphanDrawer() { 1316 var content = document.getElementById(CONTENT_ID); 1317 if (!content) return; 1318 1319 var drawer = document.createElement('div'); 1320 drawer.id = 'ann-orphan-drawer'; 1321 drawer.className = CLS_ORPHAN_DRAWER; 1322 1323 var heading = document.createElement('h4'); 1324 heading.textContent = t('orphaned_heading', 'Orphaned annotations'); 1325 drawer.appendChild(heading); 1326 1327 var note = document.createElement('p'); 1328 note.className = 'ann-orphan-note'; 1329 note.textContent = t('orphaned_note', 1330 'These annotations reference text that no longer appears on the page.'); 1331 drawer.appendChild(note); 1332 1333 var found = false; 1334 _annotations.forEach(function (ann) { 1335 if (!ann._orphaned) return; 1336 found = true; 1337 // Show the whole thread, not just the root message: wrap the root 1338 // entry and its full (read-only) reply tree in one container so 1339 // multiple orphaned threads stay visually separated. 1340 var thread = document.createElement('div'); 1341 thread.className = 'ann-orphan-thread'; 1342 thread.dataset.status = ann.status || 'open'; 1343 thread.appendChild(buildThreadEntry(ann, true)); 1344 appendReplyTree(thread, ann, buildReplyTree(ann.replies || []), 0, true); 1345 drawer.appendChild(thread); 1346 }); 1347 1348 if (!found) { 1349 var empty = document.createElement('p'); 1350 empty.textContent = t('orphaned_none', 'None.'); 1351 drawer.appendChild(empty); 1352 } 1353 1354 // Insert right below the counter bar, which lives inside .page. 1355 // All fallbacks also target .page so the drawer never stretches past 1356 // the content column. 1357 var bar = document.getElementById('ann-counter-bar'); 1358 if (bar && bar.parentNode) { 1359 bar.parentNode.insertBefore(drawer, bar.nextSibling); 1360 } else { 1361 var pageEl2 = document.querySelector('.' + PAGE_CLS); 1362 if (pageEl2) { 1363 pageEl2.insertBefore(drawer, pageEl2.firstChild); 1364 } else { 1365 content.insertBefore(drawer, content.firstChild); 1366 } 1367 } 1368 } 1369 1370 // ----------------------------------------------------------------------- 1371 // Selection capture 1372 // ----------------------------------------------------------------------- 1373 1374 /** 1375 * Wire up mouseup/touchend listeners to detect text selection. 1376 * 1377 * @param {HTMLElement} content 1378 */ 1379 function initSelectionCapture(content) { 1380 if (!_loggedIn) return; // anonymous users cannot annotate 1381 1382 document.addEventListener('mouseup', function (e) { 1383 handleSelectionEnd(e, content); 1384 }); 1385 document.addEventListener('touchend', function (e) { 1386 // Small delay so the browser has committed the selection. 1387 setTimeout(function () { handleSelectionEnd(e, content); }, 50); 1388 }); 1389 1390 // Close tooltip on click outside (but not when clicking the new-form). 1391 document.addEventListener('mousedown', function (e) { 1392 var tooltip = document.getElementById('ann-tooltip'); 1393 if (tooltip && !tooltip.contains(e.target)) { 1394 var naf = document.getElementById('ann-new-form'); 1395 if (!naf || !naf.contains(e.target)) { 1396 hideTooltip(); 1397 } 1398 } 1399 }); 1400 } 1401 1402 /** 1403 * Handle end of selection: show the "Annotate" tooltip if there is a 1404 * non-empty selection inside the content area. 1405 * 1406 * @param {Event} e 1407 * @param {HTMLElement} content 1408 */ 1409 function handleSelectionEnd(e, content) { 1410 var sel = window.getSelection(); 1411 if (!sel || sel.isCollapsed) { 1412 // Don't hide the tooltip if the mouseup came from inside it — 1413 // the click handler is responsible for cleanup in that case. 1414 var tip = document.getElementById('ann-tooltip'); 1415 if (tip && tip.contains(e.target)) { 1416 return; 1417 } 1418 // Don't hide if a new-annotation form is open (user clicked 1419 // inside the form, collapsing the original selection). 1420 var naf = document.getElementById('ann-new-form'); 1421 if (naf && naf.contains(e.target)) { 1422 return; 1423 } 1424 hideTooltip(); 1425 return; 1426 } 1427 var range = sel.getRangeAt(0); 1428 if (!content.contains(range.commonAncestorContainer)) { 1429 hideTooltip(); 1430 return; 1431 } 1432 // If the selection touches any existing annotation — even by a single 1433 // character, whether wholly inside it or overrunning it on either side — 1434 // open that annotation instead of offering to create a new one. 1435 var hitSpan = selectionHitsHighlight(range); 1436 if (hitSpan) { 1437 hideTooltip(); 1438 openPanel(hitSpan.dataset.annId); 1439 return; 1440 } 1441 // Only real page prose can be annotated: skip our own UI (panels, 1442 // counter, tooltip), the TOC, the page-info line, and section-edit 1443 // buttons — all of which live inside #dokuwiki__content. 1444 if (isInExcludedRegion(range.startContainer) || 1445 isInExcludedRegion(range.endContainer) || 1446 isInExcludedRegion(range.commonAncestorContainer)) { 1447 hideTooltip(); 1448 return; 1449 } 1450 var text = sel.toString().trim(); 1451 if (text.length < 1) { 1452 hideTooltip(); 1453 return; 1454 } 1455 1456 // If the tooltip is already showing (e.g. user moused up after 1457 // pressing the Annotate button), don't replace it with a fresh one — 1458 // that would orphan the button mid-click and break the click handler. 1459 if (document.getElementById('ann-tooltip')) { 1460 return; 1461 } 1462 1463 // Show the tooltip near the end of the selection. 1464 var rect = range.getBoundingClientRect(); 1465 showTooltip(rect, range, sel, content); 1466 } 1467 1468 /** 1469 * Show the "Annotate" tooltip bubble. 1470 * 1471 * @param {DOMRect} rect bounding rect of the selection 1472 * @param {Range} range 1473 * @param {Selection} sel 1474 * @param {HTMLElement} content 1475 */ 1476 function showTooltip(rect, range, sel, content) { 1477 hideTooltip(); 1478 1479 var tip = document.createElement('div'); 1480 tip.id = 'ann-tooltip'; 1481 tip.className = CLS_TOOLTIP; 1482 1483 // Capture the anchor on mousedown while the selection is guaranteed 1484 // to still exist. By the time 'click' fires, many browsers have 1485 // already collapsed the selection, so captureAnchor would return null. 1486 // _pendingAnchor is module-level so it survives tooltip replacement. 1487 var btn = document.createElement('button'); 1488 btn.type = 'button'; 1489 btn.textContent = t('btn_annotate', 'Annotate'); 1490 btn.className = 'ann-btn ann-btn-primary'; 1491 btn.addEventListener('mousedown', function (e) { 1492 e.preventDefault(); // prevent focus-change deselection 1493 // Capture now, while the selection is still intact. 1494 _pendingAnchor = captureAnchor(sel, range, content); 1495 }); 1496 btn.addEventListener('click', function () { 1497 var anchor = _pendingAnchor; 1498 _pendingAnchor = null; 1499 hideTooltip(); 1500 if (anchor) { 1501 openNewAnnotationForm(anchor, range); 1502 } 1503 }); 1504 tip.appendChild(btn); 1505 1506 document.body.appendChild(tip); 1507 1508 // Position below the selection's end. 1509 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 1510 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 1511 tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 1512 tip.style.left = (rect.left + scrollLeft) + 'px'; 1513 } 1514 1515 /** 1516 * Remove the tooltip if it exists. 1517 */ 1518 function hideTooltip() { 1519 var tip = document.getElementById('ann-tooltip'); 1520 if (tip && tip.parentNode) { 1521 tip.parentNode.removeChild(tip); 1522 } 1523 // Note: ann-new-form is NOT removed here — it has its own Cancel 1524 // button and must survive the mouseup that fires after the click. 1525 } 1526 1527 /** 1528 * Capture an anchor object from the current Selection. 1529 * 1530 * @param {Selection} sel 1531 * @param {Range} range 1532 * @param {HTMLElement} content 1533 * @returns {object|null} {exact, prefix, suffix, start} 1534 */ 1535 function captureAnchor(sel, range, content) { 1536 var exact = normalizeWS(sel.toString()); 1537 if (!exact) return null; 1538 1539 // Get full page text for prefix/suffix and start computation. 1540 var chunks = collectTextChunks(content); 1541 var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1542 var nm = normalizeWithMap(fullRaw); 1543 var fullNorm = nm.norm; 1544 1545 // Find where this text node + offset lands in the raw full text. 1546 var rawStart = 0; 1547 for (var i = 0; i < chunks.length; i++) { 1548 var c = chunks[i]; 1549 if (c.node === range.startContainer) { 1550 rawStart = c.start + range.startOffset; 1551 break; 1552 } 1553 } 1554 1555 // Map that raw offset to an offset in the normalised text, using the 1556 // same map as re-anchoring so capture and find stay in agreement. 1557 var normStart = nm.norm.length; 1558 for (var j = 0; j < nm.map.length; j++) { 1559 if (nm.map[j] >= rawStart) { 1560 normStart = j; 1561 break; 1562 } 1563 } 1564 1565 // Context slice length comes from the plugin config (context_length), 1566 // injected into JSINFO.annotations; fall back to 30 when absent. The 1567 // PHP side caps the stored prefix/suffix to the same length. 1568 var CTX = (typeof _info.contextLen === 'number') ? _info.contextLen : 30; 1569 var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 1570 var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 1571 1572 return { 1573 exact: exact, 1574 prefix: prefix, 1575 suffix: suffix, 1576 start: normStart, 1577 }; 1578 } 1579 1580 /** 1581 * Open the new-annotation form below the paragraph containing the selection. 1582 * 1583 * @param {object} anchor {exact, prefix, suffix, start} 1584 * @param {Range} range 1585 */ 1586 function openNewAnnotationForm(anchor, range) { 1587 closePanel(); 1588 1589 var insertAfter = findParagraph(range.commonAncestorContainer); 1590 var form = document.createElement('div'); 1591 form.id = 'ann-new-form'; 1592 form.className = 'ann-new-form'; 1593 1594 var quote = document.createElement('blockquote'); 1595 quote.className = 'ann-quote'; 1596 quote.textContent = anchor.exact; 1597 form.appendChild(quote); 1598 1599 var ta = document.createElement('textarea'); 1600 ta.className = 'ann-body-input'; 1601 ta.placeholder = t('placeholder_body', 'Add a comment…'); 1602 ta.rows = 3; 1603 form.appendChild(ta); 1604 1605 var row = document.createElement('div'); 1606 row.className = 'ann-form-row'; 1607 1608 var submitBtn = document.createElement('button'); 1609 submitBtn.type = 'button'; 1610 submitBtn.className = 'ann-btn ann-btn-primary'; 1611 submitBtn.textContent = t('btn_annotate', 'Annotate'); 1612 submitBtn.addEventListener('click', function () { 1613 var body = ta.value.trim(); 1614 if (!body) return; 1615 doCreate(anchor, body, function () { 1616 if (form.parentNode) form.parentNode.removeChild(form); 1617 }, submitBtn); 1618 }); 1619 1620 var cancelBtn = document.createElement('button'); 1621 cancelBtn.type = 'button'; 1622 cancelBtn.className = 'ann-btn'; 1623 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1624 cancelBtn.addEventListener('click', function () { 1625 if (form.parentNode) form.parentNode.removeChild(form); 1626 }); 1627 1628 row.appendChild(submitBtn); 1629 row.appendChild(cancelBtn); 1630 form.appendChild(row); 1631 1632 if (insertAfter && insertAfter.parentNode) { 1633 insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 1634 } else { 1635 var content = document.getElementById(CONTENT_ID); 1636 if (content) content.appendChild(form); 1637 } 1638 1639 ta.focus(); 1640 } 1641 1642 // ----------------------------------------------------------------------- 1643 // AJAX actions 1644 // ----------------------------------------------------------------------- 1645 1646 /** 1647 * POST create action and update state on success. 1648 * 1649 * @param {object} anchor 1650 * @param {string} body 1651 * @param {Function} onSuccess 1652 * @param {HTMLElement} [btn] button to disable while the request is in flight 1653 */ 1654 function doCreate(anchor, body, onSuccess, btn) { 1655 setBusy(btn, true); 1656 ajax({ 1657 action: 'create', 1658 id: _info.pageId, 1659 anchor: anchor, 1660 body: body, 1661 }).then(function (data) { 1662 setBusy(btn, false); 1663 if (!data.success) { 1664 showError(t('error_save', 'Could not save — please try again.'), data); 1665 return; 1666 } 1667 var ann = data.annotation; 1668 _annotations.set(ann.id, ann); 1669 if (typeof onSuccess === 'function') onSuccess(ann); 1670 renderAll(); 1671 }).catch(function () { 1672 setBusy(btn, false); 1673 alert(t('error_save', 'Could not save — please try again.')); 1674 }); 1675 } 1676 1677 /** 1678 * Run a thread-level mutation (reply / edit annotation / edit reply / 1679 * delete reply): POST the payload, then on success store the returned 1680 * annotation — keeping the client-side render state via mergeClientProps — 1681 * and re-open its panel. The server returns the full updated annotation, so 1682 * no second GET is needed. These four actions share this exact shape; 1683 * create / delete-annotation / resolve differ (they re-render the whole 1684 * overlay) and stay separate below. 1685 * 1686 * @param {object} payload AJAX body; must carry annId 1687 * @param {HTMLElement} [btn] button to show the busy spinner on 1688 * @param {string} errKey lang key for the failure message 1689 * @param {string} errText English fallback for that message 1690 * @param {Function} [onOk] optional callback run before re-rendering 1691 */ 1692 function submitThreadAction(payload, btn, errKey, errText, onOk) { 1693 setBusy(btn, true); 1694 ajax(payload).then(function (data) { 1695 setBusy(btn, false); 1696 if (!data.success) { 1697 showError(t(errKey, errText), data); 1698 return; 1699 } 1700 _annotations.set(data.annotation.id, mergeClientProps(data.annotation)); 1701 if (typeof onOk === 'function') onOk(); 1702 reopenPanel(payload.annId); 1703 // If this annotation sits in the open orphan drawer, refresh it so 1704 // an edited body / changed reply count shows there too. (The anchor 1705 // is unchanged by these actions, so _orphaned flags stay valid.) 1706 syncOrphanDrawer(); 1707 }).catch(function () { 1708 setBusy(btn, false); 1709 alert(t(errKey, errText)); 1710 }); 1711 } 1712 1713 /** 1714 * POST reply action and refresh the open panel. 1715 * 1716 * @param {string} annId 1717 * @param {string} body 1718 * @param {Function} onSuccess 1719 * @param {HTMLElement} [btn] 1720 * @param {string} [parentReplyId] id of the reply being replied to, or '' 1721 */ 1722 function doAddReply(annId, body, onSuccess, btn, parentReplyId) { 1723 submitThreadAction({ 1724 action: 'reply', 1725 id: _info.pageId, 1726 annId: annId, 1727 body: body, 1728 parentId: parentReplyId || '', 1729 }, btn, 'error_save', 'Could not save — please try again.', onSuccess); 1730 } 1731 1732 /** 1733 * POST edit_annotation and re-render. 1734 * 1735 * @param {string} annId 1736 * @param {string} body 1737 * @param {HTMLElement} [btn] 1738 */ 1739 function doEditAnnotation(annId, body, btn) { 1740 submitThreadAction({ 1741 action: 'edit_annotation', 1742 id: _info.pageId, 1743 annId: annId, 1744 body: body, 1745 }, btn, 'error_save', 'Could not save — please try again.'); 1746 } 1747 1748 /** 1749 * POST edit_reply and re-render. 1750 * 1751 * @param {string} annId 1752 * @param {string} replyId 1753 * @param {string} body 1754 * @param {HTMLElement} [btn] 1755 */ 1756 function doEditReply(annId, replyId, body, btn) { 1757 submitThreadAction({ 1758 action: 'edit_reply', 1759 id: _info.pageId, 1760 annId: annId, 1761 replyId: replyId, 1762 body: body, 1763 }, btn, 'error_save', 'Could not save — please try again.'); 1764 } 1765 1766 /** 1767 * POST delete_annotation. 1768 * 1769 * @param {string} annId 1770 * @param {HTMLElement} [btn] 1771 */ 1772 function doDeleteAnnotation(annId, btn) { 1773 setBusy(btn, true); 1774 ajax({ 1775 action: 'delete_annotation', 1776 id: _info.pageId, 1777 annId: annId, 1778 }).then(function (data) { 1779 setBusy(btn, false); 1780 if (!data.success) { 1781 showError(t('error_delete', 'Could not delete — please try again.'), data); 1782 return; 1783 } 1784 _annotations.delete(annId); 1785 closePanel(); 1786 renderAll(); 1787 // If this was deleted from the open orphan drawer, refresh it — 1788 // and remove it entirely once the last orphan is gone. 1789 syncOrphanDrawer(); 1790 }).catch(function () { 1791 setBusy(btn, false); 1792 }); 1793 } 1794 1795 /** 1796 * POST delete_reply and re-render. 1797 * 1798 * @param {string} annId 1799 * @param {string} replyId 1800 * @param {HTMLElement} [btn] 1801 */ 1802 function doDeleteReply(annId, replyId, btn) { 1803 submitThreadAction({ 1804 action: 'delete_reply', 1805 id: _info.pageId, 1806 annId: annId, 1807 replyId: replyId, 1808 }, btn, 'error_delete', 'Could not delete — please try again.'); 1809 } 1810 1811 /** 1812 * POST resolve/reopen action. 1813 * 1814 * @param {string} annId 1815 * @param {string} status 'open' | 'resolved' 1816 * @param {HTMLElement} [btn] 1817 */ 1818 function doResolve(annId, status, btn) { 1819 setBusy(btn, true); 1820 ajax({ 1821 action: 'resolve', 1822 id: _info.pageId, 1823 annId: annId, 1824 status: status, 1825 }).then(function (data) { 1826 setBusy(btn, false); 1827 if (!data.success) { 1828 showError(t('error_status', 'Could not update the status — please try again.'), data); 1829 return; 1830 } 1831 _annotations.set(data.annotation.id, data.annotation); 1832 renderAll(); 1833 reopenPanel(annId); 1834 }).catch(function () { 1835 setBusy(btn, false); 1836 }); 1837 } 1838 1839 /** 1840 * POST clear_resolved (admin). 1841 * 1842 * @param {HTMLElement} [btn] button to show the busy spinner on 1843 */ 1844 function doClearResolved(btn) { 1845 if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return; 1846 setBusy(btn, true); 1847 ajax({ 1848 action: 'clear_resolved', 1849 id: _info.pageId, 1850 }).then(function (data) { 1851 setBusy(btn, false); 1852 if (!data.success) { 1853 showError(t('error_clear', 'Could not clear — please try again.'), data); 1854 return; 1855 } 1856 // Remove resolved from local state. 1857 _annotations.forEach(function (ann, id) { 1858 if (ann.status === 'resolved') _annotations.delete(id); 1859 }); 1860 closePanel(); 1861 renderAll(); 1862 // Deleting resolved orphans may empty the drawer — sync/remove it. 1863 syncOrphanDrawer(); 1864 }).catch(function () { 1865 setBusy(btn, false); 1866 alert(t('error_clear', 'Could not clear — please try again.')); 1867 }); 1868 } 1869 1870 /** 1871 * POST clear_orphaned (admin). 1872 * 1873 * @param {HTMLElement} [btn] button to show the busy spinner on 1874 */ 1875 function doClearOrphaned(btn) { 1876 if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return; 1877 setBusy(btn, true); 1878 ajax({ 1879 action: 'clear_orphaned', 1880 id: _info.pageId, 1881 }).then(function (data) { 1882 setBusy(btn, false); 1883 if (!data.success) { 1884 showError(t('error_clear', 'Could not clear — please try again.'), data); 1885 return; 1886 } 1887 _annotations.forEach(function (ann, id) { 1888 if (ann._orphaned) _annotations.delete(id); 1889 }); 1890 closePanel(); 1891 renderAll(); 1892 // All orphans are gone now — tear down the drawer if it is open. 1893 syncOrphanDrawer(); 1894 }).catch(function () { 1895 setBusy(btn, false); 1896 alert(t('error_clear', 'Could not clear — please try again.')); 1897 }); 1898 } 1899 1900 // ----------------------------------------------------------------------- 1901 // Panel management helpers 1902 // ----------------------------------------------------------------------- 1903 1904 /** 1905 * Close the current panel and re-open it (preserves scroll position and 1906 * re-renders the thread with fresh data). 1907 * 1908 * @param {string} annId 1909 */ 1910 function reopenPanel(annId) { 1911 // closePanel() first clears _openAnnId so openPanel() rebuilds instead 1912 // of treating the same id as a toggle. focusReply=false keeps the 1913 // viewport put after resolve / edit / delete actions. 1914 closePanel(); 1915 openPanel(annId, false); 1916 } 1917 1918 // ----------------------------------------------------------------------- 1919 // Utilities 1920 // ----------------------------------------------------------------------- 1921 1922 /** 1923 * Disable a button and show a spinner while an AJAX request is in flight; 1924 * restore label and width on completion. 1925 * 1926 * @param {HTMLElement|null|undefined} btn 1927 * @param {boolean} busy 1928 */ 1929 function setBusy(btn, busy) { 1930 if (!btn) return; 1931 if (busy) { 1932 btn.disabled = true; 1933 btn.dataset.prevText = btn.textContent; 1934 // Lock the width before clearing text so the button doesn't shrink. 1935 btn.style.minWidth = btn.offsetWidth + 'px'; 1936 btn.textContent = ' '; // non-breaking space keeps height 1937 btn.classList.add('ann-btn-busy'); 1938 } else { 1939 btn.disabled = false; 1940 btn.classList.remove('ann-btn-busy'); 1941 if (btn.dataset.prevText !== undefined) { 1942 btn.textContent = btn.dataset.prevText; 1943 delete btn.dataset.prevText; 1944 } 1945 btn.style.minWidth = ''; 1946 } 1947 } 1948 1949 /** 1950 * Copy client-only runtime properties (_highlightEl, _markerEl, 1951 * _orphaned, _range) from the currently stored annotation onto a 1952 * freshly-returned server object before storing it, so that panels 1953 * reopen at the correct position instead of falling back to the 1954 * bottom of the page. 1955 * 1956 * @param {object} fresh annotation object from the server 1957 * @returns {object} the same object, augmented 1958 */ 1959 function mergeClientProps(fresh) { 1960 var existing = _annotations.get(fresh.id); 1961 if (existing) { 1962 fresh._highlightEl = existing._highlightEl; 1963 fresh._markerEl = existing._markerEl; 1964 fresh._orphaned = existing._orphaned; 1965 fresh._range = existing._range; 1966 } 1967 return fresh; 1968 } 1969 1970 /** 1971 * The per-plugin JS language bundle, exposed by DokuWiki as 1972 * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 1973 * 1974 * @returns {object} 1975 */ 1976 function uiLang() { 1977 if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) { 1978 return LANG.plugins.annotations; 1979 } 1980 return {}; 1981 } 1982 1983 /** 1984 * Look up a UI string by key, falling back to the supplied English text if 1985 * the bundle is missing the key (e.g. a lang file not yet updated). 1986 * 1987 * @param {string} key 1988 * @param {string} fallback English default 1989 * @returns {string} 1990 */ 1991 function t(key, fallback) { 1992 var s = _lang[key]; 1993 return (s === undefined || s === null || s === '') ? fallback : s; 1994 } 1995 1996 /** 1997 * Substitute a single %d placeholder with a number. 1998 * 1999 * @param {string} str 2000 * @param {number} n 2001 * @returns {string} 2002 */ 2003 function fmt(str, n) { 2004 return String(str).replace('%d', n); 2005 } 2006 2007 /** 2008 * Show a localised error, appending the server's reason in parentheses 2009 * when one is present. 2010 * 2011 * @param {string} base localised message 2012 * @param {object} data AJAX response ({error?:string}) 2013 */ 2014 function showError(base, data) { 2015 var reason = (data && data.error) ? data.error : ''; 2016 alert(reason ? base + ' (' + reason + ')' : base); 2017 } 2018 2019 /** 2020 * Collapse consecutive whitespace to a single space and trim. 2021 * 2022 * @param {string} s 2023 * @returns {string} 2024 */ 2025 function normalizeWS(s) { 2026 return String(s || '').replace(/\s+/g, ' ').trim(); 2027 } 2028 2029 /** 2030 * Return the current DokuWiki username from JSINFO. 2031 * 2032 * @returns {string} 2033 */ 2034 function currentUser() { 2035 var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 2036 return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : ''; 2037 } 2038 2039 /** 2040 * Format a Date for display. 2041 * 2042 * @param {Date} d 2043 * @returns {string} 2044 */ 2045 function formatDate(d) { 2046 var now = new Date(); 2047 var diff = (now - d) / 1000; // seconds 2048 if (diff < 60) return t('time_now', 'just now'); 2049 if (diff < 3600) return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60)); 2050 if (diff < 86400) return fmt(t('time_hours', '%dh ago'), Math.floor(diff / 3600)); 2051 if (diff < 86400 * 7) return fmt(t('time_days', '%dd ago'), Math.floor(diff / 86400)); 2052 return d.toLocaleDateString(); 2053 } 2054 2055 // ----------------------------------------------------------------------- 2056 // Init 2057 // ----------------------------------------------------------------------- 2058 2059 if (document.readyState === 'loading') { 2060 document.addEventListener('DOMContentLoaded', boot); 2061 } else { 2062 boot(); 2063 } 2064 2065}()); 2066