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