xref: /plugin/annotations/script.js (revision ad1073d40e87e01426f7a3b1bf49fa2e56d38cf1)
143d2073cStracker-user/**
243d2073cStracker-user * Annotations plugin — front-end script.
343d2073cStracker-user *
443d2073cStracker-user * Responsibilities:
543d2073cStracker-user *
643d2073cStracker-user *   1. BOOT: read JSINFO.annotations (injected by action.php); if the user
743d2073cStracker-user *      has disabled annotations, exit early.
843d2073cStracker-user *
943d2073cStracker-user *   2. LOAD: fetch the page's annotation list via the AJAX endpoint, then:
1043d2073cStracker-user *        a. Anchor each annotation in the DOM (re-anchoring).
1143d2073cStracker-user *        b. Wrap matched text in highlight <span>s.
1243d2073cStracker-user *        c. Render per-line gutter markers.
1343d2073cStracker-user *        d. Update the page counter bubble.
1443d2073cStracker-user *
1543d2073cStracker-user *   3. SELECTION: detect when the user finishes a text selection inside the
1643d2073cStracker-user *      wiki content area, show an "Annotate" tooltip, capture the anchor on
1743d2073cStracker-user *      click, and open a new-annotation form.
1843d2073cStracker-user *
1943d2073cStracker-user *   4. PANELS: clicking a highlight opens the annotation thread inline, just
2043d2073cStracker-user *      below the paragraph that contains the highlight. One open panel at a
2143d2073cStracker-user *      time. The panel renders the full thread: author, timestamp, body,
2243d2073cStracker-user *      replies; and permission-gated action buttons.
2343d2073cStracker-user *
2443d2073cStracker-user *   5. AJAX: all state-changing operations POST JSON to
2543d2073cStracker-user *      /lib/exe/ajax.php?call=annotations (with the DokuWiki security token).
2643d2073cStracker-user *      Responses update the in-memory state and re-render affected highlights
2743d2073cStracker-user *      / gutter markers / counter without a page reload.
2843d2073cStracker-user *
2943d2073cStracker-user *   6. ORPHANS: annotations that cannot be re-anchored are counted and
30*ad1073d4Stracker-user *      reachable via the orphaned counter link; their full threads (root
31*ad1073d4Stracker-user *      message plus the read-only reply tree) render in a dedicated orphan
32*ad1073d4Stracker-user *      drawer at the bottom of the content area.
3343d2073cStracker-user *
3443d2073cStracker-user * FF78 ESR compatibility:
3543d2073cStracker-user *   - No #private fields, ??=, ||=, &&=, Array.at, structuredClone,
3643d2073cStracker-user *     Object.hasOwn, native <dialog>.
3743d2073cStracker-user *   - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK.
3843d2073cStracker-user */
3943d2073cStracker-user
4043d2073cStracker-user(function () {
4143d2073cStracker-user    'use strict';
4243d2073cStracker-user
4343d2073cStracker-user    // -----------------------------------------------------------------------
4443d2073cStracker-user    // Constants
4543d2073cStracker-user    // -----------------------------------------------------------------------
4643d2073cStracker-user
4743d2073cStracker-user    var AJAX_URL   = DOKU_BASE + 'lib/exe/ajax.php?call=annotations';
4843d2073cStracker-user    var CONTENT_ID = 'dokuwiki__content';
49b8076f00Stracker-user    // .page is the article area inside #dokuwiki__content. Gutter markers
50b8076f00Stracker-user    // are appended here so position:relative doesn't break the sidebar nav.
51b8076f00Stracker-user    var PAGE_CLS = 'page';
5243d2073cStracker-user
5343d2073cStracker-user    // Colour tokens (also defined in style.css; kept here so JS can read them)
5443d2073cStracker-user    var CLS_HIGHLIGHT_OPEN     = 'ann-highlight-open';
5543d2073cStracker-user    var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved';
5643d2073cStracker-user    var CLS_GUTTER_MARKER      = 'ann-gutter-marker';
5743d2073cStracker-user    var CLS_PANEL              = 'ann-panel';
5843d2073cStracker-user    var CLS_COUNTER            = 'ann-counter';
5943d2073cStracker-user    var CLS_TOOLTIP            = 'ann-tooltip';
6043d2073cStracker-user    var CLS_ORPHAN_DRAWER      = 'ann-orphan-drawer';
6143d2073cStracker-user
6243d2073cStracker-user    // -----------------------------------------------------------------------
6343d2073cStracker-user    // State
6443d2073cStracker-user    // -----------------------------------------------------------------------
6543d2073cStracker-user
6643d2073cStracker-user    /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */
6743d2073cStracker-user    var _annotations = new Map();
6843d2073cStracker-user
6943d2073cStracker-user    /** Currently open panel element, or null. @type {HTMLElement|null} */
7043d2073cStracker-user    var _openPanel = null;
7143d2073cStracker-user
7250325813Stracker-user    /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */
7350325813Stracker-user    var _pendingAnchor = null;
7450325813Stracker-user
7543d2073cStracker-user    /** ID of the annotation whose panel is open, or null. @type {string|null} */
7643d2073cStracker-user    var _openAnnId = null;
7743d2073cStracker-user
7843d2073cStracker-user    /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */
7943d2073cStracker-user    var _info = {};
8043d2073cStracker-user
8143d2073cStracker-user    /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */
8243d2073cStracker-user    var _lang = {};
8343d2073cStracker-user
8443d2073cStracker-user    /** The DokuWiki security token. @type {string} */
8543d2073cStracker-user    var _token = '';
8643d2073cStracker-user
8743d2073cStracker-user    /** Whether the current user is logged in. @type {bool} */
8843d2073cStracker-user    var _loggedIn = false;
8943d2073cStracker-user
9043d2073cStracker-user    /** Whether the current user is an admin. @type {bool} */
9143d2073cStracker-user    var _isAdmin = false;
9243d2073cStracker-user
9343d2073cStracker-user    // -----------------------------------------------------------------------
9443d2073cStracker-user    // Boot
9543d2073cStracker-user    // -----------------------------------------------------------------------
9643d2073cStracker-user
9743d2073cStracker-user    /**
9843d2073cStracker-user     * Entry point: wired to DOMContentLoaded.
9943d2073cStracker-user     */
10043d2073cStracker-user    function boot() {
10143d2073cStracker-user        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
10243d2073cStracker-user        var annInfo = jsinfo.annotations || {};
10343d2073cStracker-user
10443d2073cStracker-user        if (!annInfo.enabled) {
10543d2073cStracker-user            return; // user disabled annotations
10643d2073cStracker-user        }
10743d2073cStracker-user
10843d2073cStracker-user        _info      = annInfo;
109da56206cStracker-user        // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as
110da56206cStracker-user        // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']).
111da56206cStracker-user        _lang      = uiLang();
1127d2714c7Stracker-user        // Token is injected into JSINFO.annotations by action.php (handleMetaHeader).
1137d2714c7Stracker-user        // getSecurityToken() on the server produces it from session_id + REMOTE_USER.
1147d2714c7Stracker-user        _token     = annInfo.token || '';
11543d2073cStracker-user
1167d2714c7Stracker-user        // DokuWiki's JSINFO doesn't include user identity; we inject
1177d2714c7Stracker-user        // user + isAdmin into JSINFO.annotations from PHP (action.php).
1187d2714c7Stracker-user        _loggedIn = !!(annInfo.user && annInfo.user !== '');
1197d2714c7Stracker-user        _isAdmin  = !!(annInfo.isAdmin);
12043d2073cStracker-user
12143d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
12243d2073cStracker-user        if (!content) {
12343d2073cStracker-user            return; // not a page view
12443d2073cStracker-user        }
12543d2073cStracker-user
12643d2073cStracker-user        renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0);
12743d2073cStracker-user        loadAnnotations();
12843d2073cStracker-user        initSelectionCapture(content);
129563f3b4cStracker-user
130563f3b4cStracker-user        // Close the open panel when the user presses Escape.
131563f3b4cStracker-user        document.addEventListener('keydown', function (e) {
132563f3b4cStracker-user            if ((e.key === 'Escape' || e.key === 'Esc') && _openPanel) {
133563f3b4cStracker-user                closePanel();
134563f3b4cStracker-user            }
135563f3b4cStracker-user        });
136563f3b4cStracker-user
137563f3b4cStracker-user        // Keep gutter markers aligned with their highlights when the viewport
138563f3b4cStracker-user        // width changes: both the .page column and the highlights reflow.
139563f3b4cStracker-user        window.addEventListener('resize', repositionMarkers);
140108f92bdStracker-user
141108f92bdStracker-user        // Annotations now render at DOMContentLoaded (the list ships inline),
142108f92bdStracker-user        // so late-loading images/web fonts can still shift the layout under the
143108f92bdStracker-user        // already-placed markers. Re-align them once everything has loaded.
144108f92bdStracker-user        window.addEventListener('load', repositionMarkers);
14543d2073cStracker-user    }
14643d2073cStracker-user
14743d2073cStracker-user    // -----------------------------------------------------------------------
14843d2073cStracker-user    // AJAX helpers
14943d2073cStracker-user    // -----------------------------------------------------------------------
15043d2073cStracker-user
15143d2073cStracker-user    /**
15243d2073cStracker-user     * POST a JSON payload to the AJAX endpoint.
15343d2073cStracker-user     *
15443d2073cStracker-user     * @param {object} payload
15543d2073cStracker-user     * @returns {Promise<object>} response data
15643d2073cStracker-user     */
15743d2073cStracker-user    function ajax(payload) {
15843d2073cStracker-user        payload.sectok = _token; // DokuWiki security token field name for AJAX
15943d2073cStracker-user        return fetch(AJAX_URL, {
16043d2073cStracker-user            method:  'POST',
16143d2073cStracker-user            headers: {'Content-Type': 'application/json'},
16243d2073cStracker-user            body:    JSON.stringify(payload),
16343d2073cStracker-user        }).then(function (res) {
16443d2073cStracker-user            return res.json();
16543d2073cStracker-user        });
16643d2073cStracker-user    }
16743d2073cStracker-user
16843d2073cStracker-user    // -----------------------------------------------------------------------
16943d2073cStracker-user    // Load and anchor annotations
17043d2073cStracker-user    // -----------------------------------------------------------------------
17143d2073cStracker-user
17243d2073cStracker-user    /**
173108f92bdStracker-user     * Load all annotations for the current page and render them.
174108f92bdStracker-user     *
175108f92bdStracker-user     * Fast path: action.php normally ships the list inline with the page (in
176108f92bdStracker-user     * JSINFO.annotations.annotations), so we render straight away with no
177108f92bdStracker-user     * round-trip. Only heavily-annotated pages omit the inline list, in which
178108f92bdStracker-user     * case we fall back to the GET 'load' endpoint.
17943d2073cStracker-user     */
18043d2073cStracker-user    function loadAnnotations() {
181108f92bdStracker-user        if (Array.isArray(_info.annotations)) {
182108f92bdStracker-user            ingestAnnotations(_info.annotations);
183108f92bdStracker-user            return;
184108f92bdStracker-user        }
185108f92bdStracker-user
186108f92bdStracker-user        // Fallback: the inline list was too large to embed. Fetch it instead.
187108f92bdStracker-user        // action.php's AJAX handler accepts action=load as a GET query.
18843d2073cStracker-user        fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), {
18943d2073cStracker-user            method: 'GET',
19043d2073cStracker-user        }).then(function (res) {
19143d2073cStracker-user            return res.json();
19243d2073cStracker-user        }).then(function (data) {
19343d2073cStracker-user            if (!data || !Array.isArray(data.annotations)) {
19443d2073cStracker-user                return;
19543d2073cStracker-user            }
196108f92bdStracker-user            ingestAnnotations(data.annotations);
19743d2073cStracker-user        }).catch(function () {
19843d2073cStracker-user            // Graceful degradation: page still works without annotations.
19943d2073cStracker-user        });
20043d2073cStracker-user    }
20143d2073cStracker-user
20243d2073cStracker-user    /**
203108f92bdStracker-user     * Store a loaded annotation list (inline or fetched) and render everything.
204108f92bdStracker-user     *
205108f92bdStracker-user     * @param {Array} list  annotation objects from the server
206108f92bdStracker-user     */
207108f92bdStracker-user    function ingestAnnotations(list) {
208108f92bdStracker-user        list.forEach(function (ann) {
209108f92bdStracker-user            _annotations.set(ann.id, ann);
210108f92bdStracker-user        });
211108f92bdStracker-user        renderAll();
212108f92bdStracker-user    }
213108f92bdStracker-user
214108f92bdStracker-user    /**
21543d2073cStracker-user     * Re-render everything: highlights, gutter markers, counter.
21643d2073cStracker-user     */
21743d2073cStracker-user    function renderAll() {
21843d2073cStracker-user        clearHighlights();
21943d2073cStracker-user        clearGutterMarkers();
22043d2073cStracker-user
22143d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
22243d2073cStracker-user        if (!content) return;
22343d2073cStracker-user
224da56206cStracker-user        // Snapshot the page text ONCE, before any highlight is inserted.
225da56206cStracker-user        // Re-collecting per annotation would exclude already-wrapped text
226da56206cStracker-user        // (collectTextChunks skips our own UI), shifting every later anchor.
227da56206cStracker-user        var chunks  = collectTextChunks(content);
228da56206cStracker-user        var rawFull = chunks.map(function (c) { return c.text; }).join('');
229da56206cStracker-user        var nm      = normalizeWithMap(rawFull);
23043d2073cStracker-user
231da56206cStracker-user        // Phase 1 — locate every annotation against the clean snapshot.
232da56206cStracker-user        var hits = [];
23343d2073cStracker-user        _annotations.forEach(function (ann) {
234da56206cStracker-user            ann._range       = null;
235da56206cStracker-user            ann._highlightEl = null;
236da56206cStracker-user            var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null;
237da56206cStracker-user            if (hit) {
238da56206cStracker-user                hits.push({ann: ann, pos: hit.pos, len: hit.len});
239da56206cStracker-user                ann._orphaned = false;
24043d2073cStracker-user            } else {
241da56206cStracker-user                ann._orphaned = true;
242da56206cStracker-user            }
243da56206cStracker-user        });
244da56206cStracker-user
245da56206cStracker-user        // Phase 2 — wrap later matches first, so wrapping (which splits text
246da56206cStracker-user        // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones.
247da56206cStracker-user        hits.sort(function (a, b) { return b.pos - a.pos; });
248da56206cStracker-user        hits.forEach(function (h) {
249da56206cStracker-user            var range = buildRange(chunks, nm.map, h.pos, h.len);
250da56206cStracker-user            if (range) {
251da56206cStracker-user                h.ann._range = range; // cache for panel positioning
252da56206cStracker-user                wrapHighlight(range, h.ann);
253da56206cStracker-user            } else {
254da56206cStracker-user                h.ann._orphaned = true;
25543d2073cStracker-user            }
25643d2073cStracker-user        });
25743d2073cStracker-user
25843d2073cStracker-user        renderGutterMarkers();
259da56206cStracker-user        updateCounter(); // recounts orphans from the _orphaned flags set above
26043d2073cStracker-user    }
26143d2073cStracker-user
26243d2073cStracker-user    // -----------------------------------------------------------------------
26343d2073cStracker-user    // Text anchoring (re-anchoring)
26443d2073cStracker-user    // -----------------------------------------------------------------------
26543d2073cStracker-user
26643d2073cStracker-user    /**
267da56206cStracker-user     * Locate an anchor's quoted text within the normalised page text.
26843d2073cStracker-user     *
26943d2073cStracker-user     * Algorithm:
270da56206cStracker-user     *   1. Search for the exact quote (normalised).
271da56206cStracker-user     *   2. If found multiple times, use prefix/suffix to disambiguate.
272da56206cStracker-user     *   3. If still ambiguous, use the start offset hint.
27343d2073cStracker-user     *
274da56206cStracker-user     * Returns offsets into the normalised string; buildRange maps them back
275da56206cStracker-user     * to a DOM Range via the normalised→raw index map.
276da56206cStracker-user     *
277da56206cStracker-user     * @param {string} norm    normalised page text (from normalizeWithMap)
27843d2073cStracker-user     * @param {object} anchor  {exact, prefix, suffix, start}
279da56206cStracker-user     * @returns {{pos:number, len:number}|null}
28043d2073cStracker-user     */
281da56206cStracker-user    function locate(norm, anchor) {
28243d2073cStracker-user        if (!anchor || !anchor.exact) return null;
28343d2073cStracker-user
28443d2073cStracker-user        var exact = normalizeWS(anchor.exact);
285da56206cStracker-user        if (exact === '') return null;
28643d2073cStracker-user        var prefix = normalizeWS(anchor.prefix || '');
28743d2073cStracker-user        var suffix = normalizeWS(anchor.suffix || '');
28843d2073cStracker-user        var hint   = anchor.start || 0;
28943d2073cStracker-user
29043d2073cStracker-user        // Find all occurrences of exact.
29143d2073cStracker-user        var positions = [];
292da56206cStracker-user        var from = 0;
29343d2073cStracker-user        var idx;
294da56206cStracker-user        while ((idx = norm.indexOf(exact, from)) !== -1) {
295da56206cStracker-user            positions.push(idx);
296da56206cStracker-user            from = idx + exact.length;
29743d2073cStracker-user        }
29843d2073cStracker-user
29943d2073cStracker-user        if (positions.length === 0) return null;
30043d2073cStracker-user
301da56206cStracker-user        var chosen = positions[0];
30243d2073cStracker-user
30343d2073cStracker-user        if (positions.length > 1) {
304da56206cStracker-user            // Disambiguate using prefix + suffix context, tie-break on the hint.
30543d2073cStracker-user            var bestScore = -1;
30643d2073cStracker-user            positions.forEach(function (pos) {
307da56206cStracker-user                var pre = norm.slice(Math.max(0, pos - prefix.length), pos);
308da56206cStracker-user                var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length);
30943d2073cStracker-user                var score = 0;
31043d2073cStracker-user                if (prefix && pre.indexOf(prefix) !== -1) score++;
31143d2073cStracker-user                if (suffix && suf.indexOf(suffix) !== -1) score++;
31243d2073cStracker-user                var distToHint = Math.abs(pos - hint);
313da56206cStracker-user                if (score > bestScore ||
314da56206cStracker-user                    (score === bestScore && distToHint < Math.abs(chosen - hint))) {
31543d2073cStracker-user                    bestScore = score;
316da56206cStracker-user                    chosen    = pos;
31743d2073cStracker-user                }
31843d2073cStracker-user            });
31943d2073cStracker-user        }
32043d2073cStracker-user
321da56206cStracker-user        return {pos: chosen, len: exact.length};
32243d2073cStracker-user    }
32343d2073cStracker-user
32443d2073cStracker-user    /**
32543d2073cStracker-user     * Walk the text nodes under root and return an array of
32643d2073cStracker-user     * {node, start, text} objects where start is the cumulative character
32743d2073cStracker-user     * offset of this node's text in the joined string.
32843d2073cStracker-user     *
32943d2073cStracker-user     * The joined string is NOT normalised here — we normalise the full string
33043d2073cStracker-user     * once above instead.
33143d2073cStracker-user     *
33243d2073cStracker-user     * @param {HTMLElement} root
33343d2073cStracker-user     * @returns {Array<{node:Text, start:number, text:string}>}
33443d2073cStracker-user     */
33543d2073cStracker-user    function collectTextChunks(root) {
33643d2073cStracker-user        var walker = document.createTreeWalker(
33743d2073cStracker-user            root,
33843d2073cStracker-user            NodeFilter.SHOW_TEXT,
33943d2073cStracker-user            null,
34043d2073cStracker-user            false
34143d2073cStracker-user        );
34243d2073cStracker-user        var chunks = [];
34343d2073cStracker-user        var offset = 0;
34443d2073cStracker-user        var node;
34543d2073cStracker-user        while ((node = walker.nextNode())) {
34643d2073cStracker-user            // Skip nodes inside our own UI elements.
34743d2073cStracker-user            if (isAnnotationUI(node.parentNode)) continue;
34843d2073cStracker-user            var text = node.nodeValue || '';
34943d2073cStracker-user            chunks.push({node: node, start: offset, text: text});
35043d2073cStracker-user            offset += text.length;
35143d2073cStracker-user        }
35243d2073cStracker-user        return chunks;
35343d2073cStracker-user    }
35443d2073cStracker-user
35543d2073cStracker-user    /**
35686c7806dStracker-user     * The first existing highlight span the given range overlaps, or null.
35786c7806dStracker-user     * Used to redirect a selection that touches an annotation into opening it,
35886c7806dStracker-user     * rather than offering to create a new (overlapping) one. intersectsNode is
35986c7806dStracker-user     * supported in Firefox 78 ESR.
36086c7806dStracker-user     *
36186c7806dStracker-user     * @param {Range} range
36286c7806dStracker-user     * @returns {HTMLElement|null}
36386c7806dStracker-user     */
36486c7806dStracker-user    function selectionHitsHighlight(range) {
36586c7806dStracker-user        var spans = document.querySelectorAll(
36686c7806dStracker-user            '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED
36786c7806dStracker-user        );
36886c7806dStracker-user        for (var i = 0; i < spans.length; i++) {
36986c7806dStracker-user            if (range.intersectsNode(spans[i])) {
37086c7806dStracker-user                return spans[i];
37186c7806dStracker-user            }
37286c7806dStracker-user        }
37386c7806dStracker-user        return null;
37486c7806dStracker-user    }
37586c7806dStracker-user
37686c7806dStracker-user    /**
37786c7806dStracker-user     * True if the node sits in a region that must never receive a new
37886c7806dStracker-user     * annotation: our own annotation UI (panels, counter bar, tooltip,
37986c7806dStracker-user     * highlights), the table of contents (#dw__toc), the page-info line
38086c7806dStracker-user     * (.docInfo), or a section-edit button (.secedit). These all live inside
38186c7806dStracker-user     * #dokuwiki__content, so plain containment is not enough to gate selection.
382563f3b4cStracker-user     *
383563f3b4cStracker-user     * @param {Node} node
384563f3b4cStracker-user     * @returns {bool}
385563f3b4cStracker-user     */
38686c7806dStracker-user    function isInExcludedRegion(node) {
387563f3b4cStracker-user        var el = (node && node.nodeType === 1) ? node : (node ? node.parentNode : null);
388563f3b4cStracker-user        while (el && el !== document.body) {
38986c7806dStracker-user            if (el.nodeType === 1) {
39086c7806dStracker-user                var cls = el.className;
39186c7806dStracker-user                if (typeof cls === 'string') {
39286c7806dStracker-user                    if (cls.indexOf('ann-') !== -1 ||  // our own UI + highlights
39386c7806dStracker-user                        cls.indexOf('docInfo') !== -1 ||
39486c7806dStracker-user                        cls.indexOf('secedit') !== -1) {
395563f3b4cStracker-user                        return true;
396563f3b4cStracker-user                    }
39786c7806dStracker-user                }
39886c7806dStracker-user                if (el.id === 'dw__toc') return true;
39986c7806dStracker-user            }
400563f3b4cStracker-user            el = el.parentNode;
401563f3b4cStracker-user        }
402563f3b4cStracker-user        return false;
403563f3b4cStracker-user    }
404563f3b4cStracker-user
405563f3b4cStracker-user    /**
40643d2073cStracker-user     * True if the element (or its ancestor) is part of our annotation UI.
40743d2073cStracker-user     *
40843d2073cStracker-user     * @param {Node} el
40943d2073cStracker-user     * @returns {bool}
41043d2073cStracker-user     */
41143d2073cStracker-user    function isAnnotationUI(el) {
41243d2073cStracker-user        while (el && el !== document.body) {
41343d2073cStracker-user            if (el.nodeType === 1) {
41443d2073cStracker-user                var cls = el.className || '';
41543d2073cStracker-user                if (
41643d2073cStracker-user                    cls.indexOf('ann-') !== -1 ||
41743d2073cStracker-user                    cls.indexOf(CLS_PANEL) !== -1
41843d2073cStracker-user                ) {
41943d2073cStracker-user                    return true;
42043d2073cStracker-user                }
42143d2073cStracker-user            }
42243d2073cStracker-user            el = el.parentNode;
42343d2073cStracker-user        }
42443d2073cStracker-user        return false;
42543d2073cStracker-user    }
42643d2073cStracker-user
42743d2073cStracker-user    /**
428da56206cStracker-user     * Turn a (start, length) offset in the normalised page text back into a
429da56206cStracker-user     * DOM Range, using the normalised→raw index map.
43043d2073cStracker-user     *
43143d2073cStracker-user     * @param {Array<{node:Text, start:number, text:string}>} chunks
432da56206cStracker-user     * @param {Array<number>} map       normalised index → raw index (normalizeWithMap)
433da56206cStracker-user     * @param {number}        startOff  start offset in the normalised text
434da56206cStracker-user     * @param {number}        length    length in normalised characters
43543d2073cStracker-user     * @returns {Range|null}
43643d2073cStracker-user     */
437da56206cStracker-user    function buildRange(chunks, map, startOff, length) {
438da56206cStracker-user        var rawStart = map[startOff];
439da56206cStracker-user        var rawEnd   = map[startOff + length - 1];
44043d2073cStracker-user        if (rawStart === undefined || rawEnd === undefined) return null;
44143d2073cStracker-user        rawEnd++; // exclusive
44243d2073cStracker-user
44343d2073cStracker-user        // Find which chunks contain rawStart and rawEnd.
44443d2073cStracker-user        var startChunk = null, startOffset = 0;
44543d2073cStracker-user        var endChunk   = null, endOffset   = 0;
44643d2073cStracker-user
44743d2073cStracker-user        for (var i = 0; i < chunks.length; i++) {
44843d2073cStracker-user            var c = chunks[i];
44943d2073cStracker-user            var cEnd = c.start + c.text.length;
45043d2073cStracker-user
45143d2073cStracker-user            if (startChunk === null && c.start <= rawStart && rawStart < cEnd) {
45243d2073cStracker-user                startChunk  = c.node;
45343d2073cStracker-user                startOffset = rawStart - c.start;
45443d2073cStracker-user            }
45543d2073cStracker-user            if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) {
45643d2073cStracker-user                endChunk  = c.node;
45743d2073cStracker-user                endOffset = rawEnd - c.start;
45843d2073cStracker-user            }
45943d2073cStracker-user            if (startChunk && endChunk) break;
46043d2073cStracker-user        }
46143d2073cStracker-user
46243d2073cStracker-user        if (!startChunk || !endChunk) return null;
46343d2073cStracker-user
46443d2073cStracker-user        try {
46543d2073cStracker-user            var range = document.createRange();
46643d2073cStracker-user            range.setStart(startChunk, startOffset);
46743d2073cStracker-user            range.setEnd(endChunk, endOffset);
46843d2073cStracker-user            return range;
46943d2073cStracker-user        } catch (e) {
47043d2073cStracker-user            return null;
47143d2073cStracker-user        }
47243d2073cStracker-user    }
47343d2073cStracker-user
47443d2073cStracker-user    /**
475da56206cStracker-user     * Normalise raw text exactly as normalizeWS does (collapse each whitespace
476da56206cStracker-user     * run to a single space, trim both ends) while recording, for every
477da56206cStracker-user     * character of the normalised string, the index of the raw character it
478da56206cStracker-user     * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i)
479da56206cStracker-user     * (a collapsed internal space maps to the first char of its run).
480da56206cStracker-user     *
481da56206cStracker-user     * Normalisation and the index map MUST stay in lockstep: an earlier
482da56206cStracker-user     * version built the map without trimming, so a leading whitespace text
483da56206cStracker-user     * node (DokuWiki indents its content markup, so there always is one)
484da56206cStracker-user     * shifted every highlight one character to the left.
48543d2073cStracker-user     *
48643d2073cStracker-user     * @param {string} raw
487da56206cStracker-user     * @returns {{norm:string, map:Array<number>}}
48843d2073cStracker-user     */
489da56206cStracker-user    function normalizeWithMap(raw) {
490da56206cStracker-user        var norm     = '';
49143d2073cStracker-user        var map      = [];
492da56206cStracker-user        var inRun    = false;
493da56206cStracker-user        var runStart = 0;
49443d2073cStracker-user        for (var i = 0; i < raw.length; i++) {
495da56206cStracker-user            if (/\s/.test(raw[i])) {
496da56206cStracker-user                if (!inRun) { inRun = true; runStart = i; }
497da56206cStracker-user                continue;
49843d2073cStracker-user            }
499da56206cStracker-user            if (inRun) {
500da56206cStracker-user                inRun = false;
501da56206cStracker-user                // internal run → one representative space; leading run → dropped
502da56206cStracker-user                if (norm.length > 0) {
503da56206cStracker-user                    norm += ' ';
504da56206cStracker-user                    map.push(runStart);
505da56206cStracker-user                }
506da56206cStracker-user            }
507da56206cStracker-user            norm += raw[i];
50843d2073cStracker-user            map.push(i);
50943d2073cStracker-user        }
510da56206cStracker-user        // a trailing whitespace run is dropped (matches trim)
511da56206cStracker-user        return {norm: norm, map: map};
51243d2073cStracker-user    }
51343d2073cStracker-user
51443d2073cStracker-user    // -----------------------------------------------------------------------
51543d2073cStracker-user    // Highlights
51643d2073cStracker-user    // -----------------------------------------------------------------------
51743d2073cStracker-user
51843d2073cStracker-user    /**
51943d2073cStracker-user     * Wrap a Range in a highlight <span> for the given annotation.
52043d2073cStracker-user     *
52143d2073cStracker-user     * @param {Range}  range
52243d2073cStracker-user     * @param {object} ann
52343d2073cStracker-user     */
52443d2073cStracker-user    function wrapHighlight(range, ann) {
525563f3b4cStracker-user        var preview = ann.body || '';
52643d2073cStracker-user        var span = document.createElement('span');
52743d2073cStracker-user        span.className = ann.status === 'resolved'
52843d2073cStracker-user            ? CLS_HIGHLIGHT_RESOLVED
52943d2073cStracker-user            : CLS_HIGHLIGHT_OPEN;
53043d2073cStracker-user        span.dataset.annId = ann.id;
531563f3b4cStracker-user        span.title = preview.slice(0, 80) + (preview.length > 80 ? '…' : '');
53243d2073cStracker-user        span.addEventListener('click', function (e) {
53343d2073cStracker-user            e.stopPropagation();
53443d2073cStracker-user            openPanel(ann.id);
53543d2073cStracker-user        });
536563f3b4cStracker-user
537563f3b4cStracker-user        try {
53843d2073cStracker-user            range.surroundContents(span);
53943d2073cStracker-user            ann._highlightEl = span;
54043d2073cStracker-user        } catch (e) {
541563f3b4cStracker-user            // surroundContents throws if the range crosses element boundaries;
542563f3b4cStracker-user            // fall back to extract + insert, reusing the same (still-empty) span.
54343d2073cStracker-user            try {
544563f3b4cStracker-user                span.appendChild(range.extractContents());
545563f3b4cStracker-user                range.insertNode(span);
546563f3b4cStracker-user                ann._highlightEl = span;
54743d2073cStracker-user            } catch (e2) {
54843d2073cStracker-user                ann._highlightEl = null;
54943d2073cStracker-user            }
55043d2073cStracker-user        }
55143d2073cStracker-user    }
55243d2073cStracker-user
55343d2073cStracker-user    /**
55443d2073cStracker-user     * Remove all highlight spans, restoring the original text nodes.
55543d2073cStracker-user     */
55643d2073cStracker-user    function clearHighlights() {
55743d2073cStracker-user        var spans = document.querySelectorAll(
558da56206cStracker-user            '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED
55943d2073cStracker-user        );
56043d2073cStracker-user        Array.prototype.forEach.call(spans, function (span) {
56143d2073cStracker-user            var parent = span.parentNode;
56243d2073cStracker-user            if (!parent) return;
56343d2073cStracker-user            while (span.firstChild) {
56443d2073cStracker-user                parent.insertBefore(span.firstChild, span);
56543d2073cStracker-user            }
56643d2073cStracker-user            parent.removeChild(span);
56743d2073cStracker-user            parent.normalize();
56843d2073cStracker-user        });
56943d2073cStracker-user    }
57043d2073cStracker-user
57143d2073cStracker-user    // -----------------------------------------------------------------------
57243d2073cStracker-user    // Gutter markers
57343d2073cStracker-user    // -----------------------------------------------------------------------
57443d2073cStracker-user
57543d2073cStracker-user    /**
576563f3b4cStracker-user     * Render a small marker for every anchored annotation. Markers are
577563f3b4cStracker-user     * appended to document.body as absolutely-positioned elements so that
578563f3b4cStracker-user     * template overflow rules on inner containers cannot clip them.
579563f3b4cStracker-user     *
580563f3b4cStracker-user     * All markers share the same X position — just to the left of the .page
581563f3b4cStracker-user     * content column — so they form a tidy vertical column in the margin.
58243d2073cStracker-user     */
58343d2073cStracker-user    function renderGutterMarkers() {
584563f3b4cStracker-user        var scrollTop  = window.pageYOffset || document.documentElement.scrollTop;
585563f3b4cStracker-user        var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
586563f3b4cStracker-user        var markerLeft = gutterMarkerLeft(scrollLeft);
587563f3b4cStracker-user
588563f3b4cStracker-user        // Speech bubble SVG — clearly communicates "annotation here".
589563f3b4cStracker-user        var ICON_SVG =
590563f3b4cStracker-user            '<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" aria-hidden="true">' +
591563f3b4cStracker-user            '<rect x="1" y="1" width="14" height="10" rx="2"/>' +
592563f3b4cStracker-user            '<path d="M4 14 L4 11 L8 11 Z"/>' +
593563f3b4cStracker-user            '</svg>';
59443d2073cStracker-user
59543d2073cStracker-user        _annotations.forEach(function (ann) {
59643d2073cStracker-user            if (!ann._highlightEl) return; // orphan
59743d2073cStracker-user
598563f3b4cStracker-user            var rect = ann._highlightEl.getBoundingClientRect();
59943d2073cStracker-user
60043d2073cStracker-user            var marker = document.createElement('button');
60143d2073cStracker-user            marker.className      = CLS_GUTTER_MARKER;
60243d2073cStracker-user            marker.dataset.annId  = ann.id;
603563f3b4cStracker-user            marker.dataset.status = ann.status || 'open'; // drives CSS amber/green colour
604da56206cStracker-user            marker.setAttribute('aria-label', t('label_annotation', 'Annotation'));
60543d2073cStracker-user            marker.type      = 'button';
606563f3b4cStracker-user            marker.innerHTML = ICON_SVG;
607563f3b4cStracker-user            // Align vertically with the first line of the highlight.
608563f3b4cStracker-user            marker.style.top  = (rect.top + scrollTop + 3) + 'px';
609563f3b4cStracker-user            marker.style.left = markerLeft + 'px';
61043d2073cStracker-user            marker.addEventListener('click', function (e) {
61143d2073cStracker-user                e.stopPropagation();
61243d2073cStracker-user                openPanel(ann.id);
61343d2073cStracker-user            });
614563f3b4cStracker-user            document.body.appendChild(marker);
61543d2073cStracker-user            ann._markerEl = marker;
61643d2073cStracker-user        });
61743d2073cStracker-user    }
61843d2073cStracker-user
61943d2073cStracker-user    /**
62043d2073cStracker-user     * Remove all gutter markers.
62143d2073cStracker-user     */
62243d2073cStracker-user    function clearGutterMarkers() {
62343d2073cStracker-user        var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER);
62443d2073cStracker-user        Array.prototype.forEach.call(markers, function (m) {
62543d2073cStracker-user            if (m.parentNode) m.parentNode.removeChild(m);
62643d2073cStracker-user        });
62743d2073cStracker-user    }
62843d2073cStracker-user
629563f3b4cStracker-user    /**
630563f3b4cStracker-user     * The shared X position (document coordinates) for every gutter marker:
631563f3b4cStracker-user     * just inside the left padding of the .page content column, so the markers
632563f3b4cStracker-user     * form a tidy vertical strip in the margin. Falls back to 4px when the
633563f3b4cStracker-user     * column cannot be measured. Reads the theme's computed padding so it
634563f3b4cStracker-user     * adapts to the template.
635563f3b4cStracker-user     *
636563f3b4cStracker-user     * @param {number} scrollLeft current horizontal scroll offset
637563f3b4cStracker-user     * @returns {number}
638563f3b4cStracker-user     */
639563f3b4cStracker-user    function gutterMarkerLeft(scrollLeft) {
640563f3b4cStracker-user        var pageEl = document.querySelector('.' + PAGE_CLS) || document.getElementById(CONTENT_ID);
641563f3b4cStracker-user        if (!pageEl) return 4;
642563f3b4cStracker-user        var pageRect = pageEl.getBoundingClientRect();
643563f3b4cStracker-user        var padLeft  = parseInt(window.getComputedStyle(pageEl).paddingLeft, 10) || 32;
644563f3b4cStracker-user        return pageRect.left + scrollLeft + Math.max(2, Math.floor(padLeft * 0.25));
645563f3b4cStracker-user    }
646563f3b4cStracker-user
647563f3b4cStracker-user    /**
648563f3b4cStracker-user     * Re-align every existing marker with its highlight without rebuilding the
649563f3b4cStracker-user     * DOM. Highlights shift when a panel is inserted/removed or the window is
650563f3b4cStracker-user     * resized, but markers live in document.body at absolute coordinates, so
651563f3b4cStracker-user     * they would otherwise drift out of line. Cheap — only touches inline
652563f3b4cStracker-user     * top/left on the handful of markers present.
653563f3b4cStracker-user     */
654563f3b4cStracker-user    function repositionMarkers() {
655563f3b4cStracker-user        var scrollTop  = window.pageYOffset || document.documentElement.scrollTop;
656563f3b4cStracker-user        var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
657563f3b4cStracker-user        var markerLeft = gutterMarkerLeft(scrollLeft);
658563f3b4cStracker-user        _annotations.forEach(function (ann) {
659563f3b4cStracker-user            if (!ann._markerEl || !ann._highlightEl) return;
660563f3b4cStracker-user            var rect = ann._highlightEl.getBoundingClientRect();
661563f3b4cStracker-user            ann._markerEl.style.top  = (rect.top + scrollTop + 3) + 'px';
662563f3b4cStracker-user            ann._markerEl.style.left = markerLeft + 'px';
663563f3b4cStracker-user        });
664563f3b4cStracker-user    }
665563f3b4cStracker-user
66643d2073cStracker-user    // -----------------------------------------------------------------------
66743d2073cStracker-user    // Page counter
66843d2073cStracker-user    // -----------------------------------------------------------------------
66943d2073cStracker-user
67043d2073cStracker-user    /**
67143d2073cStracker-user     * Render (or update) the counter bubble above the content area.
67243d2073cStracker-user     *
67343d2073cStracker-user     * @param {object} stats        {total, open, resolved}
67443d2073cStracker-user     * @param {number} orphanCount
67543d2073cStracker-user     */
67643d2073cStracker-user    function renderCounter(stats, orphanCount) {
67743d2073cStracker-user        var existing = document.getElementById('ann-counter-bar');
67843d2073cStracker-user        if (existing) existing.parentNode.removeChild(existing);
67943d2073cStracker-user
68043d2073cStracker-user        if (stats.total === 0 && orphanCount === 0) return;
68143d2073cStracker-user
68243d2073cStracker-user        var bar = document.createElement('div');
68343d2073cStracker-user        bar.id = 'ann-counter-bar';
68443d2073cStracker-user        bar.className = CLS_COUNTER;
68543d2073cStracker-user
68643d2073cStracker-user        var total = stats.total || 0;
68743d2073cStracker-user        var label = total === 1
688da56206cStracker-user            ? t('counter_annotation', '1 annotation')
689da56206cStracker-user            : fmt(t('counter_annotations', '%d annotations'), total);
69043d2073cStracker-user        bar.appendChild(document.createTextNode(label));
69143d2073cStracker-user
69243d2073cStracker-user        if (orphanCount > 0) {
69343d2073cStracker-user            bar.appendChild(document.createTextNode(' · '));
69443d2073cStracker-user            var orphanLink = document.createElement('a');
69543d2073cStracker-user            orphanLink.href = '#ann-orphan-drawer';
69643d2073cStracker-user            orphanLink.className = 'ann-orphan-link';
697da56206cStracker-user            orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount);
69843d2073cStracker-user            orphanLink.addEventListener('click', function (e) {
69943d2073cStracker-user                e.preventDefault();
70043d2073cStracker-user                toggleOrphanDrawer();
701563f3b4cStracker-user                repositionMarkers();
70243d2073cStracker-user            });
70343d2073cStracker-user            bar.appendChild(orphanLink);
70443d2073cStracker-user        }
70543d2073cStracker-user
70643d2073cStracker-user        if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) {
70743d2073cStracker-user            if (stats.resolved > 0) {
70843d2073cStracker-user                var btnCR = document.createElement('button');
70943d2073cStracker-user                btnCR.type = 'button';
71043d2073cStracker-user                btnCR.className = 'ann-btn ann-btn-admin';
711da56206cStracker-user                btnCR.textContent = t('btn_clear_resolved', 'Clear resolved');
712d6f8bd9dStracker-user                btnCR.addEventListener('click', function () { doClearResolved(btnCR); });
71343d2073cStracker-user                bar.appendChild(btnCR);
71443d2073cStracker-user            }
71543d2073cStracker-user            if (orphanCount > 0) {
71643d2073cStracker-user                var btnCO = document.createElement('button');
71743d2073cStracker-user                btnCO.type = 'button';
71843d2073cStracker-user                btnCO.className = 'ann-btn ann-btn-admin';
719da56206cStracker-user                btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned');
720d6f8bd9dStracker-user                btnCO.addEventListener('click', function () { doClearOrphaned(btnCO); });
72143d2073cStracker-user                bar.appendChild(btnCO);
72243d2073cStracker-user            }
72343d2073cStracker-user        }
72443d2073cStracker-user
725563f3b4cStracker-user        // Insert inside .page, right after #dw__toc if present.
726563f3b4cStracker-user        // The TOC is float:right so placing the bar after it (not before) lets
727563f3b4cStracker-user        // it sit to the left of the float instead of pushing the TOC down.
728563f3b4cStracker-user        var pageEl = document.querySelector('.' + PAGE_CLS);
729563f3b4cStracker-user        if (pageEl) {
730563f3b4cStracker-user            var toc = pageEl.querySelector('#dw__toc');
731563f3b4cStracker-user            if (toc && toc.nextSibling) {
732563f3b4cStracker-user                pageEl.insertBefore(bar, toc.nextSibling);
733563f3b4cStracker-user            } else if (toc) {
734563f3b4cStracker-user                pageEl.appendChild(bar);
735563f3b4cStracker-user            } else {
736563f3b4cStracker-user                pageEl.insertBefore(bar, pageEl.firstChild);
737563f3b4cStracker-user            }
738563f3b4cStracker-user        } else {
73943d2073cStracker-user            var content = document.getElementById(CONTENT_ID);
740563f3b4cStracker-user            if (content) content.insertBefore(bar, content.firstChild);
74143d2073cStracker-user        }
74243d2073cStracker-user    }
74343d2073cStracker-user
74443d2073cStracker-user    /**
74543d2073cStracker-user     * Recount and re-render the counter from in-memory state.
74643d2073cStracker-user     */
74743d2073cStracker-user    function updateCounter(orphanCount) {
74843d2073cStracker-user        var open = 0, resolved = 0;
74943d2073cStracker-user        if (orphanCount === undefined) {
75043d2073cStracker-user            orphanCount = 0;
75143d2073cStracker-user        }
75243d2073cStracker-user        _annotations.forEach(function (ann) {
75343d2073cStracker-user            if (ann._orphaned) {
75443d2073cStracker-user                orphanCount++;
75543d2073cStracker-user            } else if (ann.status === 'resolved') {
75643d2073cStracker-user                resolved++;
75743d2073cStracker-user            } else {
75843d2073cStracker-user                open++;
75943d2073cStracker-user            }
76043d2073cStracker-user        });
76143d2073cStracker-user        renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount);
76243d2073cStracker-user    }
76343d2073cStracker-user
76443d2073cStracker-user    // -----------------------------------------------------------------------
76543d2073cStracker-user    // Annotation panel
76643d2073cStracker-user    // -----------------------------------------------------------------------
76743d2073cStracker-user
76843d2073cStracker-user    /**
76943d2073cStracker-user     * Open the thread panel for the given annotation id.
77043d2073cStracker-user     * If that panel is already open, close it.
77143d2073cStracker-user     *
77243d2073cStracker-user     * @param {string}  annId
773563f3b4cStracker-user     * @param {boolean} [focusReply]  focus the reply box once open (default true);
774563f3b4cStracker-user     *                                reopenPanel passes false so re-rendering after
775563f3b4cStracker-user     *                                an action doesn't yank the viewport to the form.
77643d2073cStracker-user     */
777563f3b4cStracker-user    function openPanel(annId, focusReply) {
77843d2073cStracker-user        if (_openAnnId === annId) {
77943d2073cStracker-user            closePanel();
78043d2073cStracker-user            return;
78143d2073cStracker-user        }
78243d2073cStracker-user        closePanel();
78343d2073cStracker-user
78443d2073cStracker-user        var ann = _annotations.get(annId);
78543d2073cStracker-user        if (!ann) return;
78643d2073cStracker-user
78743d2073cStracker-user        var panel = buildPanel(ann);
78843d2073cStracker-user        _openPanel  = panel;
78943d2073cStracker-user        _openAnnId  = annId;
79043d2073cStracker-user
79143d2073cStracker-user        // Insert below the paragraph that contains the highlight.
79243d2073cStracker-user        var anchor = ann._highlightEl || null;
79343d2073cStracker-user        var insertAfter = findParagraph(anchor);
79443d2073cStracker-user        if (insertAfter && insertAfter.parentNode) {
79543d2073cStracker-user            insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling);
79643d2073cStracker-user        } else {
79743d2073cStracker-user            // Orphan or no paragraph found: show at the bottom of content.
79843d2073cStracker-user            var content = document.getElementById(CONTENT_ID);
79943d2073cStracker-user            if (content) content.appendChild(panel);
80043d2073cStracker-user        }
80143d2073cStracker-user
802563f3b4cStracker-user        if (focusReply !== false) {
803563f3b4cStracker-user            var input = panel.querySelector('.ann-body-input');
804563f3b4cStracker-user            if (input) input.focus();
805563f3b4cStracker-user        }
806563f3b4cStracker-user
807563f3b4cStracker-user        // The panel grew the document; nudge markers below it back into line.
808563f3b4cStracker-user        repositionMarkers();
80943d2073cStracker-user    }
81043d2073cStracker-user
81143d2073cStracker-user    /**
81243d2073cStracker-user     * Close and remove the currently open panel.
81343d2073cStracker-user     */
81443d2073cStracker-user    function closePanel() {
81543d2073cStracker-user        if (_openPanel && _openPanel.parentNode) {
81643d2073cStracker-user            _openPanel.parentNode.removeChild(_openPanel);
81743d2073cStracker-user        }
81843d2073cStracker-user        _openPanel = null;
81943d2073cStracker-user        _openAnnId = null;
820563f3b4cStracker-user        repositionMarkers();
82143d2073cStracker-user    }
82243d2073cStracker-user
82343d2073cStracker-user    /**
82443d2073cStracker-user     * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.)
82543d2073cStracker-user     * that can receive a sibling element.
82643d2073cStracker-user     *
82743d2073cStracker-user     * @param {HTMLElement|null} el
82843d2073cStracker-user     * @returns {HTMLElement|null}
82943d2073cStracker-user     */
83043d2073cStracker-user    function findParagraph(el) {
83143d2073cStracker-user        var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/;
83243d2073cStracker-user        var node = el;
83343d2073cStracker-user        while (node && node.id !== CONTENT_ID) {
83443d2073cStracker-user            if (node.nodeType === 1 && block.test(node.tagName)) {
83543d2073cStracker-user                return node;
83643d2073cStracker-user            }
83743d2073cStracker-user            node = node.parentNode;
83843d2073cStracker-user        }
83943d2073cStracker-user        return el; // fallback: use the element itself
84043d2073cStracker-user    }
84143d2073cStracker-user
84243d2073cStracker-user    /**
84343d2073cStracker-user     * Build and return the panel DOM element for one annotation.
84443d2073cStracker-user     *
84543d2073cStracker-user     * @param {object} ann
84643d2073cStracker-user     * @returns {HTMLElement}
84743d2073cStracker-user     */
84843d2073cStracker-user    function buildPanel(ann) {
84943d2073cStracker-user        var panel = document.createElement('div');
85043d2073cStracker-user        panel.className = CLS_PANEL;
85143d2073cStracker-user        panel.dataset.annId  = ann.id;
852da56206cStracker-user        panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css
85343d2073cStracker-user
854563f3b4cStracker-user        // Main annotation thread entry (close button lives in its meta row).
855563f3b4cStracker-user        var rootEntry = buildThreadEntry(ann, true);
856563f3b4cStracker-user        var meta = rootEntry.querySelector('.ann-meta');
857563f3b4cStracker-user        if (meta) {
85843d2073cStracker-user            var closeBtn = document.createElement('button');
85943d2073cStracker-user            closeBtn.type = 'button';
86043d2073cStracker-user            closeBtn.className = 'ann-btn ann-close';
861da56206cStracker-user            closeBtn.setAttribute('aria-label', t('label_close', 'Close'));
862563f3b4cStracker-user            closeBtn.textContent = '×'; // ×
863563f3b4cStracker-user            closeBtn.style.marginLeft = 'auto';
86443d2073cStracker-user            closeBtn.addEventListener('click', closePanel);
865563f3b4cStracker-user            meta.appendChild(closeBtn);
866563f3b4cStracker-user        }
867563f3b4cStracker-user        panel.appendChild(rootEntry);
86843d2073cStracker-user
869563f3b4cStracker-user        // Replies: build hierarchy from flat list and render depth-indented.
870563f3b4cStracker-user        appendReplyTree(panel, ann, buildReplyTree(ann.replies || []), 0);
87143d2073cStracker-user
872563f3b4cStracker-user        // Reply form at the bottom for root-level replies.
87343d2073cStracker-user        if (_loggedIn) {
87443d2073cStracker-user            panel.appendChild(buildReplyForm(ann));
87543d2073cStracker-user        }
87643d2073cStracker-user
87743d2073cStracker-user        return panel;
87843d2073cStracker-user    }
87943d2073cStracker-user
88043d2073cStracker-user    /**
88143d2073cStracker-user     * Build the DOM for the top-level annotation entry.
88243d2073cStracker-user     *
88343d2073cStracker-user     * @param {object}  ann
88443d2073cStracker-user     * @param {boolean} isRoot  true for the annotation itself, false for replies
88543d2073cStracker-user     * @returns {HTMLElement}
88643d2073cStracker-user     */
88743d2073cStracker-user    function buildThreadEntry(ann, isRoot) {
88843d2073cStracker-user        var entry = document.createElement('div');
88943d2073cStracker-user        entry.className = 'ann-thread-entry ann-annotation';
89043d2073cStracker-user        entry.dataset.annId = ann.id;
89143d2073cStracker-user
89243d2073cStracker-user        // Meta row: avatar, author, time, status pill
89343d2073cStracker-user        entry.appendChild(buildMeta(ann.author, ann.created, ann.status));
89443d2073cStracker-user
89543d2073cStracker-user        // Body
89643d2073cStracker-user        var bodyEl = document.createElement('div');
89743d2073cStracker-user        bodyEl.className = 'ann-body';
89843d2073cStracker-user        bodyEl.textContent = ann.body;
89943d2073cStracker-user        entry.appendChild(bodyEl);
90043d2073cStracker-user
90143d2073cStracker-user        // Quoted text snippet
90243d2073cStracker-user        if (ann.anchor && ann.anchor.exact) {
90343d2073cStracker-user            var quote = document.createElement('blockquote');
90443d2073cStracker-user            quote.className = 'ann-quote';
90543d2073cStracker-user            quote.textContent = ann.anchor.exact;
90643d2073cStracker-user            entry.appendChild(quote);
90743d2073cStracker-user        }
90843d2073cStracker-user
90943d2073cStracker-user        // Action buttons
91043d2073cStracker-user        var actions = document.createElement('div');
91143d2073cStracker-user        actions.className = 'ann-actions';
91243d2073cStracker-user
91372d60f2dStracker-user        // An orphaned annotation is read-only: its quoted text is gone from the
91472d60f2dStracker-user        // page, so resolving/reopening and editing the body no longer make sense.
91572d60f2dStracker-user        // It keeps only the Delete button (for its author or an admin), so the
91672d60f2dStracker-user        // only remaining action is to remove it.
91772d60f2dStracker-user        var isOrphan = !!ann._orphaned;
91872d60f2dStracker-user
91972d60f2dStracker-user        // Resolve/Reopen (any reader) — not for orphans.
92072d60f2dStracker-user        if (_loggedIn && !isOrphan) {
92143d2073cStracker-user            var resolveBtn = document.createElement('button');
92243d2073cStracker-user            resolveBtn.type = 'button';
923563f3b4cStracker-user            resolveBtn.className = 'ann-btn ann-btn-primary';
924da56206cStracker-user            resolveBtn.textContent = ann.status === 'resolved'
925da56206cStracker-user                ? t('btn_reopen', 'Reopen')
926da56206cStracker-user                : t('btn_resolve', 'Resolve');
92743d2073cStracker-user            resolveBtn.addEventListener('click', function () {
928563f3b4cStracker-user                doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved', resolveBtn);
92943d2073cStracker-user            });
93043d2073cStracker-user            actions.appendChild(resolveBtn);
93143d2073cStracker-user        }
93243d2073cStracker-user
93372d60f2dStracker-user        // Edit (own or admin) — not for orphans; Delete stays available.
93443d2073cStracker-user        var canEdit = _isAdmin || ann.author === currentUser();
93543d2073cStracker-user        if (canEdit && _loggedIn) {
93672d60f2dStracker-user            if (!isOrphan) {
93743d2073cStracker-user                var editBtn = document.createElement('button');
93843d2073cStracker-user                editBtn.type = 'button';
93943d2073cStracker-user                editBtn.className = 'ann-btn';
940da56206cStracker-user                editBtn.textContent = t('btn_edit', 'Edit');
94143d2073cStracker-user                editBtn.addEventListener('click', function () {
94243d2073cStracker-user                    showEditForm(entry, ann, 'annotation');
94343d2073cStracker-user                });
94443d2073cStracker-user                actions.appendChild(editBtn);
94572d60f2dStracker-user            }
94643d2073cStracker-user
94743d2073cStracker-user            var delBtn = document.createElement('button');
94843d2073cStracker-user            delBtn.type = 'button';
94943d2073cStracker-user            delBtn.className = 'ann-btn ann-btn-danger';
950da56206cStracker-user            delBtn.textContent = t('btn_delete', 'Delete');
95143d2073cStracker-user            delBtn.addEventListener('click', function () {
952da56206cStracker-user                if (confirm(t('confirm_delete', 'Delete this annotation?'))) {
953563f3b4cStracker-user                    doDeleteAnnotation(ann.id, delBtn);
95443d2073cStracker-user                }
95543d2073cStracker-user            });
95643d2073cStracker-user            actions.appendChild(delBtn);
95743d2073cStracker-user        }
95843d2073cStracker-user
95943d2073cStracker-user        entry.appendChild(actions);
96043d2073cStracker-user        return entry;
96143d2073cStracker-user    }
96243d2073cStracker-user
96343d2073cStracker-user    /**
964563f3b4cStracker-user     * Build the DOM for one reply entry, indented according to its nesting depth.
96543d2073cStracker-user     *
96643d2073cStracker-user     * @param {object}  ann       parent annotation
96743d2073cStracker-user     * @param {object}  reply
968563f3b4cStracker-user     * @param {number}  depth     0 = direct reply to annotation; 1+ = nested
969*ad1073d4Stracker-user     * @param {boolean} [readOnly] omit the action buttons (reply/edit/delete);
970*ad1073d4Stracker-user     *                             used by the orphan drawer, where the thread is
971*ad1073d4Stracker-user     *                             shown for reading only
97243d2073cStracker-user     * @returns {HTMLElement}
97343d2073cStracker-user     */
974*ad1073d4Stracker-user    function buildReplyEntry(ann, reply, depth, readOnly) {
97543d2073cStracker-user        var entry = document.createElement('div');
97643d2073cStracker-user        entry.className = 'ann-thread-entry ann-reply';
97743d2073cStracker-user        entry.dataset.replyId = reply.id;
978563f3b4cStracker-user        // Indent nested replies up to 4 levels (1.5 em each).
979563f3b4cStracker-user        var indent = Math.min(depth, 4) * 1.5 + 1.5;
980563f3b4cStracker-user        if (indent > 0) {
981563f3b4cStracker-user            entry.style.marginLeft = indent + 'em';
982563f3b4cStracker-user        }
98343d2073cStracker-user
98443d2073cStracker-user        entry.appendChild(buildMeta(reply.author, reply.created, null));
98543d2073cStracker-user
98643d2073cStracker-user        var bodyEl = document.createElement('div');
98743d2073cStracker-user        bodyEl.className = 'ann-body';
98843d2073cStracker-user        bodyEl.textContent = reply.body;
98943d2073cStracker-user        entry.appendChild(bodyEl);
99043d2073cStracker-user
991*ad1073d4Stracker-user        // The orphan drawer renders the thread read-only: no reply, edit, or
992*ad1073d4Stracker-user        // delete buttons on replies, mirroring the read-only root entry there.
993*ad1073d4Stracker-user        if (readOnly) {
994*ad1073d4Stracker-user            return entry;
995*ad1073d4Stracker-user        }
996*ad1073d4Stracker-user
99743d2073cStracker-user        var actions = document.createElement('div');
99843d2073cStracker-user        actions.className = 'ann-actions';
99943d2073cStracker-user
1000563f3b4cStracker-user        // "Reply to this reply" button for logged-in users.
1001563f3b4cStracker-user        if (_loggedIn) {
1002563f3b4cStracker-user            var replyToBtn = document.createElement('button');
1003563f3b4cStracker-user            replyToBtn.type = 'button';
1004563f3b4cStracker-user            replyToBtn.className = 'ann-btn ann-btn-primary';
1005563f3b4cStracker-user            replyToBtn.textContent = t('btn_reply', 'Reply');
1006563f3b4cStracker-user            replyToBtn.addEventListener('click', function () {
1007563f3b4cStracker-user                // Toggle an inline reply form directly after this entry.
1008563f3b4cStracker-user                var next = entry.nextSibling;
1009563f3b4cStracker-user                if (next && next.classList && next.classList.contains('ann-inline-reply')) {
1010563f3b4cStracker-user                    next.parentNode.removeChild(next);
1011563f3b4cStracker-user                    return;
1012563f3b4cStracker-user                }
1013563f3b4cStracker-user                var form = buildInlineReplyForm(ann, reply.id, depth + 1);
1014563f3b4cStracker-user                entry.parentNode.insertBefore(form, entry.nextSibling);
1015563f3b4cStracker-user                var ta = form.querySelector('.ann-body-input');
1016563f3b4cStracker-user                if (ta) ta.focus();
1017563f3b4cStracker-user            });
1018563f3b4cStracker-user            actions.appendChild(replyToBtn);
1019563f3b4cStracker-user        }
1020563f3b4cStracker-user
102143d2073cStracker-user        var canEdit = _isAdmin || reply.author === currentUser();
102243d2073cStracker-user        if (canEdit && _loggedIn) {
102343d2073cStracker-user            var editBtn = document.createElement('button');
102443d2073cStracker-user            editBtn.type = 'button';
102543d2073cStracker-user            editBtn.className = 'ann-btn';
1026da56206cStracker-user            editBtn.textContent = t('btn_edit', 'Edit');
102743d2073cStracker-user            editBtn.addEventListener('click', function () {
102843d2073cStracker-user                showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply');
102943d2073cStracker-user            });
103043d2073cStracker-user            actions.appendChild(editBtn);
103143d2073cStracker-user
103243d2073cStracker-user            var delBtn = document.createElement('button');
103343d2073cStracker-user            delBtn.type = 'button';
103443d2073cStracker-user            delBtn.className = 'ann-btn ann-btn-danger';
1035da56206cStracker-user            delBtn.textContent = t('btn_delete', 'Delete');
103643d2073cStracker-user            delBtn.addEventListener('click', function () {
1037da56206cStracker-user                if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) {
1038563f3b4cStracker-user                    doDeleteReply(ann.id, reply.id, delBtn);
103943d2073cStracker-user                }
104043d2073cStracker-user            });
104143d2073cStracker-user            actions.appendChild(delBtn);
104243d2073cStracker-user        }
104343d2073cStracker-user
104443d2073cStracker-user        entry.appendChild(actions);
104543d2073cStracker-user        return entry;
104643d2073cStracker-user    }
104743d2073cStracker-user
104843d2073cStracker-user    /**
1049563f3b4cStracker-user     * Build a nested tree structure from a flat reply list. Replies without a
1050563f3b4cStracker-user     * known parentId (including legacy replies with no parentId field) are
1051563f3b4cStracker-user     * treated as root-level.
1052563f3b4cStracker-user     *
1053563f3b4cStracker-user     * @param {Array} replies  flat array of reply objects
1054563f3b4cStracker-user     * @returns {Array}        array of {reply, children} nodes
1055563f3b4cStracker-user     */
1056563f3b4cStracker-user    function buildReplyTree(replies) {
1057563f3b4cStracker-user        var map = {};
1058563f3b4cStracker-user        var roots = [];
1059563f3b4cStracker-user        replies.forEach(function (r) {
1060563f3b4cStracker-user            map[r.id] = {reply: r, children: []};
1061563f3b4cStracker-user        });
1062563f3b4cStracker-user        replies.forEach(function (r) {
1063563f3b4cStracker-user            var pid = r.parentId || '';
1064563f3b4cStracker-user            if (pid && map[pid]) {
1065563f3b4cStracker-user                map[pid].children.push(map[r.id]);
1066563f3b4cStracker-user            } else {
1067563f3b4cStracker-user                roots.push(map[r.id]);
1068563f3b4cStracker-user            }
1069563f3b4cStracker-user        });
1070563f3b4cStracker-user        return roots;
1071563f3b4cStracker-user    }
1072563f3b4cStracker-user
1073563f3b4cStracker-user    /**
1074563f3b4cStracker-user     * Recursively append reply entries into the panel.
1075563f3b4cStracker-user     *
1076563f3b4cStracker-user     * @param {HTMLElement} panel
1077563f3b4cStracker-user     * @param {object}      ann
1078563f3b4cStracker-user     * @param {Array}       nodes  array of {reply, children} tree nodes
1079563f3b4cStracker-user     * @param {number}      depth
1080*ad1073d4Stracker-user     * @param {boolean}     [readOnly] render entries without action buttons
1081563f3b4cStracker-user     */
1082*ad1073d4Stracker-user    function appendReplyTree(panel, ann, nodes, depth, readOnly) {
1083563f3b4cStracker-user        nodes.forEach(function (node) {
1084*ad1073d4Stracker-user            panel.appendChild(buildReplyEntry(ann, node.reply, depth, readOnly));
1085563f3b4cStracker-user            if (node.children.length > 0) {
1086*ad1073d4Stracker-user                appendReplyTree(panel, ann, node.children, depth + 1, readOnly);
1087563f3b4cStracker-user            }
1088563f3b4cStracker-user        });
1089563f3b4cStracker-user    }
1090563f3b4cStracker-user
1091563f3b4cStracker-user    /**
1092563f3b4cStracker-user     * Build an inline reply form that appears directly below a reply entry.
1093563f3b4cStracker-user     *
1094563f3b4cStracker-user     * @param {object} ann           parent annotation
1095563f3b4cStracker-user     * @param {string} parentReplyId id of the reply being replied to
1096563f3b4cStracker-user     * @param {number} depth         visual nesting depth for the new reply
1097563f3b4cStracker-user     * @returns {HTMLElement}
1098563f3b4cStracker-user     */
1099563f3b4cStracker-user    function buildInlineReplyForm(ann, parentReplyId, depth) {
1100563f3b4cStracker-user        var form = document.createElement('div');
1101563f3b4cStracker-user        form.className = 'ann-thread-entry ann-reply ann-inline-reply';
1102563f3b4cStracker-user        var indent = Math.min(depth, 4) * 1.5 + 1.5;
1103563f3b4cStracker-user        if (indent > 0) {
1104563f3b4cStracker-user            form.style.marginLeft = indent + 'em';
1105563f3b4cStracker-user        }
1106563f3b4cStracker-user
1107563f3b4cStracker-user        var ta = document.createElement('textarea');
1108563f3b4cStracker-user        ta.className = 'ann-body-input';
1109563f3b4cStracker-user        ta.placeholder = t('placeholder_reply', 'Write a reply…');
1110108f92bdStracker-user        ta.rows = 3;
1111563f3b4cStracker-user        form.appendChild(ta);
1112563f3b4cStracker-user
1113563f3b4cStracker-user        var row = document.createElement('div');
1114563f3b4cStracker-user        row.className = 'ann-form-row';
1115563f3b4cStracker-user
1116563f3b4cStracker-user        var submitBtn = document.createElement('button');
1117563f3b4cStracker-user        submitBtn.type = 'button';
1118563f3b4cStracker-user        submitBtn.className = 'ann-btn ann-btn-primary';
1119563f3b4cStracker-user        submitBtn.textContent = t('btn_reply', 'Reply');
1120563f3b4cStracker-user        submitBtn.addEventListener('click', function () {
1121563f3b4cStracker-user            var body = ta.value.trim();
1122563f3b4cStracker-user            if (!body) return;
1123563f3b4cStracker-user            doAddReply(ann.id, body, function () {
1124563f3b4cStracker-user                if (form.parentNode) form.parentNode.removeChild(form);
1125563f3b4cStracker-user            }, submitBtn, parentReplyId);
1126563f3b4cStracker-user        });
1127563f3b4cStracker-user
1128563f3b4cStracker-user        var cancelBtn = document.createElement('button');
1129563f3b4cStracker-user        cancelBtn.type = 'button';
1130563f3b4cStracker-user        cancelBtn.className = 'ann-btn';
1131563f3b4cStracker-user        cancelBtn.textContent = t('btn_cancel', 'Cancel');
1132563f3b4cStracker-user        cancelBtn.addEventListener('click', function () {
1133563f3b4cStracker-user            if (form.parentNode) form.parentNode.removeChild(form);
1134563f3b4cStracker-user        });
1135563f3b4cStracker-user
1136563f3b4cStracker-user        row.appendChild(submitBtn);
1137563f3b4cStracker-user        row.appendChild(cancelBtn);
1138563f3b4cStracker-user        form.appendChild(row);
1139563f3b4cStracker-user        return form;
1140563f3b4cStracker-user    }
1141563f3b4cStracker-user
1142563f3b4cStracker-user    /**
114343d2073cStracker-user     * Build the meta row (avatar initials, author name, timestamp, status pill).
114443d2073cStracker-user     *
114543d2073cStracker-user     * @param {string}      author
114643d2073cStracker-user     * @param {number}      timestamp  Unix seconds
114743d2073cStracker-user     * @param {string|null} status     'open'|'resolved'|null
114843d2073cStracker-user     * @returns {HTMLElement}
114943d2073cStracker-user     */
115043d2073cStracker-user    function buildMeta(author, timestamp, status) {
115143d2073cStracker-user        var meta = document.createElement('div');
115243d2073cStracker-user        meta.className = 'ann-meta';
115343d2073cStracker-user
115443d2073cStracker-user        var avatar = document.createElement('span');
115543d2073cStracker-user        avatar.className = 'ann-avatar';
115643d2073cStracker-user        avatar.textContent = (author || '?').slice(0, 2).toUpperCase();
115743d2073cStracker-user        meta.appendChild(avatar);
115843d2073cStracker-user
115943d2073cStracker-user        var authorEl = document.createElement('span');
116043d2073cStracker-user        authorEl.className = 'ann-author';
1161da56206cStracker-user        authorEl.textContent = author || t('label_unknown', 'Unknown');
116243d2073cStracker-user        meta.appendChild(authorEl);
116343d2073cStracker-user
116443d2073cStracker-user        var timeEl = document.createElement('time');
116543d2073cStracker-user        timeEl.className = 'ann-time';
116643d2073cStracker-user        var d = new Date(timestamp * 1000);
116743d2073cStracker-user        timeEl.dateTime = d.toISOString();
116843d2073cStracker-user        timeEl.textContent = formatDate(d);
116943d2073cStracker-user        meta.appendChild(timeEl);
117043d2073cStracker-user
117143d2073cStracker-user        if (status) {
117243d2073cStracker-user            var pill = document.createElement('span');
117343d2073cStracker-user            pill.className = 'ann-status ann-status-' + status;
1174da56206cStracker-user            pill.textContent = status === 'resolved'
1175da56206cStracker-user                ? t('status_resolved', 'Resolved')
1176da56206cStracker-user                : t('status_open', 'Open');
117743d2073cStracker-user            meta.appendChild(pill);
117843d2073cStracker-user        }
117943d2073cStracker-user
118043d2073cStracker-user        return meta;
118143d2073cStracker-user    }
118243d2073cStracker-user
118343d2073cStracker-user    /**
118443d2073cStracker-user     * Build a reply form at the bottom of the panel.
118543d2073cStracker-user     *
118643d2073cStracker-user     * @param {object} ann
118743d2073cStracker-user     * @returns {HTMLElement}
118843d2073cStracker-user     */
118943d2073cStracker-user    function buildReplyForm(ann) {
119043d2073cStracker-user        var form = document.createElement('div');
119143d2073cStracker-user        form.className = 'ann-reply-form';
119243d2073cStracker-user
119343d2073cStracker-user        var ta = document.createElement('textarea');
119443d2073cStracker-user        ta.className = 'ann-body-input';
1195da56206cStracker-user        ta.placeholder = t('placeholder_reply', 'Write a reply…');
119643d2073cStracker-user        ta.rows = 3;
119743d2073cStracker-user        form.appendChild(ta);
119843d2073cStracker-user
119943d2073cStracker-user        var row = document.createElement('div');
120043d2073cStracker-user        row.className = 'ann-form-row';
120143d2073cStracker-user
120243d2073cStracker-user        var submitBtn = document.createElement('button');
120343d2073cStracker-user        submitBtn.type = 'button';
120443d2073cStracker-user        submitBtn.className = 'ann-btn ann-btn-primary';
1205da56206cStracker-user        submitBtn.textContent = t('btn_reply', 'Reply');
120643d2073cStracker-user        submitBtn.addEventListener('click', function () {
120743d2073cStracker-user            var body = ta.value.trim();
120843d2073cStracker-user            if (!body) return;
120943d2073cStracker-user            doAddReply(ann.id, body, function () {
121043d2073cStracker-user                ta.value = '';
1211563f3b4cStracker-user            }, submitBtn);
121243d2073cStracker-user        });
121343d2073cStracker-user        row.appendChild(submitBtn);
121443d2073cStracker-user        form.appendChild(row);
121543d2073cStracker-user
121643d2073cStracker-user        return form;
121743d2073cStracker-user    }
121843d2073cStracker-user
121943d2073cStracker-user    /**
122043d2073cStracker-user     * Replace the body of an entry with an inline edit form.
122143d2073cStracker-user     *
122243d2073cStracker-user     * @param {HTMLElement} entry
122343d2073cStracker-user     * @param {object}      data    {body, annId?, replyId?}  (annId = undefined → annotation)
122443d2073cStracker-user     * @param {string}      type    'annotation' | 'reply'
122543d2073cStracker-user     */
122643d2073cStracker-user    function showEditForm(entry, data, type) {
122743d2073cStracker-user        var bodyEl = entry.querySelector('.ann-body');
122843d2073cStracker-user        if (!bodyEl) return;
122943d2073cStracker-user
123043d2073cStracker-user        var ta = document.createElement('textarea');
123143d2073cStracker-user        ta.className = 'ann-body-input';
123243d2073cStracker-user        ta.value = data.body || '';
1233563f3b4cStracker-user        ta.rows = 3;
123443d2073cStracker-user
123543d2073cStracker-user        var row = document.createElement('div');
123643d2073cStracker-user        row.className = 'ann-form-row';
123743d2073cStracker-user
123843d2073cStracker-user        var saveBtn = document.createElement('button');
123943d2073cStracker-user        saveBtn.type = 'button';
124043d2073cStracker-user        saveBtn.className = 'ann-btn ann-btn-primary';
1241da56206cStracker-user        saveBtn.textContent = t('btn_save', 'Save');
124243d2073cStracker-user        saveBtn.addEventListener('click', function () {
124343d2073cStracker-user            var newBody = ta.value.trim();
124443d2073cStracker-user            if (!newBody) return;
124543d2073cStracker-user            if (type === 'annotation') {
1246563f3b4cStracker-user                doEditAnnotation(data.id || _openAnnId, newBody, saveBtn);
124743d2073cStracker-user            } else {
1248563f3b4cStracker-user                doEditReply(data.annId, data.replyId, newBody, saveBtn);
124943d2073cStracker-user            }
125043d2073cStracker-user        });
125143d2073cStracker-user
125243d2073cStracker-user        var cancelBtn = document.createElement('button');
125343d2073cStracker-user        cancelBtn.type = 'button';
125443d2073cStracker-user        cancelBtn.className = 'ann-btn';
1255da56206cStracker-user        cancelBtn.textContent = t('btn_cancel', 'Cancel');
125643d2073cStracker-user        cancelBtn.addEventListener('click', function () {
125743d2073cStracker-user            entry.removeChild(ta);
125843d2073cStracker-user            entry.removeChild(row);
125943d2073cStracker-user            bodyEl.style.display = '';
126043d2073cStracker-user        });
126143d2073cStracker-user
126243d2073cStracker-user        row.appendChild(saveBtn);
126343d2073cStracker-user        row.appendChild(cancelBtn);
126443d2073cStracker-user
126543d2073cStracker-user        bodyEl.style.display = 'none';
126643d2073cStracker-user        entry.insertBefore(ta, bodyEl.nextSibling);
126743d2073cStracker-user        entry.insertBefore(row, ta.nextSibling);
126843d2073cStracker-user        ta.focus();
126943d2073cStracker-user    }
127043d2073cStracker-user
127143d2073cStracker-user    // -----------------------------------------------------------------------
127243d2073cStracker-user    // Orphan drawer
127343d2073cStracker-user    // -----------------------------------------------------------------------
127443d2073cStracker-user
127543d2073cStracker-user    /**
127643d2073cStracker-user     * Toggle the orphan drawer visibility.
127743d2073cStracker-user     */
127843d2073cStracker-user    function toggleOrphanDrawer() {
127943d2073cStracker-user        var drawer = document.getElementById('ann-orphan-drawer');
128043d2073cStracker-user        if (drawer) {
128143d2073cStracker-user            drawer.parentNode.removeChild(drawer);
128243d2073cStracker-user            return;
128343d2073cStracker-user        }
128443d2073cStracker-user        renderOrphanDrawer();
128543d2073cStracker-user    }
128643d2073cStracker-user
128743d2073cStracker-user    /**
1288d6f8bd9dStracker-user     * Keep the orphan drawer in step with the current orphan set after a
1289d6f8bd9dStracker-user     * mutation (delete / clear). No-op when the drawer is closed. When it is
1290d6f8bd9dStracker-user     * open, rebuild it from the live _orphaned flags so deleted entries
1291d6f8bd9dStracker-user     * disappear; if no orphans remain, remove the drawer entirely instead of
1292d6f8bd9dStracker-user     * leaving an empty shell behind.
1293d6f8bd9dStracker-user     *
1294d6f8bd9dStracker-user     * Must run after renderAll(), which recomputes every ann._orphaned flag.
1295d6f8bd9dStracker-user     */
1296d6f8bd9dStracker-user    function syncOrphanDrawer() {
1297d6f8bd9dStracker-user        var drawer = document.getElementById('ann-orphan-drawer');
1298d6f8bd9dStracker-user        if (!drawer) return; // drawer not open — nothing to do
1299d6f8bd9dStracker-user
1300d6f8bd9dStracker-user        var hasOrphans = false;
1301d6f8bd9dStracker-user        _annotations.forEach(function (ann) {
1302d6f8bd9dStracker-user            if (ann._orphaned) hasOrphans = true;
1303d6f8bd9dStracker-user        });
1304d6f8bd9dStracker-user
1305d6f8bd9dStracker-user        if (drawer.parentNode) drawer.parentNode.removeChild(drawer);
1306d6f8bd9dStracker-user        if (hasOrphans) {
1307d6f8bd9dStracker-user            renderOrphanDrawer();
1308d6f8bd9dStracker-user            repositionMarkers();
1309d6f8bd9dStracker-user        }
1310d6f8bd9dStracker-user    }
1311d6f8bd9dStracker-user
1312d6f8bd9dStracker-user    /**
131343d2073cStracker-user     * Build and insert the orphan drawer at the bottom of the content area.
131443d2073cStracker-user     */
131543d2073cStracker-user    function renderOrphanDrawer() {
131643d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
131743d2073cStracker-user        if (!content) return;
131843d2073cStracker-user
131943d2073cStracker-user        var drawer = document.createElement('div');
132043d2073cStracker-user        drawer.id = 'ann-orphan-drawer';
132143d2073cStracker-user        drawer.className = CLS_ORPHAN_DRAWER;
132243d2073cStracker-user
132343d2073cStracker-user        var heading = document.createElement('h4');
1324da56206cStracker-user        heading.textContent = t('orphaned_heading', 'Orphaned annotations');
132543d2073cStracker-user        drawer.appendChild(heading);
132643d2073cStracker-user
132743d2073cStracker-user        var note = document.createElement('p');
132843d2073cStracker-user        note.className = 'ann-orphan-note';
1329da56206cStracker-user        note.textContent = t('orphaned_note',
1330da56206cStracker-user            'These annotations reference text that no longer appears on the page.');
133143d2073cStracker-user        drawer.appendChild(note);
133243d2073cStracker-user
133343d2073cStracker-user        var found = false;
133443d2073cStracker-user        _annotations.forEach(function (ann) {
133543d2073cStracker-user            if (!ann._orphaned) return;
133643d2073cStracker-user            found = true;
1337*ad1073d4Stracker-user            // Show the whole thread, not just the root message: wrap the root
1338*ad1073d4Stracker-user            // entry and its full (read-only) reply tree in one container so
1339*ad1073d4Stracker-user            // multiple orphaned threads stay visually separated.
1340*ad1073d4Stracker-user            var thread = document.createElement('div');
1341*ad1073d4Stracker-user            thread.className = 'ann-orphan-thread';
1342*ad1073d4Stracker-user            thread.dataset.status = ann.status || 'open';
1343*ad1073d4Stracker-user            thread.appendChild(buildThreadEntry(ann, true));
1344*ad1073d4Stracker-user            appendReplyTree(thread, ann, buildReplyTree(ann.replies || []), 0, true);
1345*ad1073d4Stracker-user            drawer.appendChild(thread);
134643d2073cStracker-user        });
134743d2073cStracker-user
134843d2073cStracker-user        if (!found) {
134943d2073cStracker-user            var empty = document.createElement('p');
1350da56206cStracker-user            empty.textContent = t('orphaned_none', 'None.');
135143d2073cStracker-user            drawer.appendChild(empty);
135243d2073cStracker-user        }
135343d2073cStracker-user
1354563f3b4cStracker-user        // Insert right below the counter bar, which lives inside .page.
1355563f3b4cStracker-user        // All fallbacks also target .page so the drawer never stretches past
1356563f3b4cStracker-user        // the content column.
1357563f3b4cStracker-user        var bar = document.getElementById('ann-counter-bar');
1358563f3b4cStracker-user        if (bar && bar.parentNode) {
1359563f3b4cStracker-user            bar.parentNode.insertBefore(drawer, bar.nextSibling);
1360563f3b4cStracker-user        } else {
1361563f3b4cStracker-user            var pageEl2 = document.querySelector('.' + PAGE_CLS);
1362563f3b4cStracker-user            if (pageEl2) {
1363563f3b4cStracker-user                pageEl2.insertBefore(drawer, pageEl2.firstChild);
1364563f3b4cStracker-user            } else {
1365563f3b4cStracker-user                content.insertBefore(drawer, content.firstChild);
1366563f3b4cStracker-user            }
1367563f3b4cStracker-user        }
136843d2073cStracker-user    }
136943d2073cStracker-user
137043d2073cStracker-user    // -----------------------------------------------------------------------
137143d2073cStracker-user    // Selection capture
137243d2073cStracker-user    // -----------------------------------------------------------------------
137343d2073cStracker-user
137443d2073cStracker-user    /**
137543d2073cStracker-user     * Wire up mouseup/touchend listeners to detect text selection.
137643d2073cStracker-user     *
137743d2073cStracker-user     * @param {HTMLElement} content
137843d2073cStracker-user     */
137943d2073cStracker-user    function initSelectionCapture(content) {
138043d2073cStracker-user        if (!_loggedIn) return; // anonymous users cannot annotate
138143d2073cStracker-user
138243d2073cStracker-user        document.addEventListener('mouseup', function (e) {
138343d2073cStracker-user            handleSelectionEnd(e, content);
138443d2073cStracker-user        });
138543d2073cStracker-user        document.addEventListener('touchend', function (e) {
138643d2073cStracker-user            // Small delay so the browser has committed the selection.
138743d2073cStracker-user            setTimeout(function () { handleSelectionEnd(e, content); }, 50);
138843d2073cStracker-user        });
138943d2073cStracker-user
139050325813Stracker-user        // Close tooltip on click outside (but not when clicking the new-form).
139143d2073cStracker-user        document.addEventListener('mousedown', function (e) {
139243d2073cStracker-user            var tooltip = document.getElementById('ann-tooltip');
139343d2073cStracker-user            if (tooltip && !tooltip.contains(e.target)) {
139450325813Stracker-user                var naf = document.getElementById('ann-new-form');
139550325813Stracker-user                if (!naf || !naf.contains(e.target)) {
139643d2073cStracker-user                    hideTooltip();
139743d2073cStracker-user                }
139850325813Stracker-user            }
139943d2073cStracker-user        });
140043d2073cStracker-user    }
140143d2073cStracker-user
140243d2073cStracker-user    /**
140343d2073cStracker-user     * Handle end of selection: show the "Annotate" tooltip if there is a
140443d2073cStracker-user     * non-empty selection inside the content area.
140543d2073cStracker-user     *
140643d2073cStracker-user     * @param {Event}       e
140743d2073cStracker-user     * @param {HTMLElement} content
140843d2073cStracker-user     */
140943d2073cStracker-user    function handleSelectionEnd(e, content) {
141043d2073cStracker-user        var sel = window.getSelection();
141143d2073cStracker-user        if (!sel || sel.isCollapsed) {
141250325813Stracker-user            // Don't hide the tooltip if the mouseup came from inside it —
141350325813Stracker-user            // the click handler is responsible for cleanup in that case.
141450325813Stracker-user            var tip = document.getElementById('ann-tooltip');
141550325813Stracker-user            if (tip && tip.contains(e.target)) {
141650325813Stracker-user                return;
141750325813Stracker-user            }
141850325813Stracker-user            // Don't hide if a new-annotation form is open (user clicked
141950325813Stracker-user            // inside the form, collapsing the original selection).
142050325813Stracker-user            var naf = document.getElementById('ann-new-form');
142150325813Stracker-user            if (naf && naf.contains(e.target)) {
142250325813Stracker-user                return;
142350325813Stracker-user            }
142443d2073cStracker-user            hideTooltip();
142543d2073cStracker-user            return;
142643d2073cStracker-user        }
142743d2073cStracker-user        var range = sel.getRangeAt(0);
142843d2073cStracker-user        if (!content.contains(range.commonAncestorContainer)) {
142943d2073cStracker-user            hideTooltip();
143043d2073cStracker-user            return;
143143d2073cStracker-user        }
143286c7806dStracker-user        // If the selection touches any existing annotation — even by a single
143386c7806dStracker-user        // character, whether wholly inside it or overrunning it on either side —
143486c7806dStracker-user        // open that annotation instead of offering to create a new one.
143586c7806dStracker-user        var hitSpan = selectionHitsHighlight(range);
143686c7806dStracker-user        if (hitSpan) {
143786c7806dStracker-user            hideTooltip();
143886c7806dStracker-user            openPanel(hitSpan.dataset.annId);
143986c7806dStracker-user            return;
144086c7806dStracker-user        }
144186c7806dStracker-user        // Only real page prose can be annotated: skip our own UI (panels,
144286c7806dStracker-user        // counter, tooltip), the TOC, the page-info line, and section-edit
144386c7806dStracker-user        // buttons — all of which live inside #dokuwiki__content.
144486c7806dStracker-user        if (isInExcludedRegion(range.startContainer) ||
144586c7806dStracker-user            isInExcludedRegion(range.endContainer) ||
144686c7806dStracker-user            isInExcludedRegion(range.commonAncestorContainer)) {
1447563f3b4cStracker-user            hideTooltip();
1448563f3b4cStracker-user            return;
1449563f3b4cStracker-user        }
145043d2073cStracker-user        var text = sel.toString().trim();
145143d2073cStracker-user        if (text.length < 1) {
145243d2073cStracker-user            hideTooltip();
145343d2073cStracker-user            return;
145443d2073cStracker-user        }
145543d2073cStracker-user
145650325813Stracker-user        // If the tooltip is already showing (e.g. user moused up after
145750325813Stracker-user        // pressing the Annotate button), don't replace it with a fresh one —
145850325813Stracker-user        // that would orphan the button mid-click and break the click handler.
145950325813Stracker-user        if (document.getElementById('ann-tooltip')) {
146050325813Stracker-user            return;
146150325813Stracker-user        }
146250325813Stracker-user
146343d2073cStracker-user        // Show the tooltip near the end of the selection.
146443d2073cStracker-user        var rect = range.getBoundingClientRect();
146543d2073cStracker-user        showTooltip(rect, range, sel, content);
146643d2073cStracker-user    }
146743d2073cStracker-user
146843d2073cStracker-user    /**
146943d2073cStracker-user     * Show the "Annotate" tooltip bubble.
147043d2073cStracker-user     *
147143d2073cStracker-user     * @param {DOMRect}     rect     bounding rect of the selection
147243d2073cStracker-user     * @param {Range}       range
147343d2073cStracker-user     * @param {Selection}   sel
147443d2073cStracker-user     * @param {HTMLElement} content
147543d2073cStracker-user     */
147643d2073cStracker-user    function showTooltip(rect, range, sel, content) {
147743d2073cStracker-user        hideTooltip();
147843d2073cStracker-user
147943d2073cStracker-user        var tip = document.createElement('div');
148043d2073cStracker-user        tip.id = 'ann-tooltip';
148143d2073cStracker-user        tip.className = CLS_TOOLTIP;
148243d2073cStracker-user
148350325813Stracker-user        // Capture the anchor on mousedown while the selection is guaranteed
148450325813Stracker-user        // to still exist. By the time 'click' fires, many browsers have
148550325813Stracker-user        // already collapsed the selection, so captureAnchor would return null.
148650325813Stracker-user        // _pendingAnchor is module-level so it survives tooltip replacement.
148743d2073cStracker-user        var btn = document.createElement('button');
148843d2073cStracker-user        btn.type = 'button';
1489da56206cStracker-user        btn.textContent = t('btn_annotate', 'Annotate');
149043d2073cStracker-user        btn.className = 'ann-btn ann-btn-primary';
149143d2073cStracker-user        btn.addEventListener('mousedown', function (e) {
149250325813Stracker-user            e.preventDefault(); // prevent focus-change deselection
149350325813Stracker-user            // Capture now, while the selection is still intact.
149450325813Stracker-user            _pendingAnchor = captureAnchor(sel, range, content);
149543d2073cStracker-user        });
149643d2073cStracker-user        btn.addEventListener('click', function () {
149750325813Stracker-user            var anchor = _pendingAnchor;
149850325813Stracker-user            _pendingAnchor = null;
149943d2073cStracker-user            hideTooltip();
150043d2073cStracker-user            if (anchor) {
150143d2073cStracker-user                openNewAnnotationForm(anchor, range);
150243d2073cStracker-user            }
150343d2073cStracker-user        });
150443d2073cStracker-user        tip.appendChild(btn);
150543d2073cStracker-user
150643d2073cStracker-user        document.body.appendChild(tip);
150743d2073cStracker-user
150843d2073cStracker-user        // Position below the selection's end.
150943d2073cStracker-user        var scrollTop  = window.pageYOffset || document.documentElement.scrollTop;
151043d2073cStracker-user        var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
151143d2073cStracker-user        tip.style.top  = (rect.bottom + scrollTop  + 6) + 'px';
151243d2073cStracker-user        tip.style.left = (rect.left   + scrollLeft)     + 'px';
151343d2073cStracker-user    }
151443d2073cStracker-user
151543d2073cStracker-user    /**
151643d2073cStracker-user     * Remove the tooltip if it exists.
151743d2073cStracker-user     */
151843d2073cStracker-user    function hideTooltip() {
151943d2073cStracker-user        var tip = document.getElementById('ann-tooltip');
152043d2073cStracker-user        if (tip && tip.parentNode) {
152143d2073cStracker-user            tip.parentNode.removeChild(tip);
152243d2073cStracker-user        }
152350325813Stracker-user        // Note: ann-new-form is NOT removed here — it has its own Cancel
152450325813Stracker-user        // button and must survive the mouseup that fires after the click.
152543d2073cStracker-user    }
152643d2073cStracker-user
152743d2073cStracker-user    /**
152843d2073cStracker-user     * Capture an anchor object from the current Selection.
152943d2073cStracker-user     *
153043d2073cStracker-user     * @param {Selection}   sel
153143d2073cStracker-user     * @param {Range}       range
153243d2073cStracker-user     * @param {HTMLElement} content
153343d2073cStracker-user     * @returns {object|null} {exact, prefix, suffix, start}
153443d2073cStracker-user     */
153543d2073cStracker-user    function captureAnchor(sel, range, content) {
153643d2073cStracker-user        var exact = normalizeWS(sel.toString());
153743d2073cStracker-user        if (!exact) return null;
153843d2073cStracker-user
153943d2073cStracker-user        // Get full page text for prefix/suffix and start computation.
154043d2073cStracker-user        var chunks   = collectTextChunks(content);
154143d2073cStracker-user        var fullRaw  = chunks.map(function (c) { return c.text; }).join('');
1542da56206cStracker-user        var nm       = normalizeWithMap(fullRaw);
1543da56206cStracker-user        var fullNorm = nm.norm;
154443d2073cStracker-user
154543d2073cStracker-user        // Find where this text node + offset lands in the raw full text.
154643d2073cStracker-user        var rawStart = 0;
154743d2073cStracker-user        for (var i = 0; i < chunks.length; i++) {
154843d2073cStracker-user            var c = chunks[i];
154943d2073cStracker-user            if (c.node === range.startContainer) {
155043d2073cStracker-user                rawStart = c.start + range.startOffset;
155143d2073cStracker-user                break;
155243d2073cStracker-user            }
155343d2073cStracker-user        }
155443d2073cStracker-user
1555da56206cStracker-user        // Map that raw offset to an offset in the normalised text, using the
1556da56206cStracker-user        // same map as re-anchoring so capture and find stay in agreement.
1557da56206cStracker-user        var normStart = nm.norm.length;
1558da56206cStracker-user        for (var j = 0; j < nm.map.length; j++) {
1559da56206cStracker-user            if (nm.map[j] >= rawStart) {
156043d2073cStracker-user                normStart = j;
156143d2073cStracker-user                break;
156243d2073cStracker-user            }
156343d2073cStracker-user        }
156443d2073cStracker-user
156586c7806dStracker-user        // Context slice length comes from the plugin config (context_length),
156686c7806dStracker-user        // injected into JSINFO.annotations; fall back to 30 when absent. The
156786c7806dStracker-user        // PHP side caps the stored prefix/suffix to the same length.
156886c7806dStracker-user        var CTX = (typeof _info.contextLen === 'number') ? _info.contextLen : 30;
156943d2073cStracker-user        var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart);
157043d2073cStracker-user        var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX);
157143d2073cStracker-user
157243d2073cStracker-user        return {
157343d2073cStracker-user            exact:  exact,
157443d2073cStracker-user            prefix: prefix,
157543d2073cStracker-user            suffix: suffix,
157643d2073cStracker-user            start:  normStart,
157743d2073cStracker-user        };
157843d2073cStracker-user    }
157943d2073cStracker-user
158043d2073cStracker-user    /**
158143d2073cStracker-user     * Open the new-annotation form below the paragraph containing the selection.
158243d2073cStracker-user     *
158343d2073cStracker-user     * @param {object} anchor  {exact, prefix, suffix, start}
158443d2073cStracker-user     * @param {Range}  range
158543d2073cStracker-user     */
158643d2073cStracker-user    function openNewAnnotationForm(anchor, range) {
158743d2073cStracker-user        closePanel();
158843d2073cStracker-user
158943d2073cStracker-user        var insertAfter = findParagraph(range.commonAncestorContainer);
159043d2073cStracker-user        var form = document.createElement('div');
159143d2073cStracker-user        form.id = 'ann-new-form';
159243d2073cStracker-user        form.className = 'ann-new-form';
159343d2073cStracker-user
159443d2073cStracker-user        var quote = document.createElement('blockquote');
159543d2073cStracker-user        quote.className = 'ann-quote';
159643d2073cStracker-user        quote.textContent = anchor.exact;
159743d2073cStracker-user        form.appendChild(quote);
159843d2073cStracker-user
159943d2073cStracker-user        var ta = document.createElement('textarea');
160043d2073cStracker-user        ta.className = 'ann-body-input';
1601da56206cStracker-user        ta.placeholder = t('placeholder_body', 'Add a comment…');
1602563f3b4cStracker-user        ta.rows = 3;
160343d2073cStracker-user        form.appendChild(ta);
160443d2073cStracker-user
160543d2073cStracker-user        var row = document.createElement('div');
160643d2073cStracker-user        row.className = 'ann-form-row';
160743d2073cStracker-user
160843d2073cStracker-user        var submitBtn = document.createElement('button');
160943d2073cStracker-user        submitBtn.type = 'button';
161043d2073cStracker-user        submitBtn.className = 'ann-btn ann-btn-primary';
1611da56206cStracker-user        submitBtn.textContent = t('btn_annotate', 'Annotate');
161243d2073cStracker-user        submitBtn.addEventListener('click', function () {
161343d2073cStracker-user            var body = ta.value.trim();
161443d2073cStracker-user            if (!body) return;
161543d2073cStracker-user            doCreate(anchor, body, function () {
161643d2073cStracker-user                if (form.parentNode) form.parentNode.removeChild(form);
1617563f3b4cStracker-user            }, submitBtn);
161843d2073cStracker-user        });
161943d2073cStracker-user
162043d2073cStracker-user        var cancelBtn = document.createElement('button');
162143d2073cStracker-user        cancelBtn.type = 'button';
162243d2073cStracker-user        cancelBtn.className = 'ann-btn';
1623da56206cStracker-user        cancelBtn.textContent = t('btn_cancel', 'Cancel');
162443d2073cStracker-user        cancelBtn.addEventListener('click', function () {
162543d2073cStracker-user            if (form.parentNode) form.parentNode.removeChild(form);
162643d2073cStracker-user        });
162743d2073cStracker-user
162843d2073cStracker-user        row.appendChild(submitBtn);
162943d2073cStracker-user        row.appendChild(cancelBtn);
163043d2073cStracker-user        form.appendChild(row);
163143d2073cStracker-user
163243d2073cStracker-user        if (insertAfter && insertAfter.parentNode) {
163343d2073cStracker-user            insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling);
163443d2073cStracker-user        } else {
163543d2073cStracker-user            var content = document.getElementById(CONTENT_ID);
163643d2073cStracker-user            if (content) content.appendChild(form);
163743d2073cStracker-user        }
163843d2073cStracker-user
163943d2073cStracker-user        ta.focus();
164043d2073cStracker-user    }
164143d2073cStracker-user
164243d2073cStracker-user    // -----------------------------------------------------------------------
164343d2073cStracker-user    // AJAX actions
164443d2073cStracker-user    // -----------------------------------------------------------------------
164543d2073cStracker-user
164643d2073cStracker-user    /**
164743d2073cStracker-user     * POST create action and update state on success.
164843d2073cStracker-user     *
164943d2073cStracker-user     * @param {object}        anchor
165043d2073cStracker-user     * @param {string}        body
165143d2073cStracker-user     * @param {Function}      onSuccess
1652563f3b4cStracker-user     * @param {HTMLElement}   [btn]  button to disable while the request is in flight
165343d2073cStracker-user     */
1654563f3b4cStracker-user    function doCreate(anchor, body, onSuccess, btn) {
1655563f3b4cStracker-user        setBusy(btn, true);
165643d2073cStracker-user        ajax({
165743d2073cStracker-user            action: 'create',
165843d2073cStracker-user            id:     _info.pageId,
165943d2073cStracker-user            anchor: anchor,
166043d2073cStracker-user            body:   body,
166143d2073cStracker-user        }).then(function (data) {
1662563f3b4cStracker-user            setBusy(btn, false);
166343d2073cStracker-user            if (!data.success) {
1664da56206cStracker-user                showError(t('error_save', 'Could not save — please try again.'), data);
166543d2073cStracker-user                return;
166643d2073cStracker-user            }
166743d2073cStracker-user            var ann = data.annotation;
166843d2073cStracker-user            _annotations.set(ann.id, ann);
166943d2073cStracker-user            if (typeof onSuccess === 'function') onSuccess(ann);
167043d2073cStracker-user            renderAll();
167143d2073cStracker-user        }).catch(function () {
1672563f3b4cStracker-user            setBusy(btn, false);
1673da56206cStracker-user            alert(t('error_save', 'Could not save — please try again.'));
167443d2073cStracker-user        });
167543d2073cStracker-user    }
167643d2073cStracker-user
167743d2073cStracker-user    /**
1678563f3b4cStracker-user     * Run a thread-level mutation (reply / edit annotation / edit reply /
1679563f3b4cStracker-user     * delete reply): POST the payload, then on success store the returned
1680563f3b4cStracker-user     * annotation — keeping the client-side render state via mergeClientProps —
1681563f3b4cStracker-user     * and re-open its panel. The server returns the full updated annotation, so
1682563f3b4cStracker-user     * no second GET is needed. These four actions share this exact shape;
1683563f3b4cStracker-user     * create / delete-annotation / resolve differ (they re-render the whole
1684563f3b4cStracker-user     * overlay) and stay separate below.
1685563f3b4cStracker-user     *
1686563f3b4cStracker-user     * @param {object}      payload  AJAX body; must carry annId
1687563f3b4cStracker-user     * @param {HTMLElement} [btn]    button to show the busy spinner on
1688563f3b4cStracker-user     * @param {string}      errKey   lang key for the failure message
1689563f3b4cStracker-user     * @param {string}      errText  English fallback for that message
1690563f3b4cStracker-user     * @param {Function}    [onOk]   optional callback run before re-rendering
1691563f3b4cStracker-user     */
1692563f3b4cStracker-user    function submitThreadAction(payload, btn, errKey, errText, onOk) {
1693563f3b4cStracker-user        setBusy(btn, true);
1694563f3b4cStracker-user        ajax(payload).then(function (data) {
1695563f3b4cStracker-user            setBusy(btn, false);
1696563f3b4cStracker-user            if (!data.success) {
1697563f3b4cStracker-user                showError(t(errKey, errText), data);
1698563f3b4cStracker-user                return;
1699563f3b4cStracker-user            }
1700563f3b4cStracker-user            _annotations.set(data.annotation.id, mergeClientProps(data.annotation));
1701563f3b4cStracker-user            if (typeof onOk === 'function') onOk();
1702563f3b4cStracker-user            reopenPanel(payload.annId);
170349d7ec0aStracker-user            // If this annotation sits in the open orphan drawer, refresh it so
170449d7ec0aStracker-user            // an edited body / changed reply count shows there too. (The anchor
170549d7ec0aStracker-user            // is unchanged by these actions, so _orphaned flags stay valid.)
170649d7ec0aStracker-user            syncOrphanDrawer();
1707563f3b4cStracker-user        }).catch(function () {
1708563f3b4cStracker-user            setBusy(btn, false);
1709563f3b4cStracker-user            alert(t(errKey, errText));
1710563f3b4cStracker-user        });
1711563f3b4cStracker-user    }
1712563f3b4cStracker-user
1713563f3b4cStracker-user    /**
171443d2073cStracker-user     * POST reply action and refresh the open panel.
171543d2073cStracker-user     *
171643d2073cStracker-user     * @param {string}      annId
171743d2073cStracker-user     * @param {string}      body
171843d2073cStracker-user     * @param {Function}    onSuccess
1719563f3b4cStracker-user     * @param {HTMLElement} [btn]
1720563f3b4cStracker-user     * @param {string}      [parentReplyId]  id of the reply being replied to, or ''
172143d2073cStracker-user     */
1722563f3b4cStracker-user    function doAddReply(annId, body, onSuccess, btn, parentReplyId) {
1723563f3b4cStracker-user        submitThreadAction({
172443d2073cStracker-user            action:   'reply',
172543d2073cStracker-user            id:       _info.pageId,
172643d2073cStracker-user            annId:    annId,
172743d2073cStracker-user            body:     body,
1728563f3b4cStracker-user            parentId: parentReplyId || '',
1729563f3b4cStracker-user        }, btn, 'error_save', 'Could not save — please try again.', onSuccess);
173043d2073cStracker-user    }
173143d2073cStracker-user
173243d2073cStracker-user    /**
173343d2073cStracker-user     * POST edit_annotation and re-render.
173443d2073cStracker-user     *
173543d2073cStracker-user     * @param {string}      annId
173643d2073cStracker-user     * @param {string}      body
1737563f3b4cStracker-user     * @param {HTMLElement} [btn]
173843d2073cStracker-user     */
1739563f3b4cStracker-user    function doEditAnnotation(annId, body, btn) {
1740563f3b4cStracker-user        submitThreadAction({
174143d2073cStracker-user            action: 'edit_annotation',
174243d2073cStracker-user            id:     _info.pageId,
174343d2073cStracker-user            annId:  annId,
174443d2073cStracker-user            body:   body,
1745563f3b4cStracker-user        }, btn, 'error_save', 'Could not save — please try again.');
174643d2073cStracker-user    }
174743d2073cStracker-user
174843d2073cStracker-user    /**
174943d2073cStracker-user     * POST edit_reply and re-render.
175043d2073cStracker-user     *
175143d2073cStracker-user     * @param {string}      annId
175243d2073cStracker-user     * @param {string}      replyId
175343d2073cStracker-user     * @param {string}      body
1754563f3b4cStracker-user     * @param {HTMLElement} [btn]
175543d2073cStracker-user     */
1756563f3b4cStracker-user    function doEditReply(annId, replyId, body, btn) {
1757563f3b4cStracker-user        submitThreadAction({
175843d2073cStracker-user            action:  'edit_reply',
175943d2073cStracker-user            id:      _info.pageId,
176043d2073cStracker-user            annId:   annId,
176143d2073cStracker-user            replyId: replyId,
176243d2073cStracker-user            body:    body,
1763563f3b4cStracker-user        }, btn, 'error_save', 'Could not save — please try again.');
176443d2073cStracker-user    }
176543d2073cStracker-user
176643d2073cStracker-user    /**
176743d2073cStracker-user     * POST delete_annotation.
176843d2073cStracker-user     *
176943d2073cStracker-user     * @param {string}      annId
1770563f3b4cStracker-user     * @param {HTMLElement} [btn]
177143d2073cStracker-user     */
1772563f3b4cStracker-user    function doDeleteAnnotation(annId, btn) {
1773563f3b4cStracker-user        setBusy(btn, true);
177443d2073cStracker-user        ajax({
177543d2073cStracker-user            action: 'delete_annotation',
177643d2073cStracker-user            id:     _info.pageId,
177743d2073cStracker-user            annId:  annId,
177843d2073cStracker-user        }).then(function (data) {
1779563f3b4cStracker-user            setBusy(btn, false);
178043d2073cStracker-user            if (!data.success) {
1781da56206cStracker-user                showError(t('error_delete', 'Could not delete — please try again.'), data);
178243d2073cStracker-user                return;
178343d2073cStracker-user            }
178443d2073cStracker-user            _annotations.delete(annId);
178543d2073cStracker-user            closePanel();
178643d2073cStracker-user            renderAll();
1787d6f8bd9dStracker-user            // If this was deleted from the open orphan drawer, refresh it —
1788d6f8bd9dStracker-user            // and remove it entirely once the last orphan is gone.
1789d6f8bd9dStracker-user            syncOrphanDrawer();
1790563f3b4cStracker-user        }).catch(function () {
1791563f3b4cStracker-user            setBusy(btn, false);
179243d2073cStracker-user        });
179343d2073cStracker-user    }
179443d2073cStracker-user
179543d2073cStracker-user    /**
179643d2073cStracker-user     * POST delete_reply and re-render.
179743d2073cStracker-user     *
179843d2073cStracker-user     * @param {string}      annId
179943d2073cStracker-user     * @param {string}      replyId
1800563f3b4cStracker-user     * @param {HTMLElement} [btn]
180143d2073cStracker-user     */
1802563f3b4cStracker-user    function doDeleteReply(annId, replyId, btn) {
1803563f3b4cStracker-user        submitThreadAction({
180443d2073cStracker-user            action:  'delete_reply',
180543d2073cStracker-user            id:      _info.pageId,
180643d2073cStracker-user            annId:   annId,
180743d2073cStracker-user            replyId: replyId,
1808563f3b4cStracker-user        }, btn, 'error_delete', 'Could not delete — please try again.');
180943d2073cStracker-user    }
181043d2073cStracker-user
181143d2073cStracker-user    /**
181243d2073cStracker-user     * POST resolve/reopen action.
181343d2073cStracker-user     *
181443d2073cStracker-user     * @param {string}      annId
181543d2073cStracker-user     * @param {string}      status  'open' | 'resolved'
1816563f3b4cStracker-user     * @param {HTMLElement} [btn]
181743d2073cStracker-user     */
1818563f3b4cStracker-user    function doResolve(annId, status, btn) {
1819563f3b4cStracker-user        setBusy(btn, true);
182043d2073cStracker-user        ajax({
182143d2073cStracker-user            action: 'resolve',
182243d2073cStracker-user            id:     _info.pageId,
182343d2073cStracker-user            annId:  annId,
182443d2073cStracker-user            status: status,
182543d2073cStracker-user        }).then(function (data) {
1826563f3b4cStracker-user            setBusy(btn, false);
182743d2073cStracker-user            if (!data.success) {
1828da56206cStracker-user                showError(t('error_status', 'Could not update the status — please try again.'), data);
182943d2073cStracker-user                return;
183043d2073cStracker-user            }
1831563f3b4cStracker-user            _annotations.set(data.annotation.id, data.annotation);
183243d2073cStracker-user            renderAll();
183343d2073cStracker-user            reopenPanel(annId);
1834563f3b4cStracker-user        }).catch(function () {
1835563f3b4cStracker-user            setBusy(btn, false);
183643d2073cStracker-user        });
183743d2073cStracker-user    }
183843d2073cStracker-user
183943d2073cStracker-user    /**
184043d2073cStracker-user     * POST clear_resolved (admin).
1841d6f8bd9dStracker-user     *
1842d6f8bd9dStracker-user     * @param {HTMLElement} [btn]  button to show the busy spinner on
184343d2073cStracker-user     */
1844d6f8bd9dStracker-user    function doClearResolved(btn) {
1845da56206cStracker-user        if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return;
1846d6f8bd9dStracker-user        setBusy(btn, true);
184743d2073cStracker-user        ajax({
184843d2073cStracker-user            action: 'clear_resolved',
184943d2073cStracker-user            id:     _info.pageId,
185043d2073cStracker-user        }).then(function (data) {
1851d6f8bd9dStracker-user            setBusy(btn, false);
185243d2073cStracker-user            if (!data.success) {
1853da56206cStracker-user                showError(t('error_clear', 'Could not clear — please try again.'), data);
185443d2073cStracker-user                return;
185543d2073cStracker-user            }
185643d2073cStracker-user            // Remove resolved from local state.
185743d2073cStracker-user            _annotations.forEach(function (ann, id) {
185843d2073cStracker-user                if (ann.status === 'resolved') _annotations.delete(id);
185943d2073cStracker-user            });
186043d2073cStracker-user            closePanel();
186143d2073cStracker-user            renderAll();
1862d6f8bd9dStracker-user            // Deleting resolved orphans may empty the drawer — sync/remove it.
1863d6f8bd9dStracker-user            syncOrphanDrawer();
1864d6f8bd9dStracker-user        }).catch(function () {
1865d6f8bd9dStracker-user            setBusy(btn, false);
1866d6f8bd9dStracker-user            alert(t('error_clear', 'Could not clear — please try again.'));
186743d2073cStracker-user        });
186843d2073cStracker-user    }
186943d2073cStracker-user
187043d2073cStracker-user    /**
187143d2073cStracker-user     * POST clear_orphaned (admin).
1872d6f8bd9dStracker-user     *
1873d6f8bd9dStracker-user     * @param {HTMLElement} [btn]  button to show the busy spinner on
187443d2073cStracker-user     */
1875d6f8bd9dStracker-user    function doClearOrphaned(btn) {
1876da56206cStracker-user        if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return;
1877d6f8bd9dStracker-user        setBusy(btn, true);
187843d2073cStracker-user        ajax({
187943d2073cStracker-user            action: 'clear_orphaned',
188043d2073cStracker-user            id:     _info.pageId,
188143d2073cStracker-user        }).then(function (data) {
1882d6f8bd9dStracker-user            setBusy(btn, false);
188343d2073cStracker-user            if (!data.success) {
1884da56206cStracker-user                showError(t('error_clear', 'Could not clear — please try again.'), data);
188543d2073cStracker-user                return;
188643d2073cStracker-user            }
188743d2073cStracker-user            _annotations.forEach(function (ann, id) {
188843d2073cStracker-user                if (ann._orphaned) _annotations.delete(id);
188943d2073cStracker-user            });
189043d2073cStracker-user            closePanel();
189143d2073cStracker-user            renderAll();
1892d6f8bd9dStracker-user            // All orphans are gone now — tear down the drawer if it is open.
1893d6f8bd9dStracker-user            syncOrphanDrawer();
1894d6f8bd9dStracker-user        }).catch(function () {
1895d6f8bd9dStracker-user            setBusy(btn, false);
1896d6f8bd9dStracker-user            alert(t('error_clear', 'Could not clear — please try again.'));
189743d2073cStracker-user        });
189843d2073cStracker-user    }
189943d2073cStracker-user
190043d2073cStracker-user    // -----------------------------------------------------------------------
190143d2073cStracker-user    // Panel management helpers
190243d2073cStracker-user    // -----------------------------------------------------------------------
190343d2073cStracker-user
190443d2073cStracker-user    /**
190543d2073cStracker-user     * Close the current panel and re-open it (preserves scroll position and
190643d2073cStracker-user     * re-renders the thread with fresh data).
190743d2073cStracker-user     *
190843d2073cStracker-user     * @param {string} annId
190943d2073cStracker-user     */
191043d2073cStracker-user    function reopenPanel(annId) {
1911563f3b4cStracker-user        // closePanel() first clears _openAnnId so openPanel() rebuilds instead
1912563f3b4cStracker-user        // of treating the same id as a toggle. focusReply=false keeps the
1913563f3b4cStracker-user        // viewport put after resolve / edit / delete actions.
191443d2073cStracker-user        closePanel();
1915563f3b4cStracker-user        openPanel(annId, false);
191643d2073cStracker-user    }
191743d2073cStracker-user
191843d2073cStracker-user    // -----------------------------------------------------------------------
191943d2073cStracker-user    // Utilities
192043d2073cStracker-user    // -----------------------------------------------------------------------
192143d2073cStracker-user
192243d2073cStracker-user    /**
1923563f3b4cStracker-user     * Disable a button and show a spinner while an AJAX request is in flight;
1924563f3b4cStracker-user     * restore label and width on completion.
1925563f3b4cStracker-user     *
1926563f3b4cStracker-user     * @param {HTMLElement|null|undefined} btn
1927563f3b4cStracker-user     * @param {boolean}                   busy
1928563f3b4cStracker-user     */
1929563f3b4cStracker-user    function setBusy(btn, busy) {
1930563f3b4cStracker-user        if (!btn) return;
1931563f3b4cStracker-user        if (busy) {
1932563f3b4cStracker-user            btn.disabled = true;
1933563f3b4cStracker-user            btn.dataset.prevText = btn.textContent;
1934563f3b4cStracker-user            // Lock the width before clearing text so the button doesn't shrink.
1935563f3b4cStracker-user            btn.style.minWidth = btn.offsetWidth + 'px';
1936563f3b4cStracker-user            btn.textContent = ' '; // non-breaking space keeps height
1937563f3b4cStracker-user            btn.classList.add('ann-btn-busy');
1938563f3b4cStracker-user        } else {
1939563f3b4cStracker-user            btn.disabled = false;
1940563f3b4cStracker-user            btn.classList.remove('ann-btn-busy');
1941563f3b4cStracker-user            if (btn.dataset.prevText !== undefined) {
1942563f3b4cStracker-user                btn.textContent = btn.dataset.prevText;
1943563f3b4cStracker-user                delete btn.dataset.prevText;
1944563f3b4cStracker-user            }
1945563f3b4cStracker-user            btn.style.minWidth = '';
1946563f3b4cStracker-user        }
1947563f3b4cStracker-user    }
1948563f3b4cStracker-user
1949563f3b4cStracker-user    /**
1950563f3b4cStracker-user     * Copy client-only runtime properties (_highlightEl, _markerEl,
1951563f3b4cStracker-user     * _orphaned, _range) from the currently stored annotation onto a
1952563f3b4cStracker-user     * freshly-returned server object before storing it, so that panels
1953563f3b4cStracker-user     * reopen at the correct position instead of falling back to the
1954563f3b4cStracker-user     * bottom of the page.
1955563f3b4cStracker-user     *
1956563f3b4cStracker-user     * @param {object} fresh  annotation object from the server
1957563f3b4cStracker-user     * @returns {object}      the same object, augmented
1958563f3b4cStracker-user     */
1959563f3b4cStracker-user    function mergeClientProps(fresh) {
1960563f3b4cStracker-user        var existing = _annotations.get(fresh.id);
1961563f3b4cStracker-user        if (existing) {
1962563f3b4cStracker-user            fresh._highlightEl = existing._highlightEl;
1963563f3b4cStracker-user            fresh._markerEl    = existing._markerEl;
1964563f3b4cStracker-user            fresh._orphaned    = existing._orphaned;
1965563f3b4cStracker-user            fresh._range       = existing._range;
1966563f3b4cStracker-user        }
1967563f3b4cStracker-user        return fresh;
1968563f3b4cStracker-user    }
1969563f3b4cStracker-user
1970563f3b4cStracker-user    /**
1971da56206cStracker-user     * The per-plugin JS language bundle, exposed by DokuWiki as
1972da56206cStracker-user     * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']).
1973da56206cStracker-user     *
1974da56206cStracker-user     * @returns {object}
1975da56206cStracker-user     */
1976da56206cStracker-user    function uiLang() {
1977da56206cStracker-user        if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) {
1978da56206cStracker-user            return LANG.plugins.annotations;
1979da56206cStracker-user        }
1980da56206cStracker-user        return {};
1981da56206cStracker-user    }
1982da56206cStracker-user
1983da56206cStracker-user    /**
1984da56206cStracker-user     * Look up a UI string by key, falling back to the supplied English text if
1985da56206cStracker-user     * the bundle is missing the key (e.g. a lang file not yet updated).
1986da56206cStracker-user     *
1987da56206cStracker-user     * @param {string} key
1988da56206cStracker-user     * @param {string} fallback  English default
1989da56206cStracker-user     * @returns {string}
1990da56206cStracker-user     */
1991da56206cStracker-user    function t(key, fallback) {
1992da56206cStracker-user        var s = _lang[key];
1993da56206cStracker-user        return (s === undefined || s === null || s === '') ? fallback : s;
1994da56206cStracker-user    }
1995da56206cStracker-user
1996da56206cStracker-user    /**
1997da56206cStracker-user     * Substitute a single %d placeholder with a number.
1998da56206cStracker-user     *
1999da56206cStracker-user     * @param {string} str
2000da56206cStracker-user     * @param {number} n
2001da56206cStracker-user     * @returns {string}
2002da56206cStracker-user     */
2003da56206cStracker-user    function fmt(str, n) {
2004da56206cStracker-user        return String(str).replace('%d', n);
2005da56206cStracker-user    }
2006da56206cStracker-user
2007da56206cStracker-user    /**
2008da56206cStracker-user     * Show a localised error, appending the server's reason in parentheses
2009da56206cStracker-user     * when one is present.
2010da56206cStracker-user     *
2011da56206cStracker-user     * @param {string} base  localised message
2012da56206cStracker-user     * @param {object} data  AJAX response ({error?:string})
2013da56206cStracker-user     */
2014da56206cStracker-user    function showError(base, data) {
2015da56206cStracker-user        var reason = (data && data.error) ? data.error : '';
2016da56206cStracker-user        alert(reason ? base + ' (' + reason + ')' : base);
2017da56206cStracker-user    }
2018da56206cStracker-user
2019da56206cStracker-user    /**
202043d2073cStracker-user     * Collapse consecutive whitespace to a single space and trim.
202143d2073cStracker-user     *
202243d2073cStracker-user     * @param {string} s
202343d2073cStracker-user     * @returns {string}
202443d2073cStracker-user     */
202543d2073cStracker-user    function normalizeWS(s) {
202643d2073cStracker-user        return String(s || '').replace(/\s+/g, ' ').trim();
202743d2073cStracker-user    }
202843d2073cStracker-user
202943d2073cStracker-user    /**
203043d2073cStracker-user     * Return the current DokuWiki username from JSINFO.
203143d2073cStracker-user     *
203243d2073cStracker-user     * @returns {string}
203343d2073cStracker-user     */
203443d2073cStracker-user    function currentUser() {
203543d2073cStracker-user        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
20367d2714c7Stracker-user        return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : '';
203743d2073cStracker-user    }
203843d2073cStracker-user
203943d2073cStracker-user    /**
204043d2073cStracker-user     * Format a Date for display.
204143d2073cStracker-user     *
204243d2073cStracker-user     * @param {Date} d
204343d2073cStracker-user     * @returns {string}
204443d2073cStracker-user     */
204543d2073cStracker-user    function formatDate(d) {
204643d2073cStracker-user        var now  = new Date();
204743d2073cStracker-user        var diff = (now - d) / 1000; // seconds
2048da56206cStracker-user        if (diff < 60)        return t('time_now', 'just now');
2049da56206cStracker-user        if (diff < 3600)      return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60));
2050da56206cStracker-user        if (diff < 86400)     return fmt(t('time_hours',   '%dh ago'), Math.floor(diff / 3600));
2051da56206cStracker-user        if (diff < 86400 * 7) return fmt(t('time_days',    '%dd ago'), Math.floor(diff / 86400));
205243d2073cStracker-user        return d.toLocaleDateString();
205343d2073cStracker-user    }
205443d2073cStracker-user
205543d2073cStracker-user    // -----------------------------------------------------------------------
205643d2073cStracker-user    // Init
205743d2073cStracker-user    // -----------------------------------------------------------------------
205843d2073cStracker-user
205943d2073cStracker-user    if (document.readyState === 'loading') {
206043d2073cStracker-user        document.addEventListener('DOMContentLoaded', boot);
206143d2073cStracker-user    } else {
206243d2073cStracker-user        boot();
206343d2073cStracker-user    }
206443d2073cStracker-user
206543d2073cStracker-user}());
2066