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