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