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