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