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