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