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