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