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