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