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