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