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 // Close the open panel when the user presses Escape. 130 document.addEventListener('keydown', function (e) { 131 if ((e.key === 'Escape' || e.key === 'Esc') && _openPanel) { 132 closePanel(); 133 } 134 }); 135 136 // Keep gutter markers aligned with their highlights when the viewport 137 // width changes: both the .page column and the highlights reflow. 138 window.addEventListener('resize', repositionMarkers); 139 } 140 141 // ----------------------------------------------------------------------- 142 // AJAX helpers 143 // ----------------------------------------------------------------------- 144 145 /** 146 * POST a JSON payload to the AJAX endpoint. 147 * 148 * @param {object} payload 149 * @returns {Promise<object>} response data 150 */ 151 function ajax(payload) { 152 payload.sectok = _token; // DokuWiki security token field name for AJAX 153 return fetch(AJAX_URL, { 154 method: 'POST', 155 headers: {'Content-Type': 'application/json'}, 156 body: JSON.stringify(payload), 157 }).then(function (res) { 158 return res.json(); 159 }); 160 } 161 162 // ----------------------------------------------------------------------- 163 // Load and anchor annotations 164 // ----------------------------------------------------------------------- 165 166 /** 167 * Fetch all annotations for the current page and render them. 168 */ 169 function loadAnnotations() { 170 // We use a lightweight GET-style call: the action.php AJAX handler 171 // is POST-only, so we pass action=load in the payload. 172 fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), { 173 method: 'GET', 174 }).then(function (res) { 175 return res.json(); 176 }).then(function (data) { 177 if (!data || !Array.isArray(data.annotations)) { 178 return; 179 } 180 data.annotations.forEach(function (ann) { 181 _annotations.set(ann.id, ann); 182 }); 183 renderAll(); 184 }).catch(function () { 185 // Graceful degradation: page still works without annotations. 186 }); 187 } 188 189 /** 190 * Re-render everything: highlights, gutter markers, counter. 191 */ 192 function renderAll() { 193 clearHighlights(); 194 clearGutterMarkers(); 195 196 var content = document.getElementById(CONTENT_ID); 197 if (!content) return; 198 199 // Snapshot the page text ONCE, before any highlight is inserted. 200 // Re-collecting per annotation would exclude already-wrapped text 201 // (collectTextChunks skips our own UI), shifting every later anchor. 202 var chunks = collectTextChunks(content); 203 var rawFull = chunks.map(function (c) { return c.text; }).join(''); 204 var nm = normalizeWithMap(rawFull); 205 206 // Phase 1 — locate every annotation against the clean snapshot. 207 var hits = []; 208 _annotations.forEach(function (ann) { 209 ann._range = null; 210 ann._highlightEl = null; 211 var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null; 212 if (hit) { 213 hits.push({ann: ann, pos: hit.pos, len: hit.len}); 214 ann._orphaned = false; 215 } else { 216 ann._orphaned = true; 217 } 218 }); 219 220 // Phase 2 — wrap later matches first, so wrapping (which splits text 221 // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones. 222 hits.sort(function (a, b) { return b.pos - a.pos; }); 223 hits.forEach(function (h) { 224 var range = buildRange(chunks, nm.map, h.pos, h.len); 225 if (range) { 226 h.ann._range = range; // cache for panel positioning 227 wrapHighlight(range, h.ann); 228 } else { 229 h.ann._orphaned = true; 230 } 231 }); 232 233 renderGutterMarkers(); 234 updateCounter(); // recounts orphans from the _orphaned flags set above 235 } 236 237 // ----------------------------------------------------------------------- 238 // Text anchoring (re-anchoring) 239 // ----------------------------------------------------------------------- 240 241 /** 242 * Locate an anchor's quoted text within the normalised page text. 243 * 244 * Algorithm: 245 * 1. Search for the exact quote (normalised). 246 * 2. If found multiple times, use prefix/suffix to disambiguate. 247 * 3. If still ambiguous, use the start offset hint. 248 * 249 * Returns offsets into the normalised string; buildRange maps them back 250 * to a DOM Range via the normalised→raw index map. 251 * 252 * @param {string} norm normalised page text (from normalizeWithMap) 253 * @param {object} anchor {exact, prefix, suffix, start} 254 * @returns {{pos:number, len:number}|null} 255 */ 256 function locate(norm, anchor) { 257 if (!anchor || !anchor.exact) return null; 258 259 var exact = normalizeWS(anchor.exact); 260 if (exact === '') return null; 261 var prefix = normalizeWS(anchor.prefix || ''); 262 var suffix = normalizeWS(anchor.suffix || ''); 263 var hint = anchor.start || 0; 264 265 // Find all occurrences of exact. 266 var positions = []; 267 var from = 0; 268 var idx; 269 while ((idx = norm.indexOf(exact, from)) !== -1) { 270 positions.push(idx); 271 from = idx + exact.length; 272 } 273 274 if (positions.length === 0) return null; 275 276 var chosen = positions[0]; 277 278 if (positions.length > 1) { 279 // Disambiguate using prefix + suffix context, tie-break on the hint. 280 var bestScore = -1; 281 positions.forEach(function (pos) { 282 var pre = norm.slice(Math.max(0, pos - prefix.length), pos); 283 var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length); 284 var score = 0; 285 if (prefix && pre.indexOf(prefix) !== -1) score++; 286 if (suffix && suf.indexOf(suffix) !== -1) score++; 287 var distToHint = Math.abs(pos - hint); 288 if (score > bestScore || 289 (score === bestScore && distToHint < Math.abs(chosen - hint))) { 290 bestScore = score; 291 chosen = pos; 292 } 293 }); 294 } 295 296 return {pos: chosen, len: exact.length}; 297 } 298 299 /** 300 * Walk the text nodes under root and return an array of 301 * {node, start, text} objects where start is the cumulative character 302 * offset of this node's text in the joined string. 303 * 304 * The joined string is NOT normalised here — we normalise the full string 305 * once above instead. 306 * 307 * @param {HTMLElement} root 308 * @returns {Array<{node:Text, start:number, text:string}>} 309 */ 310 function collectTextChunks(root) { 311 var walker = document.createTreeWalker( 312 root, 313 NodeFilter.SHOW_TEXT, 314 null, 315 false 316 ); 317 var chunks = []; 318 var offset = 0; 319 var node; 320 while ((node = walker.nextNode())) { 321 // Skip nodes inside our own UI elements. 322 if (isAnnotationUI(node.parentNode)) continue; 323 var text = node.nodeValue || ''; 324 chunks.push({node: node, start: offset, text: text}); 325 offset += text.length; 326 } 327 return chunks; 328 } 329 330 /** 331 * True if the given node is inside an existing highlight span. 332 * Used to block opening a new-annotation tooltip on already-annotated text. 333 * 334 * @param {Node} node 335 * @returns {bool} 336 */ 337 function isInsideHighlight(node) { 338 var el = (node && node.nodeType === 1) ? node : (node ? node.parentNode : null); 339 while (el && el !== document.body) { 340 if (el.className && 341 (el.className.indexOf(CLS_HIGHLIGHT_OPEN) !== -1 || 342 el.className.indexOf(CLS_HIGHLIGHT_RESOLVED) !== -1)) { 343 return true; 344 } 345 el = el.parentNode; 346 } 347 return false; 348 } 349 350 /** 351 * True if the element (or its ancestor) is part of our annotation UI. 352 * 353 * @param {Node} el 354 * @returns {bool} 355 */ 356 function isAnnotationUI(el) { 357 while (el && el !== document.body) { 358 if (el.nodeType === 1) { 359 var cls = el.className || ''; 360 if ( 361 cls.indexOf('ann-') !== -1 || 362 cls.indexOf(CLS_PANEL) !== -1 363 ) { 364 return true; 365 } 366 } 367 el = el.parentNode; 368 } 369 return false; 370 } 371 372 /** 373 * Turn a (start, length) offset in the normalised page text back into a 374 * DOM Range, using the normalised→raw index map. 375 * 376 * @param {Array<{node:Text, start:number, text:string}>} chunks 377 * @param {Array<number>} map normalised index → raw index (normalizeWithMap) 378 * @param {number} startOff start offset in the normalised text 379 * @param {number} length length in normalised characters 380 * @returns {Range|null} 381 */ 382 function buildRange(chunks, map, startOff, length) { 383 var rawStart = map[startOff]; 384 var rawEnd = map[startOff + length - 1]; 385 if (rawStart === undefined || rawEnd === undefined) return null; 386 rawEnd++; // exclusive 387 388 // Find which chunks contain rawStart and rawEnd. 389 var startChunk = null, startOffset = 0; 390 var endChunk = null, endOffset = 0; 391 392 for (var i = 0; i < chunks.length; i++) { 393 var c = chunks[i]; 394 var cEnd = c.start + c.text.length; 395 396 if (startChunk === null && c.start <= rawStart && rawStart < cEnd) { 397 startChunk = c.node; 398 startOffset = rawStart - c.start; 399 } 400 if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) { 401 endChunk = c.node; 402 endOffset = rawEnd - c.start; 403 } 404 if (startChunk && endChunk) break; 405 } 406 407 if (!startChunk || !endChunk) return null; 408 409 try { 410 var range = document.createRange(); 411 range.setStart(startChunk, startOffset); 412 range.setEnd(endChunk, endOffset); 413 return range; 414 } catch (e) { 415 return null; 416 } 417 } 418 419 /** 420 * Normalise raw text exactly as normalizeWS does (collapse each whitespace 421 * run to a single space, trim both ends) while recording, for every 422 * character of the normalised string, the index of the raw character it 423 * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i) 424 * (a collapsed internal space maps to the first char of its run). 425 * 426 * Normalisation and the index map MUST stay in lockstep: an earlier 427 * version built the map without trimming, so a leading whitespace text 428 * node (DokuWiki indents its content markup, so there always is one) 429 * shifted every highlight one character to the left. 430 * 431 * @param {string} raw 432 * @returns {{norm:string, map:Array<number>}} 433 */ 434 function normalizeWithMap(raw) { 435 var norm = ''; 436 var map = []; 437 var inRun = false; 438 var runStart = 0; 439 for (var i = 0; i < raw.length; i++) { 440 if (/\s/.test(raw[i])) { 441 if (!inRun) { inRun = true; runStart = i; } 442 continue; 443 } 444 if (inRun) { 445 inRun = false; 446 // internal run → one representative space; leading run → dropped 447 if (norm.length > 0) { 448 norm += ' '; 449 map.push(runStart); 450 } 451 } 452 norm += raw[i]; 453 map.push(i); 454 } 455 // a trailing whitespace run is dropped (matches trim) 456 return {norm: norm, map: map}; 457 } 458 459 // ----------------------------------------------------------------------- 460 // Highlights 461 // ----------------------------------------------------------------------- 462 463 /** 464 * Wrap a Range in a highlight <span> for the given annotation. 465 * 466 * @param {Range} range 467 * @param {object} ann 468 */ 469 function wrapHighlight(range, ann) { 470 var preview = ann.body || ''; 471 var span = document.createElement('span'); 472 span.className = ann.status === 'resolved' 473 ? CLS_HIGHLIGHT_RESOLVED 474 : CLS_HIGHLIGHT_OPEN; 475 span.dataset.annId = ann.id; 476 span.title = preview.slice(0, 80) + (preview.length > 80 ? '…' : ''); 477 span.addEventListener('click', function (e) { 478 e.stopPropagation(); 479 openPanel(ann.id); 480 }); 481 482 try { 483 range.surroundContents(span); 484 ann._highlightEl = span; 485 } catch (e) { 486 // surroundContents throws if the range crosses element boundaries; 487 // fall back to extract + insert, reusing the same (still-empty) span. 488 try { 489 span.appendChild(range.extractContents()); 490 range.insertNode(span); 491 ann._highlightEl = span; 492 } catch (e2) { 493 ann._highlightEl = null; 494 } 495 } 496 } 497 498 /** 499 * Remove all highlight spans, restoring the original text nodes. 500 */ 501 function clearHighlights() { 502 var spans = document.querySelectorAll( 503 '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED 504 ); 505 Array.prototype.forEach.call(spans, function (span) { 506 var parent = span.parentNode; 507 if (!parent) return; 508 while (span.firstChild) { 509 parent.insertBefore(span.firstChild, span); 510 } 511 parent.removeChild(span); 512 parent.normalize(); 513 }); 514 } 515 516 // ----------------------------------------------------------------------- 517 // Gutter markers 518 // ----------------------------------------------------------------------- 519 520 /** 521 * Render a small marker for every anchored annotation. Markers are 522 * appended to document.body as absolutely-positioned elements so that 523 * template overflow rules on inner containers cannot clip them. 524 * 525 * All markers share the same X position — just to the left of the .page 526 * content column — so they form a tidy vertical column in the margin. 527 */ 528 function renderGutterMarkers() { 529 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 530 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 531 var markerLeft = gutterMarkerLeft(scrollLeft); 532 533 // Speech bubble SVG — clearly communicates "annotation here". 534 var ICON_SVG = 535 '<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" aria-hidden="true">' + 536 '<rect x="1" y="1" width="14" height="10" rx="2"/>' + 537 '<path d="M4 14 L4 11 L8 11 Z"/>' + 538 '</svg>'; 539 540 _annotations.forEach(function (ann) { 541 if (!ann._highlightEl) return; // orphan 542 543 var rect = ann._highlightEl.getBoundingClientRect(); 544 545 var marker = document.createElement('button'); 546 marker.className = CLS_GUTTER_MARKER; 547 marker.dataset.annId = ann.id; 548 marker.dataset.status = ann.status || 'open'; // drives CSS amber/green colour 549 marker.setAttribute('aria-label', t('label_annotation', 'Annotation')); 550 marker.type = 'button'; 551 marker.innerHTML = ICON_SVG; 552 // Align vertically with the first line of the highlight. 553 marker.style.top = (rect.top + scrollTop + 3) + 'px'; 554 marker.style.left = markerLeft + 'px'; 555 marker.addEventListener('click', function (e) { 556 e.stopPropagation(); 557 openPanel(ann.id); 558 }); 559 document.body.appendChild(marker); 560 ann._markerEl = marker; 561 }); 562 } 563 564 /** 565 * Remove all gutter markers. 566 */ 567 function clearGutterMarkers() { 568 var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER); 569 Array.prototype.forEach.call(markers, function (m) { 570 if (m.parentNode) m.parentNode.removeChild(m); 571 }); 572 } 573 574 /** 575 * The shared X position (document coordinates) for every gutter marker: 576 * just inside the left padding of the .page content column, so the markers 577 * form a tidy vertical strip in the margin. Falls back to 4px when the 578 * column cannot be measured. Reads the theme's computed padding so it 579 * adapts to the template. 580 * 581 * @param {number} scrollLeft current horizontal scroll offset 582 * @returns {number} 583 */ 584 function gutterMarkerLeft(scrollLeft) { 585 var pageEl = document.querySelector('.' + PAGE_CLS) || document.getElementById(CONTENT_ID); 586 if (!pageEl) return 4; 587 var pageRect = pageEl.getBoundingClientRect(); 588 var padLeft = parseInt(window.getComputedStyle(pageEl).paddingLeft, 10) || 32; 589 return pageRect.left + scrollLeft + Math.max(2, Math.floor(padLeft * 0.25)); 590 } 591 592 /** 593 * Re-align every existing marker with its highlight without rebuilding the 594 * DOM. Highlights shift when a panel is inserted/removed or the window is 595 * resized, but markers live in document.body at absolute coordinates, so 596 * they would otherwise drift out of line. Cheap — only touches inline 597 * top/left on the handful of markers present. 598 */ 599 function repositionMarkers() { 600 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 601 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 602 var markerLeft = gutterMarkerLeft(scrollLeft); 603 _annotations.forEach(function (ann) { 604 if (!ann._markerEl || !ann._highlightEl) return; 605 var rect = ann._highlightEl.getBoundingClientRect(); 606 ann._markerEl.style.top = (rect.top + scrollTop + 3) + 'px'; 607 ann._markerEl.style.left = markerLeft + 'px'; 608 }); 609 } 610 611 // ----------------------------------------------------------------------- 612 // Page counter 613 // ----------------------------------------------------------------------- 614 615 /** 616 * Render (or update) the counter bubble above the content area. 617 * 618 * @param {object} stats {total, open, resolved} 619 * @param {number} orphanCount 620 */ 621 function renderCounter(stats, orphanCount) { 622 var existing = document.getElementById('ann-counter-bar'); 623 if (existing) existing.parentNode.removeChild(existing); 624 625 if (stats.total === 0 && orphanCount === 0) return; 626 627 var bar = document.createElement('div'); 628 bar.id = 'ann-counter-bar'; 629 bar.className = CLS_COUNTER; 630 631 var total = stats.total || 0; 632 var label = total === 1 633 ? t('counter_annotation', '1 annotation') 634 : fmt(t('counter_annotations', '%d annotations'), total); 635 bar.appendChild(document.createTextNode(label)); 636 637 if (orphanCount > 0) { 638 bar.appendChild(document.createTextNode(' · ')); 639 var orphanLink = document.createElement('a'); 640 orphanLink.href = '#ann-orphan-drawer'; 641 orphanLink.className = 'ann-orphan-link'; 642 orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount); 643 orphanLink.addEventListener('click', function (e) { 644 e.preventDefault(); 645 toggleOrphanDrawer(); 646 repositionMarkers(); 647 }); 648 bar.appendChild(orphanLink); 649 } 650 651 if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) { 652 if (stats.resolved > 0) { 653 var btnCR = document.createElement('button'); 654 btnCR.type = 'button'; 655 btnCR.className = 'ann-btn ann-btn-admin'; 656 btnCR.textContent = t('btn_clear_resolved', 'Clear resolved'); 657 btnCR.addEventListener('click', doClearResolved); 658 bar.appendChild(btnCR); 659 } 660 if (orphanCount > 0) { 661 var btnCO = document.createElement('button'); 662 btnCO.type = 'button'; 663 btnCO.className = 'ann-btn ann-btn-admin'; 664 btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned'); 665 btnCO.addEventListener('click', doClearOrphaned); 666 bar.appendChild(btnCO); 667 } 668 } 669 670 // Insert inside .page, right after #dw__toc if present. 671 // The TOC is float:right so placing the bar after it (not before) lets 672 // it sit to the left of the float instead of pushing the TOC down. 673 var pageEl = document.querySelector('.' + PAGE_CLS); 674 if (pageEl) { 675 var toc = pageEl.querySelector('#dw__toc'); 676 if (toc && toc.nextSibling) { 677 pageEl.insertBefore(bar, toc.nextSibling); 678 } else if (toc) { 679 pageEl.appendChild(bar); 680 } else { 681 pageEl.insertBefore(bar, pageEl.firstChild); 682 } 683 } else { 684 var content = document.getElementById(CONTENT_ID); 685 if (content) content.insertBefore(bar, content.firstChild); 686 } 687 } 688 689 /** 690 * Recount and re-render the counter from in-memory state. 691 */ 692 function updateCounter(orphanCount) { 693 var open = 0, resolved = 0; 694 if (orphanCount === undefined) { 695 orphanCount = 0; 696 } 697 _annotations.forEach(function (ann) { 698 if (ann._orphaned) { 699 orphanCount++; 700 } else if (ann.status === 'resolved') { 701 resolved++; 702 } else { 703 open++; 704 } 705 }); 706 renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount); 707 } 708 709 // ----------------------------------------------------------------------- 710 // Annotation panel 711 // ----------------------------------------------------------------------- 712 713 /** 714 * Open the thread panel for the given annotation id. 715 * If that panel is already open, close it. 716 * 717 * @param {string} annId 718 * @param {boolean} [focusReply] focus the reply box once open (default true); 719 * reopenPanel passes false so re-rendering after 720 * an action doesn't yank the viewport to the form. 721 */ 722 function openPanel(annId, focusReply) { 723 if (_openAnnId === annId) { 724 closePanel(); 725 return; 726 } 727 closePanel(); 728 729 var ann = _annotations.get(annId); 730 if (!ann) return; 731 732 var panel = buildPanel(ann); 733 _openPanel = panel; 734 _openAnnId = annId; 735 736 // Insert below the paragraph that contains the highlight. 737 var anchor = ann._highlightEl || null; 738 var insertAfter = findParagraph(anchor); 739 if (insertAfter && insertAfter.parentNode) { 740 insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling); 741 } else { 742 // Orphan or no paragraph found: show at the bottom of content. 743 var content = document.getElementById(CONTENT_ID); 744 if (content) content.appendChild(panel); 745 } 746 747 if (focusReply !== false) { 748 var input = panel.querySelector('.ann-body-input'); 749 if (input) input.focus(); 750 } 751 752 // The panel grew the document; nudge markers below it back into line. 753 repositionMarkers(); 754 } 755 756 /** 757 * Close and remove the currently open panel. 758 */ 759 function closePanel() { 760 if (_openPanel && _openPanel.parentNode) { 761 _openPanel.parentNode.removeChild(_openPanel); 762 } 763 _openPanel = null; 764 _openAnnId = null; 765 repositionMarkers(); 766 } 767 768 /** 769 * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.) 770 * that can receive a sibling element. 771 * 772 * @param {HTMLElement|null} el 773 * @returns {HTMLElement|null} 774 */ 775 function findParagraph(el) { 776 var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/; 777 var node = el; 778 while (node && node.id !== CONTENT_ID) { 779 if (node.nodeType === 1 && block.test(node.tagName)) { 780 return node; 781 } 782 node = node.parentNode; 783 } 784 return el; // fallback: use the element itself 785 } 786 787 /** 788 * Build and return the panel DOM element for one annotation. 789 * 790 * @param {object} ann 791 * @returns {HTMLElement} 792 */ 793 function buildPanel(ann) { 794 var panel = document.createElement('div'); 795 panel.className = CLS_PANEL; 796 panel.dataset.annId = ann.id; 797 panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css 798 799 // Main annotation thread entry (close button lives in its meta row). 800 var rootEntry = buildThreadEntry(ann, true); 801 var meta = rootEntry.querySelector('.ann-meta'); 802 if (meta) { 803 var closeBtn = document.createElement('button'); 804 closeBtn.type = 'button'; 805 closeBtn.className = 'ann-btn ann-close'; 806 closeBtn.setAttribute('aria-label', t('label_close', 'Close')); 807 closeBtn.textContent = '×'; // × 808 closeBtn.style.marginLeft = 'auto'; 809 closeBtn.addEventListener('click', closePanel); 810 meta.appendChild(closeBtn); 811 } 812 panel.appendChild(rootEntry); 813 814 // Replies: build hierarchy from flat list and render depth-indented. 815 appendReplyTree(panel, ann, buildReplyTree(ann.replies || []), 0); 816 817 // Reply form at the bottom for root-level replies. 818 if (_loggedIn) { 819 panel.appendChild(buildReplyForm(ann)); 820 } 821 822 return panel; 823 } 824 825 /** 826 * Build the DOM for the top-level annotation entry. 827 * 828 * @param {object} ann 829 * @param {boolean} isRoot true for the annotation itself, false for replies 830 * @returns {HTMLElement} 831 */ 832 function buildThreadEntry(ann, isRoot) { 833 var entry = document.createElement('div'); 834 entry.className = 'ann-thread-entry ann-annotation'; 835 entry.dataset.annId = ann.id; 836 837 // Meta row: avatar, author, time, status pill 838 entry.appendChild(buildMeta(ann.author, ann.created, ann.status)); 839 840 // Body 841 var bodyEl = document.createElement('div'); 842 bodyEl.className = 'ann-body'; 843 bodyEl.textContent = ann.body; 844 entry.appendChild(bodyEl); 845 846 // Quoted text snippet 847 if (ann.anchor && ann.anchor.exact) { 848 var quote = document.createElement('blockquote'); 849 quote.className = 'ann-quote'; 850 quote.textContent = ann.anchor.exact; 851 entry.appendChild(quote); 852 } 853 854 // Action buttons 855 var actions = document.createElement('div'); 856 actions.className = 'ann-actions'; 857 858 // Resolve/Reopen (any reader) 859 if (_loggedIn) { 860 var resolveBtn = document.createElement('button'); 861 resolveBtn.type = 'button'; 862 resolveBtn.className = 'ann-btn ann-btn-primary'; 863 resolveBtn.textContent = ann.status === 'resolved' 864 ? t('btn_reopen', 'Reopen') 865 : t('btn_resolve', 'Resolve'); 866 resolveBtn.addEventListener('click', function () { 867 doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved', resolveBtn); 868 }); 869 actions.appendChild(resolveBtn); 870 } 871 872 // Edit + Delete (own or admin) 873 var canEdit = _isAdmin || ann.author === currentUser(); 874 if (canEdit && _loggedIn) { 875 var editBtn = document.createElement('button'); 876 editBtn.type = 'button'; 877 editBtn.className = 'ann-btn'; 878 editBtn.textContent = t('btn_edit', 'Edit'); 879 editBtn.addEventListener('click', function () { 880 showEditForm(entry, ann, 'annotation'); 881 }); 882 actions.appendChild(editBtn); 883 884 var delBtn = document.createElement('button'); 885 delBtn.type = 'button'; 886 delBtn.className = 'ann-btn ann-btn-danger'; 887 delBtn.textContent = t('btn_delete', 'Delete'); 888 delBtn.addEventListener('click', function () { 889 if (confirm(t('confirm_delete', 'Delete this annotation?'))) { 890 doDeleteAnnotation(ann.id, delBtn); 891 } 892 }); 893 actions.appendChild(delBtn); 894 } 895 896 entry.appendChild(actions); 897 return entry; 898 } 899 900 /** 901 * Build the DOM for one reply entry, indented according to its nesting depth. 902 * 903 * @param {object} ann parent annotation 904 * @param {object} reply 905 * @param {number} depth 0 = direct reply to annotation; 1+ = nested 906 * @returns {HTMLElement} 907 */ 908 function buildReplyEntry(ann, reply, depth) { 909 var entry = document.createElement('div'); 910 entry.className = 'ann-thread-entry ann-reply'; 911 entry.dataset.replyId = reply.id; 912 // Indent nested replies up to 4 levels (1.5 em each). 913 var indent = Math.min(depth, 4) * 1.5 + 1.5; 914 if (indent > 0) { 915 entry.style.marginLeft = indent + 'em'; 916 } 917 918 entry.appendChild(buildMeta(reply.author, reply.created, null)); 919 920 var bodyEl = document.createElement('div'); 921 bodyEl.className = 'ann-body'; 922 bodyEl.textContent = reply.body; 923 entry.appendChild(bodyEl); 924 925 var actions = document.createElement('div'); 926 actions.className = 'ann-actions'; 927 928 // "Reply to this reply" button for logged-in users. 929 if (_loggedIn) { 930 var replyToBtn = document.createElement('button'); 931 replyToBtn.type = 'button'; 932 replyToBtn.className = 'ann-btn ann-btn-primary'; 933 replyToBtn.textContent = t('btn_reply', 'Reply'); 934 replyToBtn.addEventListener('click', function () { 935 // Toggle an inline reply form directly after this entry. 936 var next = entry.nextSibling; 937 if (next && next.classList && next.classList.contains('ann-inline-reply')) { 938 next.parentNode.removeChild(next); 939 return; 940 } 941 var form = buildInlineReplyForm(ann, reply.id, depth + 1); 942 entry.parentNode.insertBefore(form, entry.nextSibling); 943 var ta = form.querySelector('.ann-body-input'); 944 if (ta) ta.focus(); 945 }); 946 actions.appendChild(replyToBtn); 947 } 948 949 var canEdit = _isAdmin || reply.author === currentUser(); 950 if (canEdit && _loggedIn) { 951 var editBtn = document.createElement('button'); 952 editBtn.type = 'button'; 953 editBtn.className = 'ann-btn'; 954 editBtn.textContent = t('btn_edit', 'Edit'); 955 editBtn.addEventListener('click', function () { 956 showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply'); 957 }); 958 actions.appendChild(editBtn); 959 960 var delBtn = document.createElement('button'); 961 delBtn.type = 'button'; 962 delBtn.className = 'ann-btn ann-btn-danger'; 963 delBtn.textContent = t('btn_delete', 'Delete'); 964 delBtn.addEventListener('click', function () { 965 if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) { 966 doDeleteReply(ann.id, reply.id, delBtn); 967 } 968 }); 969 actions.appendChild(delBtn); 970 } 971 972 entry.appendChild(actions); 973 return entry; 974 } 975 976 /** 977 * Build a nested tree structure from a flat reply list. Replies without a 978 * known parentId (including legacy replies with no parentId field) are 979 * treated as root-level. 980 * 981 * @param {Array} replies flat array of reply objects 982 * @returns {Array} array of {reply, children} nodes 983 */ 984 function buildReplyTree(replies) { 985 var map = {}; 986 var roots = []; 987 replies.forEach(function (r) { 988 map[r.id] = {reply: r, children: []}; 989 }); 990 replies.forEach(function (r) { 991 var pid = r.parentId || ''; 992 if (pid && map[pid]) { 993 map[pid].children.push(map[r.id]); 994 } else { 995 roots.push(map[r.id]); 996 } 997 }); 998 return roots; 999 } 1000 1001 /** 1002 * Recursively append reply entries into the panel. 1003 * 1004 * @param {HTMLElement} panel 1005 * @param {object} ann 1006 * @param {Array} nodes array of {reply, children} tree nodes 1007 * @param {number} depth 1008 */ 1009 function appendReplyTree(panel, ann, nodes, depth) { 1010 nodes.forEach(function (node) { 1011 panel.appendChild(buildReplyEntry(ann, node.reply, depth)); 1012 if (node.children.length > 0) { 1013 appendReplyTree(panel, ann, node.children, depth + 1); 1014 } 1015 }); 1016 } 1017 1018 /** 1019 * Build an inline reply form that appears directly below a reply entry. 1020 * 1021 * @param {object} ann parent annotation 1022 * @param {string} parentReplyId id of the reply being replied to 1023 * @param {number} depth visual nesting depth for the new reply 1024 * @returns {HTMLElement} 1025 */ 1026 function buildInlineReplyForm(ann, parentReplyId, depth) { 1027 var form = document.createElement('div'); 1028 form.className = 'ann-thread-entry ann-reply ann-inline-reply'; 1029 var indent = Math.min(depth, 4) * 1.5 + 1.5; 1030 if (indent > 0) { 1031 form.style.marginLeft = indent + 'em'; 1032 } 1033 1034 var ta = document.createElement('textarea'); 1035 ta.className = 'ann-body-input'; 1036 ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1037 ta.rows = 2; 1038 form.appendChild(ta); 1039 1040 var row = document.createElement('div'); 1041 row.className = 'ann-form-row'; 1042 1043 var submitBtn = document.createElement('button'); 1044 submitBtn.type = 'button'; 1045 submitBtn.className = 'ann-btn ann-btn-primary'; 1046 submitBtn.textContent = t('btn_reply', 'Reply'); 1047 submitBtn.addEventListener('click', function () { 1048 var body = ta.value.trim(); 1049 if (!body) return; 1050 doAddReply(ann.id, body, function () { 1051 if (form.parentNode) form.parentNode.removeChild(form); 1052 }, submitBtn, parentReplyId); 1053 }); 1054 1055 var cancelBtn = document.createElement('button'); 1056 cancelBtn.type = 'button'; 1057 cancelBtn.className = 'ann-btn'; 1058 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1059 cancelBtn.addEventListener('click', function () { 1060 if (form.parentNode) form.parentNode.removeChild(form); 1061 }); 1062 1063 row.appendChild(submitBtn); 1064 row.appendChild(cancelBtn); 1065 form.appendChild(row); 1066 return form; 1067 } 1068 1069 /** 1070 * Build the meta row (avatar initials, author name, timestamp, status pill). 1071 * 1072 * @param {string} author 1073 * @param {number} timestamp Unix seconds 1074 * @param {string|null} status 'open'|'resolved'|null 1075 * @returns {HTMLElement} 1076 */ 1077 function buildMeta(author, timestamp, status) { 1078 var meta = document.createElement('div'); 1079 meta.className = 'ann-meta'; 1080 1081 var avatar = document.createElement('span'); 1082 avatar.className = 'ann-avatar'; 1083 avatar.textContent = (author || '?').slice(0, 2).toUpperCase(); 1084 meta.appendChild(avatar); 1085 1086 var authorEl = document.createElement('span'); 1087 authorEl.className = 'ann-author'; 1088 authorEl.textContent = author || t('label_unknown', 'Unknown'); 1089 meta.appendChild(authorEl); 1090 1091 var timeEl = document.createElement('time'); 1092 timeEl.className = 'ann-time'; 1093 var d = new Date(timestamp * 1000); 1094 timeEl.dateTime = d.toISOString(); 1095 timeEl.textContent = formatDate(d); 1096 meta.appendChild(timeEl); 1097 1098 if (status) { 1099 var pill = document.createElement('span'); 1100 pill.className = 'ann-status ann-status-' + status; 1101 pill.textContent = status === 'resolved' 1102 ? t('status_resolved', 'Resolved') 1103 : t('status_open', 'Open'); 1104 meta.appendChild(pill); 1105 } 1106 1107 return meta; 1108 } 1109 1110 /** 1111 * Build a reply form at the bottom of the panel. 1112 * 1113 * @param {object} ann 1114 * @returns {HTMLElement} 1115 */ 1116 function buildReplyForm(ann) { 1117 var form = document.createElement('div'); 1118 form.className = 'ann-reply-form'; 1119 1120 var ta = document.createElement('textarea'); 1121 ta.className = 'ann-body-input'; 1122 ta.placeholder = t('placeholder_reply', 'Write a reply…'); 1123 ta.rows = 3; 1124 form.appendChild(ta); 1125 1126 var row = document.createElement('div'); 1127 row.className = 'ann-form-row'; 1128 1129 var submitBtn = document.createElement('button'); 1130 submitBtn.type = 'button'; 1131 submitBtn.className = 'ann-btn ann-btn-primary'; 1132 submitBtn.textContent = t('btn_reply', 'Reply'); 1133 submitBtn.addEventListener('click', function () { 1134 var body = ta.value.trim(); 1135 if (!body) return; 1136 doAddReply(ann.id, body, function () { 1137 ta.value = ''; 1138 }, submitBtn); 1139 }); 1140 row.appendChild(submitBtn); 1141 form.appendChild(row); 1142 1143 return form; 1144 } 1145 1146 /** 1147 * Replace the body of an entry with an inline edit form. 1148 * 1149 * @param {HTMLElement} entry 1150 * @param {object} data {body, annId?, replyId?} (annId = undefined → annotation) 1151 * @param {string} type 'annotation' | 'reply' 1152 */ 1153 function showEditForm(entry, data, type) { 1154 var bodyEl = entry.querySelector('.ann-body'); 1155 if (!bodyEl) return; 1156 1157 var ta = document.createElement('textarea'); 1158 ta.className = 'ann-body-input'; 1159 ta.value = data.body || ''; 1160 ta.rows = 3; 1161 1162 var row = document.createElement('div'); 1163 row.className = 'ann-form-row'; 1164 1165 var saveBtn = document.createElement('button'); 1166 saveBtn.type = 'button'; 1167 saveBtn.className = 'ann-btn ann-btn-primary'; 1168 saveBtn.textContent = t('btn_save', 'Save'); 1169 saveBtn.addEventListener('click', function () { 1170 var newBody = ta.value.trim(); 1171 if (!newBody) return; 1172 if (type === 'annotation') { 1173 doEditAnnotation(data.id || _openAnnId, newBody, saveBtn); 1174 } else { 1175 doEditReply(data.annId, data.replyId, newBody, saveBtn); 1176 } 1177 }); 1178 1179 var cancelBtn = document.createElement('button'); 1180 cancelBtn.type = 'button'; 1181 cancelBtn.className = 'ann-btn'; 1182 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1183 cancelBtn.addEventListener('click', function () { 1184 entry.removeChild(ta); 1185 entry.removeChild(row); 1186 bodyEl.style.display = ''; 1187 }); 1188 1189 row.appendChild(saveBtn); 1190 row.appendChild(cancelBtn); 1191 1192 bodyEl.style.display = 'none'; 1193 entry.insertBefore(ta, bodyEl.nextSibling); 1194 entry.insertBefore(row, ta.nextSibling); 1195 ta.focus(); 1196 } 1197 1198 // ----------------------------------------------------------------------- 1199 // Orphan drawer 1200 // ----------------------------------------------------------------------- 1201 1202 /** 1203 * Toggle the orphan drawer visibility. 1204 */ 1205 function toggleOrphanDrawer() { 1206 var drawer = document.getElementById('ann-orphan-drawer'); 1207 if (drawer) { 1208 drawer.parentNode.removeChild(drawer); 1209 return; 1210 } 1211 renderOrphanDrawer(); 1212 } 1213 1214 /** 1215 * Build and insert the orphan drawer at the bottom of the content area. 1216 */ 1217 function renderOrphanDrawer() { 1218 var content = document.getElementById(CONTENT_ID); 1219 if (!content) return; 1220 1221 var drawer = document.createElement('div'); 1222 drawer.id = 'ann-orphan-drawer'; 1223 drawer.className = CLS_ORPHAN_DRAWER; 1224 1225 var heading = document.createElement('h4'); 1226 heading.textContent = t('orphaned_heading', 'Orphaned annotations'); 1227 drawer.appendChild(heading); 1228 1229 var note = document.createElement('p'); 1230 note.className = 'ann-orphan-note'; 1231 note.textContent = t('orphaned_note', 1232 'These annotations reference text that no longer appears on the page.'); 1233 drawer.appendChild(note); 1234 1235 var found = false; 1236 _annotations.forEach(function (ann) { 1237 if (!ann._orphaned) return; 1238 found = true; 1239 var entry = buildThreadEntry(ann, true); 1240 drawer.appendChild(entry); 1241 }); 1242 1243 if (!found) { 1244 var empty = document.createElement('p'); 1245 empty.textContent = t('orphaned_none', 'None.'); 1246 drawer.appendChild(empty); 1247 } 1248 1249 // Insert right below the counter bar, which lives inside .page. 1250 // All fallbacks also target .page so the drawer never stretches past 1251 // the content column. 1252 var bar = document.getElementById('ann-counter-bar'); 1253 if (bar && bar.parentNode) { 1254 bar.parentNode.insertBefore(drawer, bar.nextSibling); 1255 } else { 1256 var pageEl2 = document.querySelector('.' + PAGE_CLS); 1257 if (pageEl2) { 1258 pageEl2.insertBefore(drawer, pageEl2.firstChild); 1259 } else { 1260 content.insertBefore(drawer, content.firstChild); 1261 } 1262 } 1263 } 1264 1265 // ----------------------------------------------------------------------- 1266 // Selection capture 1267 // ----------------------------------------------------------------------- 1268 1269 /** 1270 * Wire up mouseup/touchend listeners to detect text selection. 1271 * 1272 * @param {HTMLElement} content 1273 */ 1274 function initSelectionCapture(content) { 1275 if (!_loggedIn) return; // anonymous users cannot annotate 1276 1277 document.addEventListener('mouseup', function (e) { 1278 handleSelectionEnd(e, content); 1279 }); 1280 document.addEventListener('touchend', function (e) { 1281 // Small delay so the browser has committed the selection. 1282 setTimeout(function () { handleSelectionEnd(e, content); }, 50); 1283 }); 1284 1285 // Close tooltip on click outside (but not when clicking the new-form). 1286 document.addEventListener('mousedown', function (e) { 1287 var tooltip = document.getElementById('ann-tooltip'); 1288 if (tooltip && !tooltip.contains(e.target)) { 1289 var naf = document.getElementById('ann-new-form'); 1290 if (!naf || !naf.contains(e.target)) { 1291 hideTooltip(); 1292 } 1293 } 1294 }); 1295 } 1296 1297 /** 1298 * Handle end of selection: show the "Annotate" tooltip if there is a 1299 * non-empty selection inside the content area. 1300 * 1301 * @param {Event} e 1302 * @param {HTMLElement} content 1303 */ 1304 function handleSelectionEnd(e, content) { 1305 var sel = window.getSelection(); 1306 if (!sel || sel.isCollapsed) { 1307 // Don't hide the tooltip if the mouseup came from inside it — 1308 // the click handler is responsible for cleanup in that case. 1309 var tip = document.getElementById('ann-tooltip'); 1310 if (tip && tip.contains(e.target)) { 1311 return; 1312 } 1313 // Don't hide if a new-annotation form is open (user clicked 1314 // inside the form, collapsing the original selection). 1315 var naf = document.getElementById('ann-new-form'); 1316 if (naf && naf.contains(e.target)) { 1317 return; 1318 } 1319 hideTooltip(); 1320 return; 1321 } 1322 var range = sel.getRangeAt(0); 1323 if (!content.contains(range.commonAncestorContainer)) { 1324 hideTooltip(); 1325 return; 1326 } 1327 // Don't open a new annotation when the selection overlaps existing annotated text. 1328 if (isInsideHighlight(range.startContainer) || isInsideHighlight(range.endContainer)) { 1329 hideTooltip(); 1330 return; 1331 } 1332 var text = sel.toString().trim(); 1333 if (text.length < 1) { 1334 hideTooltip(); 1335 return; 1336 } 1337 1338 // If the tooltip is already showing (e.g. user moused up after 1339 // pressing the Annotate button), don't replace it with a fresh one — 1340 // that would orphan the button mid-click and break the click handler. 1341 if (document.getElementById('ann-tooltip')) { 1342 return; 1343 } 1344 1345 // Show the tooltip near the end of the selection. 1346 var rect = range.getBoundingClientRect(); 1347 showTooltip(rect, range, sel, content); 1348 } 1349 1350 /** 1351 * Show the "Annotate" tooltip bubble. 1352 * 1353 * @param {DOMRect} rect bounding rect of the selection 1354 * @param {Range} range 1355 * @param {Selection} sel 1356 * @param {HTMLElement} content 1357 */ 1358 function showTooltip(rect, range, sel, content) { 1359 hideTooltip(); 1360 1361 var tip = document.createElement('div'); 1362 tip.id = 'ann-tooltip'; 1363 tip.className = CLS_TOOLTIP; 1364 1365 // Capture the anchor on mousedown while the selection is guaranteed 1366 // to still exist. By the time 'click' fires, many browsers have 1367 // already collapsed the selection, so captureAnchor would return null. 1368 // _pendingAnchor is module-level so it survives tooltip replacement. 1369 var btn = document.createElement('button'); 1370 btn.type = 'button'; 1371 btn.textContent = t('btn_annotate', 'Annotate'); 1372 btn.className = 'ann-btn ann-btn-primary'; 1373 btn.addEventListener('mousedown', function (e) { 1374 e.preventDefault(); // prevent focus-change deselection 1375 // Capture now, while the selection is still intact. 1376 _pendingAnchor = captureAnchor(sel, range, content); 1377 }); 1378 btn.addEventListener('click', function () { 1379 var anchor = _pendingAnchor; 1380 _pendingAnchor = null; 1381 hideTooltip(); 1382 if (anchor) { 1383 openNewAnnotationForm(anchor, range); 1384 } 1385 }); 1386 tip.appendChild(btn); 1387 1388 document.body.appendChild(tip); 1389 1390 // Position below the selection's end. 1391 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 1392 var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 1393 tip.style.top = (rect.bottom + scrollTop + 6) + 'px'; 1394 tip.style.left = (rect.left + scrollLeft) + 'px'; 1395 } 1396 1397 /** 1398 * Remove the tooltip if it exists. 1399 */ 1400 function hideTooltip() { 1401 var tip = document.getElementById('ann-tooltip'); 1402 if (tip && tip.parentNode) { 1403 tip.parentNode.removeChild(tip); 1404 } 1405 // Note: ann-new-form is NOT removed here — it has its own Cancel 1406 // button and must survive the mouseup that fires after the click. 1407 } 1408 1409 /** 1410 * Capture an anchor object from the current Selection. 1411 * 1412 * @param {Selection} sel 1413 * @param {Range} range 1414 * @param {HTMLElement} content 1415 * @returns {object|null} {exact, prefix, suffix, start} 1416 */ 1417 function captureAnchor(sel, range, content) { 1418 var exact = normalizeWS(sel.toString()); 1419 if (!exact) return null; 1420 1421 // Get full page text for prefix/suffix and start computation. 1422 var chunks = collectTextChunks(content); 1423 var fullRaw = chunks.map(function (c) { return c.text; }).join(''); 1424 var nm = normalizeWithMap(fullRaw); 1425 var fullNorm = nm.norm; 1426 1427 // Find where this text node + offset lands in the raw full text. 1428 var rawStart = 0; 1429 for (var i = 0; i < chunks.length; i++) { 1430 var c = chunks[i]; 1431 if (c.node === range.startContainer) { 1432 rawStart = c.start + range.startOffset; 1433 break; 1434 } 1435 } 1436 1437 // Map that raw offset to an offset in the normalised text, using the 1438 // same map as re-anchoring so capture and find stay in agreement. 1439 var normStart = nm.norm.length; 1440 for (var j = 0; j < nm.map.length; j++) { 1441 if (nm.map[j] >= rawStart) { 1442 normStart = j; 1443 break; 1444 } 1445 } 1446 1447 var CTX = 30; 1448 var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart); 1449 var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX); 1450 1451 return { 1452 exact: exact, 1453 prefix: prefix, 1454 suffix: suffix, 1455 start: normStart, 1456 }; 1457 } 1458 1459 /** 1460 * Open the new-annotation form below the paragraph containing the selection. 1461 * 1462 * @param {object} anchor {exact, prefix, suffix, start} 1463 * @param {Range} range 1464 */ 1465 function openNewAnnotationForm(anchor, range) { 1466 closePanel(); 1467 1468 var insertAfter = findParagraph(range.commonAncestorContainer); 1469 var form = document.createElement('div'); 1470 form.id = 'ann-new-form'; 1471 form.className = 'ann-new-form'; 1472 1473 var quote = document.createElement('blockquote'); 1474 quote.className = 'ann-quote'; 1475 quote.textContent = anchor.exact; 1476 form.appendChild(quote); 1477 1478 var ta = document.createElement('textarea'); 1479 ta.className = 'ann-body-input'; 1480 ta.placeholder = t('placeholder_body', 'Add a comment…'); 1481 ta.rows = 3; 1482 form.appendChild(ta); 1483 1484 var row = document.createElement('div'); 1485 row.className = 'ann-form-row'; 1486 1487 var submitBtn = document.createElement('button'); 1488 submitBtn.type = 'button'; 1489 submitBtn.className = 'ann-btn ann-btn-primary'; 1490 submitBtn.textContent = t('btn_annotate', 'Annotate'); 1491 submitBtn.addEventListener('click', function () { 1492 var body = ta.value.trim(); 1493 if (!body) return; 1494 doCreate(anchor, body, function () { 1495 if (form.parentNode) form.parentNode.removeChild(form); 1496 }, submitBtn); 1497 }); 1498 1499 var cancelBtn = document.createElement('button'); 1500 cancelBtn.type = 'button'; 1501 cancelBtn.className = 'ann-btn'; 1502 cancelBtn.textContent = t('btn_cancel', 'Cancel'); 1503 cancelBtn.addEventListener('click', function () { 1504 if (form.parentNode) form.parentNode.removeChild(form); 1505 }); 1506 1507 row.appendChild(submitBtn); 1508 row.appendChild(cancelBtn); 1509 form.appendChild(row); 1510 1511 if (insertAfter && insertAfter.parentNode) { 1512 insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling); 1513 } else { 1514 var content = document.getElementById(CONTENT_ID); 1515 if (content) content.appendChild(form); 1516 } 1517 1518 ta.focus(); 1519 } 1520 1521 // ----------------------------------------------------------------------- 1522 // AJAX actions 1523 // ----------------------------------------------------------------------- 1524 1525 /** 1526 * POST create action and update state on success. 1527 * 1528 * @param {object} anchor 1529 * @param {string} body 1530 * @param {Function} onSuccess 1531 * @param {HTMLElement} [btn] button to disable while the request is in flight 1532 */ 1533 function doCreate(anchor, body, onSuccess, btn) { 1534 setBusy(btn, true); 1535 ajax({ 1536 action: 'create', 1537 id: _info.pageId, 1538 anchor: anchor, 1539 body: body, 1540 }).then(function (data) { 1541 setBusy(btn, false); 1542 if (!data.success) { 1543 showError(t('error_save', 'Could not save — please try again.'), data); 1544 return; 1545 } 1546 var ann = data.annotation; 1547 _annotations.set(ann.id, ann); 1548 if (typeof onSuccess === 'function') onSuccess(ann); 1549 renderAll(); 1550 }).catch(function () { 1551 setBusy(btn, false); 1552 alert(t('error_save', 'Could not save — please try again.')); 1553 }); 1554 } 1555 1556 /** 1557 * Run a thread-level mutation (reply / edit annotation / edit reply / 1558 * delete reply): POST the payload, then on success store the returned 1559 * annotation — keeping the client-side render state via mergeClientProps — 1560 * and re-open its panel. The server returns the full updated annotation, so 1561 * no second GET is needed. These four actions share this exact shape; 1562 * create / delete-annotation / resolve differ (they re-render the whole 1563 * overlay) and stay separate below. 1564 * 1565 * @param {object} payload AJAX body; must carry annId 1566 * @param {HTMLElement} [btn] button to show the busy spinner on 1567 * @param {string} errKey lang key for the failure message 1568 * @param {string} errText English fallback for that message 1569 * @param {Function} [onOk] optional callback run before re-rendering 1570 */ 1571 function submitThreadAction(payload, btn, errKey, errText, onOk) { 1572 setBusy(btn, true); 1573 ajax(payload).then(function (data) { 1574 setBusy(btn, false); 1575 if (!data.success) { 1576 showError(t(errKey, errText), data); 1577 return; 1578 } 1579 _annotations.set(data.annotation.id, mergeClientProps(data.annotation)); 1580 if (typeof onOk === 'function') onOk(); 1581 reopenPanel(payload.annId); 1582 }).catch(function () { 1583 setBusy(btn, false); 1584 alert(t(errKey, errText)); 1585 }); 1586 } 1587 1588 /** 1589 * POST reply action and refresh the open panel. 1590 * 1591 * @param {string} annId 1592 * @param {string} body 1593 * @param {Function} onSuccess 1594 * @param {HTMLElement} [btn] 1595 * @param {string} [parentReplyId] id of the reply being replied to, or '' 1596 */ 1597 function doAddReply(annId, body, onSuccess, btn, parentReplyId) { 1598 submitThreadAction({ 1599 action: 'reply', 1600 id: _info.pageId, 1601 annId: annId, 1602 body: body, 1603 parentId: parentReplyId || '', 1604 }, btn, 'error_save', 'Could not save — please try again.', onSuccess); 1605 } 1606 1607 /** 1608 * POST edit_annotation and re-render. 1609 * 1610 * @param {string} annId 1611 * @param {string} body 1612 * @param {HTMLElement} [btn] 1613 */ 1614 function doEditAnnotation(annId, body, btn) { 1615 submitThreadAction({ 1616 action: 'edit_annotation', 1617 id: _info.pageId, 1618 annId: annId, 1619 body: body, 1620 }, btn, 'error_save', 'Could not save — please try again.'); 1621 } 1622 1623 /** 1624 * POST edit_reply and re-render. 1625 * 1626 * @param {string} annId 1627 * @param {string} replyId 1628 * @param {string} body 1629 * @param {HTMLElement} [btn] 1630 */ 1631 function doEditReply(annId, replyId, body, btn) { 1632 submitThreadAction({ 1633 action: 'edit_reply', 1634 id: _info.pageId, 1635 annId: annId, 1636 replyId: replyId, 1637 body: body, 1638 }, btn, 'error_save', 'Could not save — please try again.'); 1639 } 1640 1641 /** 1642 * POST delete_annotation. 1643 * 1644 * @param {string} annId 1645 * @param {HTMLElement} [btn] 1646 */ 1647 function doDeleteAnnotation(annId, btn) { 1648 setBusy(btn, true); 1649 ajax({ 1650 action: 'delete_annotation', 1651 id: _info.pageId, 1652 annId: annId, 1653 }).then(function (data) { 1654 setBusy(btn, false); 1655 if (!data.success) { 1656 showError(t('error_delete', 'Could not delete — please try again.'), data); 1657 return; 1658 } 1659 _annotations.delete(annId); 1660 closePanel(); 1661 renderAll(); 1662 }).catch(function () { 1663 setBusy(btn, false); 1664 }); 1665 } 1666 1667 /** 1668 * POST delete_reply and re-render. 1669 * 1670 * @param {string} annId 1671 * @param {string} replyId 1672 * @param {HTMLElement} [btn] 1673 */ 1674 function doDeleteReply(annId, replyId, btn) { 1675 submitThreadAction({ 1676 action: 'delete_reply', 1677 id: _info.pageId, 1678 annId: annId, 1679 replyId: replyId, 1680 }, btn, 'error_delete', 'Could not delete — please try again.'); 1681 } 1682 1683 /** 1684 * POST resolve/reopen action. 1685 * 1686 * @param {string} annId 1687 * @param {string} status 'open' | 'resolved' 1688 * @param {HTMLElement} [btn] 1689 */ 1690 function doResolve(annId, status, btn) { 1691 setBusy(btn, true); 1692 ajax({ 1693 action: 'resolve', 1694 id: _info.pageId, 1695 annId: annId, 1696 status: status, 1697 }).then(function (data) { 1698 setBusy(btn, false); 1699 if (!data.success) { 1700 showError(t('error_status', 'Could not update the status — please try again.'), data); 1701 return; 1702 } 1703 _annotations.set(data.annotation.id, data.annotation); 1704 renderAll(); 1705 reopenPanel(annId); 1706 }).catch(function () { 1707 setBusy(btn, false); 1708 }); 1709 } 1710 1711 /** 1712 * POST clear_resolved (admin). 1713 */ 1714 function doClearResolved() { 1715 if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return; 1716 ajax({ 1717 action: 'clear_resolved', 1718 id: _info.pageId, 1719 }).then(function (data) { 1720 if (!data.success) { 1721 showError(t('error_clear', 'Could not clear — please try again.'), data); 1722 return; 1723 } 1724 // Remove resolved from local state. 1725 _annotations.forEach(function (ann, id) { 1726 if (ann.status === 'resolved') _annotations.delete(id); 1727 }); 1728 closePanel(); 1729 renderAll(); 1730 }); 1731 } 1732 1733 /** 1734 * POST clear_orphaned (admin). 1735 */ 1736 function doClearOrphaned() { 1737 if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return; 1738 ajax({ 1739 action: 'clear_orphaned', 1740 id: _info.pageId, 1741 }).then(function (data) { 1742 if (!data.success) { 1743 showError(t('error_clear', 'Could not clear — please try again.'), data); 1744 return; 1745 } 1746 _annotations.forEach(function (ann, id) { 1747 if (ann._orphaned) _annotations.delete(id); 1748 }); 1749 closePanel(); 1750 renderAll(); 1751 }); 1752 } 1753 1754 // ----------------------------------------------------------------------- 1755 // Panel management helpers 1756 // ----------------------------------------------------------------------- 1757 1758 /** 1759 * Close the current panel and re-open it (preserves scroll position and 1760 * re-renders the thread with fresh data). 1761 * 1762 * @param {string} annId 1763 */ 1764 function reopenPanel(annId) { 1765 // closePanel() first clears _openAnnId so openPanel() rebuilds instead 1766 // of treating the same id as a toggle. focusReply=false keeps the 1767 // viewport put after resolve / edit / delete actions. 1768 closePanel(); 1769 openPanel(annId, false); 1770 } 1771 1772 // ----------------------------------------------------------------------- 1773 // Utilities 1774 // ----------------------------------------------------------------------- 1775 1776 /** 1777 * Disable a button and show a spinner while an AJAX request is in flight; 1778 * restore label and width on completion. 1779 * 1780 * @param {HTMLElement|null|undefined} btn 1781 * @param {boolean} busy 1782 */ 1783 function setBusy(btn, busy) { 1784 if (!btn) return; 1785 if (busy) { 1786 btn.disabled = true; 1787 btn.dataset.prevText = btn.textContent; 1788 // Lock the width before clearing text so the button doesn't shrink. 1789 btn.style.minWidth = btn.offsetWidth + 'px'; 1790 btn.textContent = ' '; // non-breaking space keeps height 1791 btn.classList.add('ann-btn-busy'); 1792 } else { 1793 btn.disabled = false; 1794 btn.classList.remove('ann-btn-busy'); 1795 if (btn.dataset.prevText !== undefined) { 1796 btn.textContent = btn.dataset.prevText; 1797 delete btn.dataset.prevText; 1798 } 1799 btn.style.minWidth = ''; 1800 } 1801 } 1802 1803 /** 1804 * Copy client-only runtime properties (_highlightEl, _markerEl, 1805 * _orphaned, _range) from the currently stored annotation onto a 1806 * freshly-returned server object before storing it, so that panels 1807 * reopen at the correct position instead of falling back to the 1808 * bottom of the page. 1809 * 1810 * @param {object} fresh annotation object from the server 1811 * @returns {object} the same object, augmented 1812 */ 1813 function mergeClientProps(fresh) { 1814 var existing = _annotations.get(fresh.id); 1815 if (existing) { 1816 fresh._highlightEl = existing._highlightEl; 1817 fresh._markerEl = existing._markerEl; 1818 fresh._orphaned = existing._orphaned; 1819 fresh._range = existing._range; 1820 } 1821 return fresh; 1822 } 1823 1824 /** 1825 * The per-plugin JS language bundle, exposed by DokuWiki as 1826 * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']). 1827 * 1828 * @returns {object} 1829 */ 1830 function uiLang() { 1831 if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) { 1832 return LANG.plugins.annotations; 1833 } 1834 return {}; 1835 } 1836 1837 /** 1838 * Look up a UI string by key, falling back to the supplied English text if 1839 * the bundle is missing the key (e.g. a lang file not yet updated). 1840 * 1841 * @param {string} key 1842 * @param {string} fallback English default 1843 * @returns {string} 1844 */ 1845 function t(key, fallback) { 1846 var s = _lang[key]; 1847 return (s === undefined || s === null || s === '') ? fallback : s; 1848 } 1849 1850 /** 1851 * Substitute a single %d placeholder with a number. 1852 * 1853 * @param {string} str 1854 * @param {number} n 1855 * @returns {string} 1856 */ 1857 function fmt(str, n) { 1858 return String(str).replace('%d', n); 1859 } 1860 1861 /** 1862 * Show a localised error, appending the server's reason in parentheses 1863 * when one is present. 1864 * 1865 * @param {string} base localised message 1866 * @param {object} data AJAX response ({error?:string}) 1867 */ 1868 function showError(base, data) { 1869 var reason = (data && data.error) ? data.error : ''; 1870 alert(reason ? base + ' (' + reason + ')' : base); 1871 } 1872 1873 /** 1874 * Collapse consecutive whitespace to a single space and trim. 1875 * 1876 * @param {string} s 1877 * @returns {string} 1878 */ 1879 function normalizeWS(s) { 1880 return String(s || '').replace(/\s+/g, ' ').trim(); 1881 } 1882 1883 /** 1884 * Return the current DokuWiki username from JSINFO. 1885 * 1886 * @returns {string} 1887 */ 1888 function currentUser() { 1889 var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {}; 1890 return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : ''; 1891 } 1892 1893 /** 1894 * Format a Date for display. 1895 * 1896 * @param {Date} d 1897 * @returns {string} 1898 */ 1899 function formatDate(d) { 1900 var now = new Date(); 1901 var diff = (now - d) / 1000; // seconds 1902 if (diff < 60) return t('time_now', 'just now'); 1903 if (diff < 3600) return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60)); 1904 if (diff < 86400) return fmt(t('time_hours', '%dh ago'), Math.floor(diff / 3600)); 1905 if (diff < 86400 * 7) return fmt(t('time_days', '%dd ago'), Math.floor(diff / 86400)); 1906 return d.toLocaleDateString(); 1907 } 1908 1909 // ----------------------------------------------------------------------- 1910 // Init 1911 // ----------------------------------------------------------------------- 1912 1913 if (document.readyState === 'loading') { 1914 document.addEventListener('DOMContentLoaded', boot); 1915 } else { 1916 boot(); 1917 } 1918 1919}()); 1920