xref: /plugin/annotations/script.js (revision da56206cc13612db0df36be97c0f01d8f3c5e9f4)
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
3043d2073cStracker-user *      reachable via the orphaned counter link; their panels open in a
3143d2073cStracker-user *      dedicated orphan drawer at the bottom of the content area.
3243d2073cStracker-user *
3343d2073cStracker-user * FF78 ESR compatibility:
3443d2073cStracker-user *   - No #private fields, ??=, ||=, &&=, Array.at, structuredClone,
3543d2073cStracker-user *     Object.hasOwn, native <dialog>.
3643d2073cStracker-user *   - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK.
3743d2073cStracker-user */
3843d2073cStracker-user
3943d2073cStracker-user(function () {
4043d2073cStracker-user    'use strict';
4143d2073cStracker-user
4243d2073cStracker-user    // -----------------------------------------------------------------------
4343d2073cStracker-user    // Constants
4443d2073cStracker-user    // -----------------------------------------------------------------------
4543d2073cStracker-user
4643d2073cStracker-user    var AJAX_URL   = DOKU_BASE + 'lib/exe/ajax.php?call=annotations';
4743d2073cStracker-user    var CONTENT_ID = 'dokuwiki__content';
48b8076f00Stracker-user    // .page is the article area inside #dokuwiki__content. Gutter markers
49b8076f00Stracker-user    // are appended here so position:relative doesn't break the sidebar nav.
50b8076f00Stracker-user    var PAGE_CLS = 'page';
5143d2073cStracker-user
5243d2073cStracker-user    // Colour tokens (also defined in style.css; kept here so JS can read them)
5343d2073cStracker-user    var CLS_HIGHLIGHT_OPEN     = 'ann-highlight-open';
5443d2073cStracker-user    var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved';
5543d2073cStracker-user    var CLS_GUTTER_MARKER      = 'ann-gutter-marker';
5643d2073cStracker-user    var CLS_PANEL              = 'ann-panel';
5743d2073cStracker-user    var CLS_COUNTER            = 'ann-counter';
5843d2073cStracker-user    var CLS_TOOLTIP            = 'ann-tooltip';
5943d2073cStracker-user    var CLS_ORPHAN_DRAWER      = 'ann-orphan-drawer';
6043d2073cStracker-user
6143d2073cStracker-user    // -----------------------------------------------------------------------
6243d2073cStracker-user    // State
6343d2073cStracker-user    // -----------------------------------------------------------------------
6443d2073cStracker-user
6543d2073cStracker-user    /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */
6643d2073cStracker-user    var _annotations = new Map();
6743d2073cStracker-user
6843d2073cStracker-user    /** Currently open panel element, or null. @type {HTMLElement|null} */
6943d2073cStracker-user    var _openPanel = null;
7043d2073cStracker-user
7150325813Stracker-user    /** Anchor captured on tooltip button mousedown; consumed by click. @type {object|null} */
7250325813Stracker-user    var _pendingAnchor = null;
7350325813Stracker-user
7443d2073cStracker-user    /** ID of the annotation whose panel is open, or null. @type {string|null} */
7543d2073cStracker-user    var _openAnnId = null;
7643d2073cStracker-user
7743d2073cStracker-user    /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */
7843d2073cStracker-user    var _info = {};
7943d2073cStracker-user
8043d2073cStracker-user    /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */
8143d2073cStracker-user    var _lang = {};
8243d2073cStracker-user
8343d2073cStracker-user    /** The DokuWiki security token. @type {string} */
8443d2073cStracker-user    var _token = '';
8543d2073cStracker-user
8643d2073cStracker-user    /** Whether the current user is logged in. @type {bool} */
8743d2073cStracker-user    var _loggedIn = false;
8843d2073cStracker-user
8943d2073cStracker-user    /** Whether the current user is an admin. @type {bool} */
9043d2073cStracker-user    var _isAdmin = false;
9143d2073cStracker-user
9243d2073cStracker-user    // -----------------------------------------------------------------------
9343d2073cStracker-user    // Boot
9443d2073cStracker-user    // -----------------------------------------------------------------------
9543d2073cStracker-user
9643d2073cStracker-user    /**
9743d2073cStracker-user     * Entry point: wired to DOMContentLoaded.
9843d2073cStracker-user     */
9943d2073cStracker-user    function boot() {
10043d2073cStracker-user        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
10143d2073cStracker-user        var annInfo = jsinfo.annotations || {};
10243d2073cStracker-user
10343d2073cStracker-user        if (!annInfo.enabled) {
10443d2073cStracker-user            return; // user disabled annotations
10543d2073cStracker-user        }
10643d2073cStracker-user
10743d2073cStracker-user        _info      = annInfo;
108*da56206cStracker-user        // UI strings come from DokuWiki's per-plugin JS lang bundle, exposed as
109*da56206cStracker-user        // LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']).
110*da56206cStracker-user        _lang      = uiLang();
1117d2714c7Stracker-user        // Token is injected into JSINFO.annotations by action.php (handleMetaHeader).
1127d2714c7Stracker-user        // getSecurityToken() on the server produces it from session_id + REMOTE_USER.
1137d2714c7Stracker-user        _token     = annInfo.token || '';
11443d2073cStracker-user
1157d2714c7Stracker-user        // DokuWiki's JSINFO doesn't include user identity; we inject
1167d2714c7Stracker-user        // user + isAdmin into JSINFO.annotations from PHP (action.php).
1177d2714c7Stracker-user        _loggedIn = !!(annInfo.user && annInfo.user !== '');
1187d2714c7Stracker-user        _isAdmin  = !!(annInfo.isAdmin);
11943d2073cStracker-user
12043d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
12143d2073cStracker-user        if (!content) {
12243d2073cStracker-user            return; // not a page view
12343d2073cStracker-user        }
12443d2073cStracker-user
12543d2073cStracker-user        renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0);
12643d2073cStracker-user        loadAnnotations();
12743d2073cStracker-user        initSelectionCapture(content);
12843d2073cStracker-user    }
12943d2073cStracker-user
13043d2073cStracker-user    // -----------------------------------------------------------------------
13143d2073cStracker-user    // AJAX helpers
13243d2073cStracker-user    // -----------------------------------------------------------------------
13343d2073cStracker-user
13443d2073cStracker-user    /**
13543d2073cStracker-user     * POST a JSON payload to the AJAX endpoint.
13643d2073cStracker-user     *
13743d2073cStracker-user     * @param {object} payload
13843d2073cStracker-user     * @returns {Promise<object>} response data
13943d2073cStracker-user     */
14043d2073cStracker-user    function ajax(payload) {
14143d2073cStracker-user        payload.sectok = _token; // DokuWiki security token field name for AJAX
14243d2073cStracker-user        return fetch(AJAX_URL, {
14343d2073cStracker-user            method:  'POST',
14443d2073cStracker-user            headers: {'Content-Type': 'application/json'},
14543d2073cStracker-user            body:    JSON.stringify(payload),
14643d2073cStracker-user        }).then(function (res) {
14743d2073cStracker-user            return res.json();
14843d2073cStracker-user        });
14943d2073cStracker-user    }
15043d2073cStracker-user
15143d2073cStracker-user    // -----------------------------------------------------------------------
15243d2073cStracker-user    // Load and anchor annotations
15343d2073cStracker-user    // -----------------------------------------------------------------------
15443d2073cStracker-user
15543d2073cStracker-user    /**
15643d2073cStracker-user     * Fetch all annotations for the current page and render them.
15743d2073cStracker-user     */
15843d2073cStracker-user    function loadAnnotations() {
15943d2073cStracker-user        // We use a lightweight GET-style call: the action.php AJAX handler
16043d2073cStracker-user        // is POST-only, so we pass action=load in the payload.
16143d2073cStracker-user        fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), {
16243d2073cStracker-user            method: 'GET',
16343d2073cStracker-user        }).then(function (res) {
16443d2073cStracker-user            return res.json();
16543d2073cStracker-user        }).then(function (data) {
16643d2073cStracker-user            if (!data || !Array.isArray(data.annotations)) {
16743d2073cStracker-user                return;
16843d2073cStracker-user            }
16943d2073cStracker-user            data.annotations.forEach(function (ann) {
17043d2073cStracker-user                _annotations.set(ann.id, ann);
17143d2073cStracker-user            });
17243d2073cStracker-user            renderAll();
17343d2073cStracker-user        }).catch(function () {
17443d2073cStracker-user            // Graceful degradation: page still works without annotations.
17543d2073cStracker-user        });
17643d2073cStracker-user    }
17743d2073cStracker-user
17843d2073cStracker-user    /**
17943d2073cStracker-user     * Re-render everything: highlights, gutter markers, counter.
18043d2073cStracker-user     */
18143d2073cStracker-user    function renderAll() {
18243d2073cStracker-user        clearHighlights();
18343d2073cStracker-user        clearGutterMarkers();
18443d2073cStracker-user
18543d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
18643d2073cStracker-user        if (!content) return;
18743d2073cStracker-user
188*da56206cStracker-user        // Snapshot the page text ONCE, before any highlight is inserted.
189*da56206cStracker-user        // Re-collecting per annotation would exclude already-wrapped text
190*da56206cStracker-user        // (collectTextChunks skips our own UI), shifting every later anchor.
191*da56206cStracker-user        var chunks  = collectTextChunks(content);
192*da56206cStracker-user        var rawFull = chunks.map(function (c) { return c.text; }).join('');
193*da56206cStracker-user        var nm      = normalizeWithMap(rawFull);
19443d2073cStracker-user
195*da56206cStracker-user        // Phase 1 — locate every annotation against the clean snapshot.
196*da56206cStracker-user        var hits = [];
19743d2073cStracker-user        _annotations.forEach(function (ann) {
198*da56206cStracker-user            ann._range       = null;
199*da56206cStracker-user            ann._highlightEl = null;
200*da56206cStracker-user            var hit = ann.anchor ? locate(nm.norm, ann.anchor) : null;
201*da56206cStracker-user            if (hit) {
202*da56206cStracker-user                hits.push({ann: ann, pos: hit.pos, len: hit.len});
203*da56206cStracker-user                ann._orphaned = false;
20443d2073cStracker-user            } else {
205*da56206cStracker-user                ann._orphaned = true;
206*da56206cStracker-user            }
207*da56206cStracker-user        });
208*da56206cStracker-user
209*da56206cStracker-user        // Phase 2 — wrap later matches first, so wrapping (which splits text
210*da56206cStracker-user        // nodes) never invalidates the offsets of earlier, not-yet-wrapped ones.
211*da56206cStracker-user        hits.sort(function (a, b) { return b.pos - a.pos; });
212*da56206cStracker-user        hits.forEach(function (h) {
213*da56206cStracker-user            var range = buildRange(chunks, nm.map, h.pos, h.len);
214*da56206cStracker-user            if (range) {
215*da56206cStracker-user                h.ann._range = range; // cache for panel positioning
216*da56206cStracker-user                wrapHighlight(range, h.ann);
217*da56206cStracker-user            } else {
218*da56206cStracker-user                h.ann._orphaned = true;
21943d2073cStracker-user            }
22043d2073cStracker-user        });
22143d2073cStracker-user
22243d2073cStracker-user        renderGutterMarkers();
223*da56206cStracker-user        updateCounter(); // recounts orphans from the _orphaned flags set above
22443d2073cStracker-user    }
22543d2073cStracker-user
22643d2073cStracker-user    // -----------------------------------------------------------------------
22743d2073cStracker-user    // Text anchoring (re-anchoring)
22843d2073cStracker-user    // -----------------------------------------------------------------------
22943d2073cStracker-user
23043d2073cStracker-user    /**
231*da56206cStracker-user     * Locate an anchor's quoted text within the normalised page text.
23243d2073cStracker-user     *
23343d2073cStracker-user     * Algorithm:
234*da56206cStracker-user     *   1. Search for the exact quote (normalised).
235*da56206cStracker-user     *   2. If found multiple times, use prefix/suffix to disambiguate.
236*da56206cStracker-user     *   3. If still ambiguous, use the start offset hint.
23743d2073cStracker-user     *
238*da56206cStracker-user     * Returns offsets into the normalised string; buildRange maps them back
239*da56206cStracker-user     * to a DOM Range via the normalised→raw index map.
240*da56206cStracker-user     *
241*da56206cStracker-user     * @param {string} norm    normalised page text (from normalizeWithMap)
24243d2073cStracker-user     * @param {object} anchor  {exact, prefix, suffix, start}
243*da56206cStracker-user     * @returns {{pos:number, len:number}|null}
24443d2073cStracker-user     */
245*da56206cStracker-user    function locate(norm, anchor) {
24643d2073cStracker-user        if (!anchor || !anchor.exact) return null;
24743d2073cStracker-user
24843d2073cStracker-user        var exact = normalizeWS(anchor.exact);
249*da56206cStracker-user        if (exact === '') return null;
25043d2073cStracker-user        var prefix = normalizeWS(anchor.prefix || '');
25143d2073cStracker-user        var suffix = normalizeWS(anchor.suffix || '');
25243d2073cStracker-user        var hint   = anchor.start || 0;
25343d2073cStracker-user
25443d2073cStracker-user        // Find all occurrences of exact.
25543d2073cStracker-user        var positions = [];
256*da56206cStracker-user        var from = 0;
25743d2073cStracker-user        var idx;
258*da56206cStracker-user        while ((idx = norm.indexOf(exact, from)) !== -1) {
259*da56206cStracker-user            positions.push(idx);
260*da56206cStracker-user            from = idx + exact.length;
26143d2073cStracker-user        }
26243d2073cStracker-user
26343d2073cStracker-user        if (positions.length === 0) return null;
26443d2073cStracker-user
265*da56206cStracker-user        var chosen = positions[0];
26643d2073cStracker-user
26743d2073cStracker-user        if (positions.length > 1) {
268*da56206cStracker-user            // Disambiguate using prefix + suffix context, tie-break on the hint.
26943d2073cStracker-user            var bestScore = -1;
27043d2073cStracker-user            positions.forEach(function (pos) {
271*da56206cStracker-user                var pre = norm.slice(Math.max(0, pos - prefix.length), pos);
272*da56206cStracker-user                var suf = norm.slice(pos + exact.length, pos + exact.length + suffix.length);
27343d2073cStracker-user                var score = 0;
27443d2073cStracker-user                if (prefix && pre.indexOf(prefix) !== -1) score++;
27543d2073cStracker-user                if (suffix && suf.indexOf(suffix) !== -1) score++;
27643d2073cStracker-user                var distToHint = Math.abs(pos - hint);
277*da56206cStracker-user                if (score > bestScore ||
278*da56206cStracker-user                    (score === bestScore && distToHint < Math.abs(chosen - hint))) {
27943d2073cStracker-user                    bestScore = score;
280*da56206cStracker-user                    chosen    = pos;
28143d2073cStracker-user                }
28243d2073cStracker-user            });
28343d2073cStracker-user        }
28443d2073cStracker-user
285*da56206cStracker-user        return {pos: chosen, len: exact.length};
28643d2073cStracker-user    }
28743d2073cStracker-user
28843d2073cStracker-user    /**
28943d2073cStracker-user     * Walk the text nodes under root and return an array of
29043d2073cStracker-user     * {node, start, text} objects where start is the cumulative character
29143d2073cStracker-user     * offset of this node's text in the joined string.
29243d2073cStracker-user     *
29343d2073cStracker-user     * The joined string is NOT normalised here — we normalise the full string
29443d2073cStracker-user     * once above instead.
29543d2073cStracker-user     *
29643d2073cStracker-user     * @param {HTMLElement} root
29743d2073cStracker-user     * @returns {Array<{node:Text, start:number, text:string}>}
29843d2073cStracker-user     */
29943d2073cStracker-user    function collectTextChunks(root) {
30043d2073cStracker-user        var walker = document.createTreeWalker(
30143d2073cStracker-user            root,
30243d2073cStracker-user            NodeFilter.SHOW_TEXT,
30343d2073cStracker-user            null,
30443d2073cStracker-user            false
30543d2073cStracker-user        );
30643d2073cStracker-user        var chunks = [];
30743d2073cStracker-user        var offset = 0;
30843d2073cStracker-user        var node;
30943d2073cStracker-user        while ((node = walker.nextNode())) {
31043d2073cStracker-user            // Skip nodes inside our own UI elements.
31143d2073cStracker-user            if (isAnnotationUI(node.parentNode)) continue;
31243d2073cStracker-user            var text = node.nodeValue || '';
31343d2073cStracker-user            chunks.push({node: node, start: offset, text: text});
31443d2073cStracker-user            offset += text.length;
31543d2073cStracker-user        }
31643d2073cStracker-user        return chunks;
31743d2073cStracker-user    }
31843d2073cStracker-user
31943d2073cStracker-user    /**
32043d2073cStracker-user     * True if the element (or its ancestor) is part of our annotation UI.
32143d2073cStracker-user     *
32243d2073cStracker-user     * @param {Node} el
32343d2073cStracker-user     * @returns {bool}
32443d2073cStracker-user     */
32543d2073cStracker-user    function isAnnotationUI(el) {
32643d2073cStracker-user        while (el && el !== document.body) {
32743d2073cStracker-user            if (el.nodeType === 1) {
32843d2073cStracker-user                var cls = el.className || '';
32943d2073cStracker-user                if (
33043d2073cStracker-user                    cls.indexOf('ann-') !== -1 ||
33143d2073cStracker-user                    cls.indexOf(CLS_PANEL) !== -1
33243d2073cStracker-user                ) {
33343d2073cStracker-user                    return true;
33443d2073cStracker-user                }
33543d2073cStracker-user            }
33643d2073cStracker-user            el = el.parentNode;
33743d2073cStracker-user        }
33843d2073cStracker-user        return false;
33943d2073cStracker-user    }
34043d2073cStracker-user
34143d2073cStracker-user    /**
342*da56206cStracker-user     * Turn a (start, length) offset in the normalised page text back into a
343*da56206cStracker-user     * DOM Range, using the normalised→raw index map.
34443d2073cStracker-user     *
34543d2073cStracker-user     * @param {Array<{node:Text, start:number, text:string}>} chunks
346*da56206cStracker-user     * @param {Array<number>} map       normalised index → raw index (normalizeWithMap)
347*da56206cStracker-user     * @param {number}        startOff  start offset in the normalised text
348*da56206cStracker-user     * @param {number}        length    length in normalised characters
34943d2073cStracker-user     * @returns {Range|null}
35043d2073cStracker-user     */
351*da56206cStracker-user    function buildRange(chunks, map, startOff, length) {
352*da56206cStracker-user        var rawStart = map[startOff];
353*da56206cStracker-user        var rawEnd   = map[startOff + length - 1];
35443d2073cStracker-user        if (rawStart === undefined || rawEnd === undefined) return null;
35543d2073cStracker-user        rawEnd++; // exclusive
35643d2073cStracker-user
35743d2073cStracker-user        // Find which chunks contain rawStart and rawEnd.
35843d2073cStracker-user        var startChunk = null, startOffset = 0;
35943d2073cStracker-user        var endChunk   = null, endOffset   = 0;
36043d2073cStracker-user
36143d2073cStracker-user        for (var i = 0; i < chunks.length; i++) {
36243d2073cStracker-user            var c = chunks[i];
36343d2073cStracker-user            var cEnd = c.start + c.text.length;
36443d2073cStracker-user
36543d2073cStracker-user            if (startChunk === null && c.start <= rawStart && rawStart < cEnd) {
36643d2073cStracker-user                startChunk  = c.node;
36743d2073cStracker-user                startOffset = rawStart - c.start;
36843d2073cStracker-user            }
36943d2073cStracker-user            if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) {
37043d2073cStracker-user                endChunk  = c.node;
37143d2073cStracker-user                endOffset = rawEnd - c.start;
37243d2073cStracker-user            }
37343d2073cStracker-user            if (startChunk && endChunk) break;
37443d2073cStracker-user        }
37543d2073cStracker-user
37643d2073cStracker-user        if (!startChunk || !endChunk) return null;
37743d2073cStracker-user
37843d2073cStracker-user        try {
37943d2073cStracker-user            var range = document.createRange();
38043d2073cStracker-user            range.setStart(startChunk, startOffset);
38143d2073cStracker-user            range.setEnd(endChunk, endOffset);
38243d2073cStracker-user            return range;
38343d2073cStracker-user        } catch (e) {
38443d2073cStracker-user            return null;
38543d2073cStracker-user        }
38643d2073cStracker-user    }
38743d2073cStracker-user
38843d2073cStracker-user    /**
389*da56206cStracker-user     * Normalise raw text exactly as normalizeWS does (collapse each whitespace
390*da56206cStracker-user     * run to a single space, trim both ends) while recording, for every
391*da56206cStracker-user     * character of the normalised string, the index of the raw character it
392*da56206cStracker-user     * came from. Returns {norm, map} with raw.charAt(map[i]) === norm.charAt(i)
393*da56206cStracker-user     * (a collapsed internal space maps to the first char of its run).
394*da56206cStracker-user     *
395*da56206cStracker-user     * Normalisation and the index map MUST stay in lockstep: an earlier
396*da56206cStracker-user     * version built the map without trimming, so a leading whitespace text
397*da56206cStracker-user     * node (DokuWiki indents its content markup, so there always is one)
398*da56206cStracker-user     * shifted every highlight one character to the left.
39943d2073cStracker-user     *
40043d2073cStracker-user     * @param {string} raw
401*da56206cStracker-user     * @returns {{norm:string, map:Array<number>}}
40243d2073cStracker-user     */
403*da56206cStracker-user    function normalizeWithMap(raw) {
404*da56206cStracker-user        var norm     = '';
40543d2073cStracker-user        var map      = [];
406*da56206cStracker-user        var inRun    = false;
407*da56206cStracker-user        var runStart = 0;
40843d2073cStracker-user        for (var i = 0; i < raw.length; i++) {
409*da56206cStracker-user            if (/\s/.test(raw[i])) {
410*da56206cStracker-user                if (!inRun) { inRun = true; runStart = i; }
411*da56206cStracker-user                continue;
41243d2073cStracker-user            }
413*da56206cStracker-user            if (inRun) {
414*da56206cStracker-user                inRun = false;
415*da56206cStracker-user                // internal run → one representative space; leading run → dropped
416*da56206cStracker-user                if (norm.length > 0) {
417*da56206cStracker-user                    norm += ' ';
418*da56206cStracker-user                    map.push(runStart);
419*da56206cStracker-user                }
420*da56206cStracker-user            }
421*da56206cStracker-user            norm += raw[i];
42243d2073cStracker-user            map.push(i);
42343d2073cStracker-user        }
424*da56206cStracker-user        // a trailing whitespace run is dropped (matches trim)
425*da56206cStracker-user        return {norm: norm, map: map};
42643d2073cStracker-user    }
42743d2073cStracker-user
42843d2073cStracker-user    // -----------------------------------------------------------------------
42943d2073cStracker-user    // Highlights
43043d2073cStracker-user    // -----------------------------------------------------------------------
43143d2073cStracker-user
43243d2073cStracker-user    /**
43343d2073cStracker-user     * Wrap a Range in a highlight <span> for the given annotation.
43443d2073cStracker-user     *
43543d2073cStracker-user     * @param {Range}  range
43643d2073cStracker-user     * @param {object} ann
43743d2073cStracker-user     */
43843d2073cStracker-user    function wrapHighlight(range, ann) {
43943d2073cStracker-user        try {
44043d2073cStracker-user            var span = document.createElement('span');
44143d2073cStracker-user            span.className = ann.status === 'resolved'
44243d2073cStracker-user                ? CLS_HIGHLIGHT_RESOLVED
44343d2073cStracker-user                : CLS_HIGHLIGHT_OPEN;
44443d2073cStracker-user            span.dataset.annId = ann.id;
44543d2073cStracker-user            span.title = ann.body.slice(0, 80) + (ann.body.length > 80 ? '…' : '');
44643d2073cStracker-user            span.addEventListener('click', function (e) {
44743d2073cStracker-user                e.stopPropagation();
44843d2073cStracker-user                openPanel(ann.id);
44943d2073cStracker-user            });
45043d2073cStracker-user            range.surroundContents(span);
45143d2073cStracker-user            ann._highlightEl = span;
45243d2073cStracker-user        } catch (e) {
45343d2073cStracker-user            // surroundContents throws if the range crosses element boundaries.
45443d2073cStracker-user            // Fall back to insertNode with a cloned range fragment.
45543d2073cStracker-user            try {
45643d2073cStracker-user                var frag = range.extractContents();
45743d2073cStracker-user                var span2 = document.createElement('span');
45843d2073cStracker-user                span2.className = ann.status === 'resolved'
45943d2073cStracker-user                    ? CLS_HIGHLIGHT_RESOLVED
46043d2073cStracker-user                    : CLS_HIGHLIGHT_OPEN;
46143d2073cStracker-user                span2.dataset.annId = ann.id;
46243d2073cStracker-user                span2.appendChild(frag);
46343d2073cStracker-user                span2.addEventListener('click', function (e) {
46443d2073cStracker-user                    e.stopPropagation();
46543d2073cStracker-user                    openPanel(ann.id);
46643d2073cStracker-user                });
46743d2073cStracker-user                range.insertNode(span2);
46843d2073cStracker-user                ann._highlightEl = span2;
46943d2073cStracker-user            } catch (e2) {
47043d2073cStracker-user                ann._highlightEl = null;
47143d2073cStracker-user            }
47243d2073cStracker-user        }
47343d2073cStracker-user    }
47443d2073cStracker-user
47543d2073cStracker-user    /**
47643d2073cStracker-user     * Remove all highlight spans, restoring the original text nodes.
47743d2073cStracker-user     */
47843d2073cStracker-user    function clearHighlights() {
47943d2073cStracker-user        var spans = document.querySelectorAll(
480*da56206cStracker-user            '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED
48143d2073cStracker-user        );
48243d2073cStracker-user        Array.prototype.forEach.call(spans, function (span) {
48343d2073cStracker-user            var parent = span.parentNode;
48443d2073cStracker-user            if (!parent) return;
48543d2073cStracker-user            while (span.firstChild) {
48643d2073cStracker-user                parent.insertBefore(span.firstChild, span);
48743d2073cStracker-user            }
48843d2073cStracker-user            parent.removeChild(span);
48943d2073cStracker-user            parent.normalize();
49043d2073cStracker-user        });
49143d2073cStracker-user    }
49243d2073cStracker-user
49343d2073cStracker-user    // -----------------------------------------------------------------------
49443d2073cStracker-user    // Gutter markers
49543d2073cStracker-user    // -----------------------------------------------------------------------
49643d2073cStracker-user
49743d2073cStracker-user    /**
49843d2073cStracker-user     * Render a small marker in the gutter for every anchored annotation.
49943d2073cStracker-user     * Markers are absolutely positioned relative to the content wrapper.
50043d2073cStracker-user     */
50143d2073cStracker-user    function renderGutterMarkers() {
502b8076f00Stracker-user        // Append markers to .page (position:relative), not #dokuwiki__content
503b8076f00Stracker-user        // (which also wraps the sidebar nav and would capture pointer events).
504b8076f00Stracker-user        var pageEl = document.querySelector('.' + PAGE_CLS);
505b8076f00Stracker-user        if (!pageEl) return;
50643d2073cStracker-user
50743d2073cStracker-user        _annotations.forEach(function (ann) {
50843d2073cStracker-user            if (!ann._highlightEl) return; // orphan
50943d2073cStracker-user
51043d2073cStracker-user            var el      = ann._highlightEl;
51143d2073cStracker-user            var rect    = el.getBoundingClientRect();
512b8076f00Stracker-user            var pageRect = pageEl.getBoundingClientRect();
51343d2073cStracker-user
51443d2073cStracker-user            var marker = document.createElement('button');
51543d2073cStracker-user            marker.className  = CLS_GUTTER_MARKER;
51643d2073cStracker-user            marker.dataset.annId = ann.id;
517*da56206cStracker-user            marker.setAttribute('aria-label', t('label_annotation', 'Annotation'));
51843d2073cStracker-user            marker.type = 'button';
519b8076f00Stracker-user            // top is relative to .page's top edge + its current scroll offset
520b8076f00Stracker-user            marker.style.top = (rect.top - pageRect.top + pageEl.scrollTop) + 'px';
52143d2073cStracker-user            marker.addEventListener('click', function (e) {
52243d2073cStracker-user                e.stopPropagation();
52343d2073cStracker-user                openPanel(ann.id);
52443d2073cStracker-user            });
525b8076f00Stracker-user            pageEl.appendChild(marker);
52643d2073cStracker-user            ann._markerEl = marker;
52743d2073cStracker-user        });
52843d2073cStracker-user    }
52943d2073cStracker-user
53043d2073cStracker-user    /**
53143d2073cStracker-user     * Remove all gutter markers.
53243d2073cStracker-user     */
53343d2073cStracker-user    function clearGutterMarkers() {
53443d2073cStracker-user        var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER);
53543d2073cStracker-user        Array.prototype.forEach.call(markers, function (m) {
53643d2073cStracker-user            if (m.parentNode) m.parentNode.removeChild(m);
53743d2073cStracker-user        });
53843d2073cStracker-user    }
53943d2073cStracker-user
54043d2073cStracker-user    // -----------------------------------------------------------------------
54143d2073cStracker-user    // Page counter
54243d2073cStracker-user    // -----------------------------------------------------------------------
54343d2073cStracker-user
54443d2073cStracker-user    /**
54543d2073cStracker-user     * Render (or update) the counter bubble above the content area.
54643d2073cStracker-user     *
54743d2073cStracker-user     * @param {object} stats        {total, open, resolved}
54843d2073cStracker-user     * @param {number} orphanCount
54943d2073cStracker-user     */
55043d2073cStracker-user    function renderCounter(stats, orphanCount) {
55143d2073cStracker-user        var existing = document.getElementById('ann-counter-bar');
55243d2073cStracker-user        if (existing) existing.parentNode.removeChild(existing);
55343d2073cStracker-user
55443d2073cStracker-user        if (stats.total === 0 && orphanCount === 0) return;
55543d2073cStracker-user
55643d2073cStracker-user        var bar = document.createElement('div');
55743d2073cStracker-user        bar.id = 'ann-counter-bar';
55843d2073cStracker-user        bar.className = CLS_COUNTER;
55943d2073cStracker-user
56043d2073cStracker-user        var total = stats.total || 0;
56143d2073cStracker-user        var label = total === 1
562*da56206cStracker-user            ? t('counter_annotation', '1 annotation')
563*da56206cStracker-user            : fmt(t('counter_annotations', '%d annotations'), total);
56443d2073cStracker-user        bar.appendChild(document.createTextNode(label));
56543d2073cStracker-user
56643d2073cStracker-user        if (orphanCount > 0) {
56743d2073cStracker-user            bar.appendChild(document.createTextNode(' · '));
56843d2073cStracker-user            var orphanLink = document.createElement('a');
56943d2073cStracker-user            orphanLink.href = '#ann-orphan-drawer';
57043d2073cStracker-user            orphanLink.className = 'ann-orphan-link';
571*da56206cStracker-user            orphanLink.textContent = fmt(t('counter_orphaned', '%d orphaned'), orphanCount);
57243d2073cStracker-user            orphanLink.addEventListener('click', function (e) {
57343d2073cStracker-user                e.preventDefault();
57443d2073cStracker-user                toggleOrphanDrawer();
57543d2073cStracker-user            });
57643d2073cStracker-user            bar.appendChild(orphanLink);
57743d2073cStracker-user        }
57843d2073cStracker-user
57943d2073cStracker-user        if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) {
58043d2073cStracker-user            if (stats.resolved > 0) {
58143d2073cStracker-user                var btnCR = document.createElement('button');
58243d2073cStracker-user                btnCR.type = 'button';
58343d2073cStracker-user                btnCR.className = 'ann-btn ann-btn-admin';
584*da56206cStracker-user                btnCR.textContent = t('btn_clear_resolved', 'Clear resolved');
58543d2073cStracker-user                btnCR.addEventListener('click', doClearResolved);
58643d2073cStracker-user                bar.appendChild(btnCR);
58743d2073cStracker-user            }
58843d2073cStracker-user            if (orphanCount > 0) {
58943d2073cStracker-user                var btnCO = document.createElement('button');
59043d2073cStracker-user                btnCO.type = 'button';
59143d2073cStracker-user                btnCO.className = 'ann-btn ann-btn-admin';
592*da56206cStracker-user                btnCO.textContent = t('btn_clear_orphaned', 'Clear orphaned');
59343d2073cStracker-user                btnCO.addEventListener('click', doClearOrphaned);
59443d2073cStracker-user                bar.appendChild(btnCO);
59543d2073cStracker-user            }
59643d2073cStracker-user        }
59743d2073cStracker-user
59843d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
59943d2073cStracker-user        if (content && content.parentNode) {
60043d2073cStracker-user            content.parentNode.insertBefore(bar, content);
60143d2073cStracker-user        }
60243d2073cStracker-user    }
60343d2073cStracker-user
60443d2073cStracker-user    /**
60543d2073cStracker-user     * Recount and re-render the counter from in-memory state.
60643d2073cStracker-user     */
60743d2073cStracker-user    function updateCounter(orphanCount) {
60843d2073cStracker-user        var open = 0, resolved = 0;
60943d2073cStracker-user        if (orphanCount === undefined) {
61043d2073cStracker-user            orphanCount = 0;
61143d2073cStracker-user        }
61243d2073cStracker-user        _annotations.forEach(function (ann) {
61343d2073cStracker-user            if (ann._orphaned) {
61443d2073cStracker-user                orphanCount++;
61543d2073cStracker-user            } else if (ann.status === 'resolved') {
61643d2073cStracker-user                resolved++;
61743d2073cStracker-user            } else {
61843d2073cStracker-user                open++;
61943d2073cStracker-user            }
62043d2073cStracker-user        });
62143d2073cStracker-user        renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount);
62243d2073cStracker-user    }
62343d2073cStracker-user
62443d2073cStracker-user    // -----------------------------------------------------------------------
62543d2073cStracker-user    // Annotation panel
62643d2073cStracker-user    // -----------------------------------------------------------------------
62743d2073cStracker-user
62843d2073cStracker-user    /**
62943d2073cStracker-user     * Open the thread panel for the given annotation id.
63043d2073cStracker-user     * If that panel is already open, close it.
63143d2073cStracker-user     *
63243d2073cStracker-user     * @param {string} annId
63343d2073cStracker-user     */
63443d2073cStracker-user    function openPanel(annId) {
63543d2073cStracker-user        if (_openAnnId === annId) {
63643d2073cStracker-user            closePanel();
63743d2073cStracker-user            return;
63843d2073cStracker-user        }
63943d2073cStracker-user        closePanel();
64043d2073cStracker-user
64143d2073cStracker-user        var ann = _annotations.get(annId);
64243d2073cStracker-user        if (!ann) return;
64343d2073cStracker-user
64443d2073cStracker-user        var panel = buildPanel(ann);
64543d2073cStracker-user        _openPanel  = panel;
64643d2073cStracker-user        _openAnnId  = annId;
64743d2073cStracker-user
64843d2073cStracker-user        // Insert below the paragraph that contains the highlight.
64943d2073cStracker-user        var anchor = ann._highlightEl || null;
65043d2073cStracker-user        var insertAfter = findParagraph(anchor);
65143d2073cStracker-user        if (insertAfter && insertAfter.parentNode) {
65243d2073cStracker-user            insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling);
65343d2073cStracker-user        } else {
65443d2073cStracker-user            // Orphan or no paragraph found: show at the bottom of content.
65543d2073cStracker-user            var content = document.getElementById(CONTENT_ID);
65643d2073cStracker-user            if (content) content.appendChild(panel);
65743d2073cStracker-user        }
65843d2073cStracker-user
65943d2073cStracker-user        panel.querySelector('.ann-body-input') && panel.querySelector('.ann-body-input').focus();
66043d2073cStracker-user    }
66143d2073cStracker-user
66243d2073cStracker-user    /**
66343d2073cStracker-user     * Close and remove the currently open panel.
66443d2073cStracker-user     */
66543d2073cStracker-user    function closePanel() {
66643d2073cStracker-user        if (_openPanel && _openPanel.parentNode) {
66743d2073cStracker-user            _openPanel.parentNode.removeChild(_openPanel);
66843d2073cStracker-user        }
66943d2073cStracker-user        _openPanel = null;
67043d2073cStracker-user        _openAnnId = null;
67143d2073cStracker-user    }
67243d2073cStracker-user
67343d2073cStracker-user    /**
67443d2073cStracker-user     * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.)
67543d2073cStracker-user     * that can receive a sibling element.
67643d2073cStracker-user     *
67743d2073cStracker-user     * @param {HTMLElement|null} el
67843d2073cStracker-user     * @returns {HTMLElement|null}
67943d2073cStracker-user     */
68043d2073cStracker-user    function findParagraph(el) {
68143d2073cStracker-user        var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/;
68243d2073cStracker-user        var node = el;
68343d2073cStracker-user        while (node && node.id !== CONTENT_ID) {
68443d2073cStracker-user            if (node.nodeType === 1 && block.test(node.tagName)) {
68543d2073cStracker-user                return node;
68643d2073cStracker-user            }
68743d2073cStracker-user            node = node.parentNode;
68843d2073cStracker-user        }
68943d2073cStracker-user        return el; // fallback: use the element itself
69043d2073cStracker-user    }
69143d2073cStracker-user
69243d2073cStracker-user    /**
69343d2073cStracker-user     * Build and return the panel DOM element for one annotation.
69443d2073cStracker-user     *
69543d2073cStracker-user     * @param {object} ann
69643d2073cStracker-user     * @returns {HTMLElement}
69743d2073cStracker-user     */
69843d2073cStracker-user    function buildPanel(ann) {
69943d2073cStracker-user        var panel = document.createElement('div');
70043d2073cStracker-user        panel.className = CLS_PANEL;
70143d2073cStracker-user        panel.dataset.annId  = ann.id;
702*da56206cStracker-user        panel.dataset.status = ann.status || 'open'; // drives the resolved accent in style.css
70343d2073cStracker-user
70443d2073cStracker-user        // Header
70543d2073cStracker-user        var header = document.createElement('div');
70643d2073cStracker-user        header.className = 'ann-panel-header';
70743d2073cStracker-user
70843d2073cStracker-user        var closeBtn = document.createElement('button');
70943d2073cStracker-user        closeBtn.type = 'button';
71043d2073cStracker-user        closeBtn.className = 'ann-btn ann-close';
711*da56206cStracker-user        closeBtn.setAttribute('aria-label', t('label_close', 'Close'));
71243d2073cStracker-user        closeBtn.textContent = '×';
71343d2073cStracker-user        closeBtn.addEventListener('click', closePanel);
71443d2073cStracker-user        header.appendChild(closeBtn);
71543d2073cStracker-user
71643d2073cStracker-user        panel.appendChild(header);
71743d2073cStracker-user
71843d2073cStracker-user        // Main annotation thread entry
71943d2073cStracker-user        panel.appendChild(buildThreadEntry(ann, true));
72043d2073cStracker-user
72143d2073cStracker-user        // Replies
72243d2073cStracker-user        (ann.replies || []).forEach(function (reply) {
72343d2073cStracker-user            panel.appendChild(buildReplyEntry(ann, reply));
72443d2073cStracker-user        });
72543d2073cStracker-user
72643d2073cStracker-user        // Reply form (if logged in and has read access — gate is server-side anyway)
72743d2073cStracker-user        if (_loggedIn) {
72843d2073cStracker-user            panel.appendChild(buildReplyForm(ann));
72943d2073cStracker-user        }
73043d2073cStracker-user
73143d2073cStracker-user        return panel;
73243d2073cStracker-user    }
73343d2073cStracker-user
73443d2073cStracker-user    /**
73543d2073cStracker-user     * Build the DOM for the top-level annotation entry.
73643d2073cStracker-user     *
73743d2073cStracker-user     * @param {object}  ann
73843d2073cStracker-user     * @param {boolean} isRoot  true for the annotation itself, false for replies
73943d2073cStracker-user     * @returns {HTMLElement}
74043d2073cStracker-user     */
74143d2073cStracker-user    function buildThreadEntry(ann, isRoot) {
74243d2073cStracker-user        var entry = document.createElement('div');
74343d2073cStracker-user        entry.className = 'ann-thread-entry ann-annotation';
74443d2073cStracker-user        entry.dataset.annId = ann.id;
74543d2073cStracker-user
74643d2073cStracker-user        // Meta row: avatar, author, time, status pill
74743d2073cStracker-user        entry.appendChild(buildMeta(ann.author, ann.created, ann.status));
74843d2073cStracker-user
74943d2073cStracker-user        // Body
75043d2073cStracker-user        var bodyEl = document.createElement('div');
75143d2073cStracker-user        bodyEl.className = 'ann-body';
75243d2073cStracker-user        bodyEl.textContent = ann.body;
75343d2073cStracker-user        entry.appendChild(bodyEl);
75443d2073cStracker-user
75543d2073cStracker-user        // Quoted text snippet
75643d2073cStracker-user        if (ann.anchor && ann.anchor.exact) {
75743d2073cStracker-user            var quote = document.createElement('blockquote');
75843d2073cStracker-user            quote.className = 'ann-quote';
75943d2073cStracker-user            quote.textContent = ann.anchor.exact;
76043d2073cStracker-user            entry.appendChild(quote);
76143d2073cStracker-user        }
76243d2073cStracker-user
76343d2073cStracker-user        // Action buttons
76443d2073cStracker-user        var actions = document.createElement('div');
76543d2073cStracker-user        actions.className = 'ann-actions';
76643d2073cStracker-user
76743d2073cStracker-user        // Resolve/Reopen (any reader)
76843d2073cStracker-user        if (_loggedIn) {
76943d2073cStracker-user            var resolveBtn = document.createElement('button');
77043d2073cStracker-user            resolveBtn.type = 'button';
77143d2073cStracker-user            resolveBtn.className = 'ann-btn ann-btn-resolve';
772*da56206cStracker-user            resolveBtn.textContent = ann.status === 'resolved'
773*da56206cStracker-user                ? t('btn_reopen', 'Reopen')
774*da56206cStracker-user                : t('btn_resolve', 'Resolve');
77543d2073cStracker-user            resolveBtn.addEventListener('click', function () {
77643d2073cStracker-user                doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved');
77743d2073cStracker-user            });
77843d2073cStracker-user            actions.appendChild(resolveBtn);
77943d2073cStracker-user        }
78043d2073cStracker-user
78143d2073cStracker-user        // Edit + Delete (own or admin)
78243d2073cStracker-user        var canEdit = _isAdmin || ann.author === currentUser();
78343d2073cStracker-user        if (canEdit && _loggedIn) {
78443d2073cStracker-user            var editBtn = document.createElement('button');
78543d2073cStracker-user            editBtn.type = 'button';
78643d2073cStracker-user            editBtn.className = 'ann-btn';
787*da56206cStracker-user            editBtn.textContent = t('btn_edit', 'Edit');
78843d2073cStracker-user            editBtn.addEventListener('click', function () {
78943d2073cStracker-user                showEditForm(entry, ann, 'annotation');
79043d2073cStracker-user            });
79143d2073cStracker-user            actions.appendChild(editBtn);
79243d2073cStracker-user
79343d2073cStracker-user            var delBtn = document.createElement('button');
79443d2073cStracker-user            delBtn.type = 'button';
79543d2073cStracker-user            delBtn.className = 'ann-btn ann-btn-danger';
796*da56206cStracker-user            delBtn.textContent = t('btn_delete', 'Delete');
79743d2073cStracker-user            delBtn.addEventListener('click', function () {
798*da56206cStracker-user                if (confirm(t('confirm_delete', 'Delete this annotation?'))) {
79943d2073cStracker-user                    doDeleteAnnotation(ann.id);
80043d2073cStracker-user                }
80143d2073cStracker-user            });
80243d2073cStracker-user            actions.appendChild(delBtn);
80343d2073cStracker-user        }
80443d2073cStracker-user
80543d2073cStracker-user        entry.appendChild(actions);
80643d2073cStracker-user        return entry;
80743d2073cStracker-user    }
80843d2073cStracker-user
80943d2073cStracker-user    /**
81043d2073cStracker-user     * Build the DOM for one reply entry.
81143d2073cStracker-user     *
81243d2073cStracker-user     * @param {object} ann   parent annotation
81343d2073cStracker-user     * @param {object} reply
81443d2073cStracker-user     * @returns {HTMLElement}
81543d2073cStracker-user     */
81643d2073cStracker-user    function buildReplyEntry(ann, reply) {
81743d2073cStracker-user        var entry = document.createElement('div');
81843d2073cStracker-user        entry.className = 'ann-thread-entry ann-reply';
81943d2073cStracker-user        entry.dataset.replyId = reply.id;
82043d2073cStracker-user
82143d2073cStracker-user        entry.appendChild(buildMeta(reply.author, reply.created, null));
82243d2073cStracker-user
82343d2073cStracker-user        var bodyEl = document.createElement('div');
82443d2073cStracker-user        bodyEl.className = 'ann-body';
82543d2073cStracker-user        bodyEl.textContent = reply.body;
82643d2073cStracker-user        entry.appendChild(bodyEl);
82743d2073cStracker-user
82843d2073cStracker-user        var actions = document.createElement('div');
82943d2073cStracker-user        actions.className = 'ann-actions';
83043d2073cStracker-user
83143d2073cStracker-user        var canEdit = _isAdmin || reply.author === currentUser();
83243d2073cStracker-user        if (canEdit && _loggedIn) {
83343d2073cStracker-user            var editBtn = document.createElement('button');
83443d2073cStracker-user            editBtn.type = 'button';
83543d2073cStracker-user            editBtn.className = 'ann-btn';
836*da56206cStracker-user            editBtn.textContent = t('btn_edit', 'Edit');
83743d2073cStracker-user            editBtn.addEventListener('click', function () {
83843d2073cStracker-user                showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply');
83943d2073cStracker-user            });
84043d2073cStracker-user            actions.appendChild(editBtn);
84143d2073cStracker-user
84243d2073cStracker-user            var delBtn = document.createElement('button');
84343d2073cStracker-user            delBtn.type = 'button';
84443d2073cStracker-user            delBtn.className = 'ann-btn ann-btn-danger';
845*da56206cStracker-user            delBtn.textContent = t('btn_delete', 'Delete');
84643d2073cStracker-user            delBtn.addEventListener('click', function () {
847*da56206cStracker-user                if (confirm(t('confirm_delete_reply', 'Delete this reply?'))) {
84843d2073cStracker-user                    doDeleteReply(ann.id, reply.id);
84943d2073cStracker-user                }
85043d2073cStracker-user            });
85143d2073cStracker-user            actions.appendChild(delBtn);
85243d2073cStracker-user        }
85343d2073cStracker-user
85443d2073cStracker-user        entry.appendChild(actions);
85543d2073cStracker-user        return entry;
85643d2073cStracker-user    }
85743d2073cStracker-user
85843d2073cStracker-user    /**
85943d2073cStracker-user     * Build the meta row (avatar initials, author name, timestamp, status pill).
86043d2073cStracker-user     *
86143d2073cStracker-user     * @param {string}      author
86243d2073cStracker-user     * @param {number}      timestamp  Unix seconds
86343d2073cStracker-user     * @param {string|null} status     'open'|'resolved'|null
86443d2073cStracker-user     * @returns {HTMLElement}
86543d2073cStracker-user     */
86643d2073cStracker-user    function buildMeta(author, timestamp, status) {
86743d2073cStracker-user        var meta = document.createElement('div');
86843d2073cStracker-user        meta.className = 'ann-meta';
86943d2073cStracker-user
87043d2073cStracker-user        var avatar = document.createElement('span');
87143d2073cStracker-user        avatar.className = 'ann-avatar';
87243d2073cStracker-user        avatar.textContent = (author || '?').slice(0, 2).toUpperCase();
87343d2073cStracker-user        meta.appendChild(avatar);
87443d2073cStracker-user
87543d2073cStracker-user        var authorEl = document.createElement('span');
87643d2073cStracker-user        authorEl.className = 'ann-author';
877*da56206cStracker-user        authorEl.textContent = author || t('label_unknown', 'Unknown');
87843d2073cStracker-user        meta.appendChild(authorEl);
87943d2073cStracker-user
88043d2073cStracker-user        var timeEl = document.createElement('time');
88143d2073cStracker-user        timeEl.className = 'ann-time';
88243d2073cStracker-user        var d = new Date(timestamp * 1000);
88343d2073cStracker-user        timeEl.dateTime = d.toISOString();
88443d2073cStracker-user        timeEl.textContent = formatDate(d);
88543d2073cStracker-user        meta.appendChild(timeEl);
88643d2073cStracker-user
88743d2073cStracker-user        if (status) {
88843d2073cStracker-user            var pill = document.createElement('span');
88943d2073cStracker-user            pill.className = 'ann-status ann-status-' + status;
890*da56206cStracker-user            pill.textContent = status === 'resolved'
891*da56206cStracker-user                ? t('status_resolved', 'Resolved')
892*da56206cStracker-user                : t('status_open', 'Open');
89343d2073cStracker-user            meta.appendChild(pill);
89443d2073cStracker-user        }
89543d2073cStracker-user
89643d2073cStracker-user        return meta;
89743d2073cStracker-user    }
89843d2073cStracker-user
89943d2073cStracker-user    /**
90043d2073cStracker-user     * Build a reply form at the bottom of the panel.
90143d2073cStracker-user     *
90243d2073cStracker-user     * @param {object} ann
90343d2073cStracker-user     * @returns {HTMLElement}
90443d2073cStracker-user     */
90543d2073cStracker-user    function buildReplyForm(ann) {
90643d2073cStracker-user        var form = document.createElement('div');
90743d2073cStracker-user        form.className = 'ann-reply-form';
90843d2073cStracker-user
90943d2073cStracker-user        var ta = document.createElement('textarea');
91043d2073cStracker-user        ta.className = 'ann-body-input';
911*da56206cStracker-user        ta.placeholder = t('placeholder_reply', 'Write a reply…');
91243d2073cStracker-user        ta.rows = 3;
91343d2073cStracker-user        form.appendChild(ta);
91443d2073cStracker-user
91543d2073cStracker-user        var row = document.createElement('div');
91643d2073cStracker-user        row.className = 'ann-form-row';
91743d2073cStracker-user
91843d2073cStracker-user        var submitBtn = document.createElement('button');
91943d2073cStracker-user        submitBtn.type = 'button';
92043d2073cStracker-user        submitBtn.className = 'ann-btn ann-btn-primary';
921*da56206cStracker-user        submitBtn.textContent = t('btn_reply', 'Reply');
92243d2073cStracker-user        submitBtn.addEventListener('click', function () {
92343d2073cStracker-user            var body = ta.value.trim();
92443d2073cStracker-user            if (!body) return;
92543d2073cStracker-user            doAddReply(ann.id, body, function () {
92643d2073cStracker-user                ta.value = '';
92743d2073cStracker-user            });
92843d2073cStracker-user        });
92943d2073cStracker-user        row.appendChild(submitBtn);
93043d2073cStracker-user        form.appendChild(row);
93143d2073cStracker-user
93243d2073cStracker-user        return form;
93343d2073cStracker-user    }
93443d2073cStracker-user
93543d2073cStracker-user    /**
93643d2073cStracker-user     * Replace the body of an entry with an inline edit form.
93743d2073cStracker-user     *
93843d2073cStracker-user     * @param {HTMLElement} entry
93943d2073cStracker-user     * @param {object}      data    {body, annId?, replyId?}  (annId = undefined → annotation)
94043d2073cStracker-user     * @param {string}      type    'annotation' | 'reply'
94143d2073cStracker-user     */
94243d2073cStracker-user    function showEditForm(entry, data, type) {
94343d2073cStracker-user        var bodyEl = entry.querySelector('.ann-body');
94443d2073cStracker-user        if (!bodyEl) return;
94543d2073cStracker-user
94643d2073cStracker-user        var ta = document.createElement('textarea');
94743d2073cStracker-user        ta.className = 'ann-body-input';
94843d2073cStracker-user        ta.value = data.body || '';
94943d2073cStracker-user        ta.rows  = 4;
95043d2073cStracker-user
95143d2073cStracker-user        var row = document.createElement('div');
95243d2073cStracker-user        row.className = 'ann-form-row';
95343d2073cStracker-user
95443d2073cStracker-user        var saveBtn = document.createElement('button');
95543d2073cStracker-user        saveBtn.type = 'button';
95643d2073cStracker-user        saveBtn.className = 'ann-btn ann-btn-primary';
957*da56206cStracker-user        saveBtn.textContent = t('btn_save', 'Save');
95843d2073cStracker-user        saveBtn.addEventListener('click', function () {
95943d2073cStracker-user            var newBody = ta.value.trim();
96043d2073cStracker-user            if (!newBody) return;
96143d2073cStracker-user            if (type === 'annotation') {
96243d2073cStracker-user                doEditAnnotation(data.id || _openAnnId, newBody);
96343d2073cStracker-user            } else {
96443d2073cStracker-user                doEditReply(data.annId, data.replyId, newBody);
96543d2073cStracker-user            }
96643d2073cStracker-user        });
96743d2073cStracker-user
96843d2073cStracker-user        var cancelBtn = document.createElement('button');
96943d2073cStracker-user        cancelBtn.type = 'button';
97043d2073cStracker-user        cancelBtn.className = 'ann-btn';
971*da56206cStracker-user        cancelBtn.textContent = t('btn_cancel', 'Cancel');
97243d2073cStracker-user        cancelBtn.addEventListener('click', function () {
97343d2073cStracker-user            entry.removeChild(ta);
97443d2073cStracker-user            entry.removeChild(row);
97543d2073cStracker-user            bodyEl.style.display = '';
97643d2073cStracker-user        });
97743d2073cStracker-user
97843d2073cStracker-user        row.appendChild(saveBtn);
97943d2073cStracker-user        row.appendChild(cancelBtn);
98043d2073cStracker-user
98143d2073cStracker-user        bodyEl.style.display = 'none';
98243d2073cStracker-user        entry.insertBefore(ta, bodyEl.nextSibling);
98343d2073cStracker-user        entry.insertBefore(row, ta.nextSibling);
98443d2073cStracker-user        ta.focus();
98543d2073cStracker-user    }
98643d2073cStracker-user
98743d2073cStracker-user    // -----------------------------------------------------------------------
98843d2073cStracker-user    // Orphan drawer
98943d2073cStracker-user    // -----------------------------------------------------------------------
99043d2073cStracker-user
99143d2073cStracker-user    /**
99243d2073cStracker-user     * Toggle the orphan drawer visibility.
99343d2073cStracker-user     */
99443d2073cStracker-user    function toggleOrphanDrawer() {
99543d2073cStracker-user        var drawer = document.getElementById('ann-orphan-drawer');
99643d2073cStracker-user        if (drawer) {
99743d2073cStracker-user            drawer.parentNode.removeChild(drawer);
99843d2073cStracker-user            return;
99943d2073cStracker-user        }
100043d2073cStracker-user        renderOrphanDrawer();
100143d2073cStracker-user    }
100243d2073cStracker-user
100343d2073cStracker-user    /**
100443d2073cStracker-user     * Build and insert the orphan drawer at the bottom of the content area.
100543d2073cStracker-user     */
100643d2073cStracker-user    function renderOrphanDrawer() {
100743d2073cStracker-user        var content = document.getElementById(CONTENT_ID);
100843d2073cStracker-user        if (!content) return;
100943d2073cStracker-user
101043d2073cStracker-user        var drawer = document.createElement('div');
101143d2073cStracker-user        drawer.id = 'ann-orphan-drawer';
101243d2073cStracker-user        drawer.className = CLS_ORPHAN_DRAWER;
101343d2073cStracker-user
101443d2073cStracker-user        var heading = document.createElement('h4');
1015*da56206cStracker-user        heading.textContent = t('orphaned_heading', 'Orphaned annotations');
101643d2073cStracker-user        drawer.appendChild(heading);
101743d2073cStracker-user
101843d2073cStracker-user        var note = document.createElement('p');
101943d2073cStracker-user        note.className = 'ann-orphan-note';
1020*da56206cStracker-user        note.textContent = t('orphaned_note',
1021*da56206cStracker-user            'These annotations reference text that no longer appears on the page.');
102243d2073cStracker-user        drawer.appendChild(note);
102343d2073cStracker-user
102443d2073cStracker-user        var found = false;
102543d2073cStracker-user        _annotations.forEach(function (ann) {
102643d2073cStracker-user            if (!ann._orphaned) return;
102743d2073cStracker-user            found = true;
102843d2073cStracker-user            var entry = buildThreadEntry(ann, true);
102943d2073cStracker-user            drawer.appendChild(entry);
103043d2073cStracker-user        });
103143d2073cStracker-user
103243d2073cStracker-user        if (!found) {
103343d2073cStracker-user            var empty = document.createElement('p');
1034*da56206cStracker-user            empty.textContent = t('orphaned_none', 'None.');
103543d2073cStracker-user            drawer.appendChild(empty);
103643d2073cStracker-user        }
103743d2073cStracker-user
103843d2073cStracker-user        content.appendChild(drawer);
103943d2073cStracker-user    }
104043d2073cStracker-user
104143d2073cStracker-user    // -----------------------------------------------------------------------
104243d2073cStracker-user    // Selection capture
104343d2073cStracker-user    // -----------------------------------------------------------------------
104443d2073cStracker-user
104543d2073cStracker-user    /**
104643d2073cStracker-user     * Wire up mouseup/touchend listeners to detect text selection.
104743d2073cStracker-user     *
104843d2073cStracker-user     * @param {HTMLElement} content
104943d2073cStracker-user     */
105043d2073cStracker-user    function initSelectionCapture(content) {
105143d2073cStracker-user        if (!_loggedIn) return; // anonymous users cannot annotate
105243d2073cStracker-user
105343d2073cStracker-user        document.addEventListener('mouseup', function (e) {
105443d2073cStracker-user            handleSelectionEnd(e, content);
105543d2073cStracker-user        });
105643d2073cStracker-user        document.addEventListener('touchend', function (e) {
105743d2073cStracker-user            // Small delay so the browser has committed the selection.
105843d2073cStracker-user            setTimeout(function () { handleSelectionEnd(e, content); }, 50);
105943d2073cStracker-user        });
106043d2073cStracker-user
106150325813Stracker-user        // Close tooltip on click outside (but not when clicking the new-form).
106243d2073cStracker-user        document.addEventListener('mousedown', function (e) {
106343d2073cStracker-user            var tooltip = document.getElementById('ann-tooltip');
106443d2073cStracker-user            if (tooltip && !tooltip.contains(e.target)) {
106550325813Stracker-user                var naf = document.getElementById('ann-new-form');
106650325813Stracker-user                if (!naf || !naf.contains(e.target)) {
106743d2073cStracker-user                    hideTooltip();
106843d2073cStracker-user                }
106950325813Stracker-user            }
107043d2073cStracker-user        });
107143d2073cStracker-user    }
107243d2073cStracker-user
107343d2073cStracker-user    /**
107443d2073cStracker-user     * Handle end of selection: show the "Annotate" tooltip if there is a
107543d2073cStracker-user     * non-empty selection inside the content area.
107643d2073cStracker-user     *
107743d2073cStracker-user     * @param {Event}       e
107843d2073cStracker-user     * @param {HTMLElement} content
107943d2073cStracker-user     */
108043d2073cStracker-user    function handleSelectionEnd(e, content) {
108143d2073cStracker-user        var sel = window.getSelection();
108243d2073cStracker-user        if (!sel || sel.isCollapsed) {
108350325813Stracker-user            // Don't hide the tooltip if the mouseup came from inside it —
108450325813Stracker-user            // the click handler is responsible for cleanup in that case.
108550325813Stracker-user            var tip = document.getElementById('ann-tooltip');
108650325813Stracker-user            if (tip && tip.contains(e.target)) {
108750325813Stracker-user                return;
108850325813Stracker-user            }
108950325813Stracker-user            // Don't hide if a new-annotation form is open (user clicked
109050325813Stracker-user            // inside the form, collapsing the original selection).
109150325813Stracker-user            var naf = document.getElementById('ann-new-form');
109250325813Stracker-user            if (naf && naf.contains(e.target)) {
109350325813Stracker-user                return;
109450325813Stracker-user            }
109543d2073cStracker-user            hideTooltip();
109643d2073cStracker-user            return;
109743d2073cStracker-user        }
109843d2073cStracker-user        var range = sel.getRangeAt(0);
109943d2073cStracker-user        if (!content.contains(range.commonAncestorContainer)) {
110043d2073cStracker-user            hideTooltip();
110143d2073cStracker-user            return;
110243d2073cStracker-user        }
110343d2073cStracker-user        var text = sel.toString().trim();
110443d2073cStracker-user        if (text.length < 1) {
110543d2073cStracker-user            hideTooltip();
110643d2073cStracker-user            return;
110743d2073cStracker-user        }
110843d2073cStracker-user
110950325813Stracker-user        // If the tooltip is already showing (e.g. user moused up after
111050325813Stracker-user        // pressing the Annotate button), don't replace it with a fresh one —
111150325813Stracker-user        // that would orphan the button mid-click and break the click handler.
111250325813Stracker-user        if (document.getElementById('ann-tooltip')) {
111350325813Stracker-user            return;
111450325813Stracker-user        }
111550325813Stracker-user
111643d2073cStracker-user        // Show the tooltip near the end of the selection.
111743d2073cStracker-user        var rect = range.getBoundingClientRect();
111843d2073cStracker-user        showTooltip(rect, range, sel, content);
111943d2073cStracker-user    }
112043d2073cStracker-user
112143d2073cStracker-user    /**
112243d2073cStracker-user     * Show the "Annotate" tooltip bubble.
112343d2073cStracker-user     *
112443d2073cStracker-user     * @param {DOMRect}     rect     bounding rect of the selection
112543d2073cStracker-user     * @param {Range}       range
112643d2073cStracker-user     * @param {Selection}   sel
112743d2073cStracker-user     * @param {HTMLElement} content
112843d2073cStracker-user     */
112943d2073cStracker-user    function showTooltip(rect, range, sel, content) {
113043d2073cStracker-user        hideTooltip();
113143d2073cStracker-user
113243d2073cStracker-user        var tip = document.createElement('div');
113343d2073cStracker-user        tip.id = 'ann-tooltip';
113443d2073cStracker-user        tip.className = CLS_TOOLTIP;
113543d2073cStracker-user
113650325813Stracker-user        // Capture the anchor on mousedown while the selection is guaranteed
113750325813Stracker-user        // to still exist. By the time 'click' fires, many browsers have
113850325813Stracker-user        // already collapsed the selection, so captureAnchor would return null.
113950325813Stracker-user        // _pendingAnchor is module-level so it survives tooltip replacement.
114043d2073cStracker-user        var btn = document.createElement('button');
114143d2073cStracker-user        btn.type = 'button';
1142*da56206cStracker-user        btn.textContent = t('btn_annotate', 'Annotate');
114343d2073cStracker-user        btn.className = 'ann-btn ann-btn-primary';
114443d2073cStracker-user        btn.addEventListener('mousedown', function (e) {
114550325813Stracker-user            e.preventDefault(); // prevent focus-change deselection
114650325813Stracker-user            // Capture now, while the selection is still intact.
114750325813Stracker-user            _pendingAnchor = captureAnchor(sel, range, content);
114843d2073cStracker-user        });
114943d2073cStracker-user        btn.addEventListener('click', function () {
115050325813Stracker-user            var anchor = _pendingAnchor;
115150325813Stracker-user            _pendingAnchor = null;
115243d2073cStracker-user            hideTooltip();
115343d2073cStracker-user            if (anchor) {
115443d2073cStracker-user                openNewAnnotationForm(anchor, range);
115543d2073cStracker-user            }
115643d2073cStracker-user        });
115743d2073cStracker-user        tip.appendChild(btn);
115843d2073cStracker-user
115943d2073cStracker-user        document.body.appendChild(tip);
116043d2073cStracker-user
116143d2073cStracker-user        // Position below the selection's end.
116243d2073cStracker-user        var scrollTop  = window.pageYOffset || document.documentElement.scrollTop;
116343d2073cStracker-user        var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
116443d2073cStracker-user        tip.style.top  = (rect.bottom + scrollTop  + 6) + 'px';
116543d2073cStracker-user        tip.style.left = (rect.left   + scrollLeft)     + 'px';
116643d2073cStracker-user    }
116743d2073cStracker-user
116843d2073cStracker-user    /**
116943d2073cStracker-user     * Remove the tooltip if it exists.
117043d2073cStracker-user     */
117143d2073cStracker-user    function hideTooltip() {
117243d2073cStracker-user        var tip = document.getElementById('ann-tooltip');
117343d2073cStracker-user        if (tip && tip.parentNode) {
117443d2073cStracker-user            tip.parentNode.removeChild(tip);
117543d2073cStracker-user        }
117650325813Stracker-user        // Note: ann-new-form is NOT removed here — it has its own Cancel
117750325813Stracker-user        // button and must survive the mouseup that fires after the click.
117843d2073cStracker-user    }
117943d2073cStracker-user
118043d2073cStracker-user    /**
118143d2073cStracker-user     * Capture an anchor object from the current Selection.
118243d2073cStracker-user     *
118343d2073cStracker-user     * @param {Selection}   sel
118443d2073cStracker-user     * @param {Range}       range
118543d2073cStracker-user     * @param {HTMLElement} content
118643d2073cStracker-user     * @returns {object|null} {exact, prefix, suffix, start}
118743d2073cStracker-user     */
118843d2073cStracker-user    function captureAnchor(sel, range, content) {
118943d2073cStracker-user        var exact = normalizeWS(sel.toString());
119043d2073cStracker-user        if (!exact) return null;
119143d2073cStracker-user
119243d2073cStracker-user        // Get full page text for prefix/suffix and start computation.
119343d2073cStracker-user        var chunks   = collectTextChunks(content);
119443d2073cStracker-user        var fullRaw  = chunks.map(function (c) { return c.text; }).join('');
1195*da56206cStracker-user        var nm       = normalizeWithMap(fullRaw);
1196*da56206cStracker-user        var fullNorm = nm.norm;
119743d2073cStracker-user
119843d2073cStracker-user        // Find where this text node + offset lands in the raw full text.
119943d2073cStracker-user        var rawStart = 0;
120043d2073cStracker-user        for (var i = 0; i < chunks.length; i++) {
120143d2073cStracker-user            var c = chunks[i];
120243d2073cStracker-user            if (c.node === range.startContainer) {
120343d2073cStracker-user                rawStart = c.start + range.startOffset;
120443d2073cStracker-user                break;
120543d2073cStracker-user            }
120643d2073cStracker-user        }
120743d2073cStracker-user
1208*da56206cStracker-user        // Map that raw offset to an offset in the normalised text, using the
1209*da56206cStracker-user        // same map as re-anchoring so capture and find stay in agreement.
1210*da56206cStracker-user        var normStart = nm.norm.length;
1211*da56206cStracker-user        for (var j = 0; j < nm.map.length; j++) {
1212*da56206cStracker-user            if (nm.map[j] >= rawStart) {
121343d2073cStracker-user                normStart = j;
121443d2073cStracker-user                break;
121543d2073cStracker-user            }
121643d2073cStracker-user        }
121743d2073cStracker-user
121843d2073cStracker-user        var CTX = 30;
121943d2073cStracker-user        var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart);
122043d2073cStracker-user        var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX);
122143d2073cStracker-user
122243d2073cStracker-user        return {
122343d2073cStracker-user            exact:  exact,
122443d2073cStracker-user            prefix: prefix,
122543d2073cStracker-user            suffix: suffix,
122643d2073cStracker-user            start:  normStart,
122743d2073cStracker-user        };
122843d2073cStracker-user    }
122943d2073cStracker-user
123043d2073cStracker-user    /**
123143d2073cStracker-user     * Open the new-annotation form below the paragraph containing the selection.
123243d2073cStracker-user     *
123343d2073cStracker-user     * @param {object} anchor  {exact, prefix, suffix, start}
123443d2073cStracker-user     * @param {Range}  range
123543d2073cStracker-user     */
123643d2073cStracker-user    function openNewAnnotationForm(anchor, range) {
123743d2073cStracker-user        closePanel();
123843d2073cStracker-user
123943d2073cStracker-user        var insertAfter = findParagraph(range.commonAncestorContainer);
124043d2073cStracker-user        var form = document.createElement('div');
124143d2073cStracker-user        form.id = 'ann-new-form';
124243d2073cStracker-user        form.className = 'ann-new-form';
124343d2073cStracker-user
124443d2073cStracker-user        var quote = document.createElement('blockquote');
124543d2073cStracker-user        quote.className = 'ann-quote';
124643d2073cStracker-user        quote.textContent = anchor.exact;
124743d2073cStracker-user        form.appendChild(quote);
124843d2073cStracker-user
124943d2073cStracker-user        var ta = document.createElement('textarea');
125043d2073cStracker-user        ta.className = 'ann-body-input';
1251*da56206cStracker-user        ta.placeholder = t('placeholder_body', 'Add a comment…');
125243d2073cStracker-user        ta.rows = 4;
125343d2073cStracker-user        form.appendChild(ta);
125443d2073cStracker-user
125543d2073cStracker-user        var row = document.createElement('div');
125643d2073cStracker-user        row.className = 'ann-form-row';
125743d2073cStracker-user
125843d2073cStracker-user        var submitBtn = document.createElement('button');
125943d2073cStracker-user        submitBtn.type = 'button';
126043d2073cStracker-user        submitBtn.className = 'ann-btn ann-btn-primary';
1261*da56206cStracker-user        submitBtn.textContent = t('btn_annotate', 'Annotate');
126243d2073cStracker-user        submitBtn.addEventListener('click', function () {
126343d2073cStracker-user            var body = ta.value.trim();
126443d2073cStracker-user            if (!body) return;
126543d2073cStracker-user            doCreate(anchor, body, function () {
126643d2073cStracker-user                if (form.parentNode) form.parentNode.removeChild(form);
126743d2073cStracker-user            });
126843d2073cStracker-user        });
126943d2073cStracker-user
127043d2073cStracker-user        var cancelBtn = document.createElement('button');
127143d2073cStracker-user        cancelBtn.type = 'button';
127243d2073cStracker-user        cancelBtn.className = 'ann-btn';
1273*da56206cStracker-user        cancelBtn.textContent = t('btn_cancel', 'Cancel');
127443d2073cStracker-user        cancelBtn.addEventListener('click', function () {
127543d2073cStracker-user            if (form.parentNode) form.parentNode.removeChild(form);
127643d2073cStracker-user        });
127743d2073cStracker-user
127843d2073cStracker-user        row.appendChild(submitBtn);
127943d2073cStracker-user        row.appendChild(cancelBtn);
128043d2073cStracker-user        form.appendChild(row);
128143d2073cStracker-user
128243d2073cStracker-user        if (insertAfter && insertAfter.parentNode) {
128343d2073cStracker-user            insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling);
128443d2073cStracker-user        } else {
128543d2073cStracker-user            var content = document.getElementById(CONTENT_ID);
128643d2073cStracker-user            if (content) content.appendChild(form);
128743d2073cStracker-user        }
128843d2073cStracker-user
128943d2073cStracker-user        ta.focus();
129043d2073cStracker-user    }
129143d2073cStracker-user
129243d2073cStracker-user    // -----------------------------------------------------------------------
129343d2073cStracker-user    // AJAX actions
129443d2073cStracker-user    // -----------------------------------------------------------------------
129543d2073cStracker-user
129643d2073cStracker-user    /**
129743d2073cStracker-user     * POST create action and update state on success.
129843d2073cStracker-user     *
129943d2073cStracker-user     * @param {object}   anchor
130043d2073cStracker-user     * @param {string}   body
130143d2073cStracker-user     * @param {Function} onSuccess
130243d2073cStracker-user     */
130343d2073cStracker-user    function doCreate(anchor, body, onSuccess) {
130443d2073cStracker-user        ajax({
130543d2073cStracker-user            action: 'create',
130643d2073cStracker-user            id:     _info.pageId,
130743d2073cStracker-user            anchor: anchor,
130843d2073cStracker-user            body:   body,
130943d2073cStracker-user        }).then(function (data) {
131043d2073cStracker-user            if (!data.success) {
1311*da56206cStracker-user                showError(t('error_save', 'Could not save — please try again.'), data);
131243d2073cStracker-user                return;
131343d2073cStracker-user            }
131443d2073cStracker-user            var ann = data.annotation;
131543d2073cStracker-user            _annotations.set(ann.id, ann);
131643d2073cStracker-user            if (typeof onSuccess === 'function') onSuccess(ann);
131743d2073cStracker-user            renderAll();
131843d2073cStracker-user        }).catch(function () {
1319*da56206cStracker-user            alert(t('error_save', 'Could not save — please try again.'));
132043d2073cStracker-user        });
132143d2073cStracker-user    }
132243d2073cStracker-user
132343d2073cStracker-user    /**
132443d2073cStracker-user     * POST reply action and refresh the open panel.
132543d2073cStracker-user     *
132643d2073cStracker-user     * @param {string}   annId
132743d2073cStracker-user     * @param {string}   body
132843d2073cStracker-user     * @param {Function} onSuccess
132943d2073cStracker-user     */
133043d2073cStracker-user    function doAddReply(annId, body, onSuccess) {
133143d2073cStracker-user        ajax({
133243d2073cStracker-user            action: 'reply',
133343d2073cStracker-user            id:     _info.pageId,
133443d2073cStracker-user            annId:  annId,
133543d2073cStracker-user            body:   body,
133643d2073cStracker-user        }).then(function (data) {
133743d2073cStracker-user            if (!data.success) {
1338*da56206cStracker-user                showError(t('error_save', 'Could not save — please try again.'), data);
133943d2073cStracker-user                return;
134043d2073cStracker-user            }
134143d2073cStracker-user            // Re-fetch the updated annotation from server.
134243d2073cStracker-user            refreshAnnotation(annId, function () {
134343d2073cStracker-user                if (typeof onSuccess === 'function') onSuccess();
134443d2073cStracker-user                reopenPanel(annId);
134543d2073cStracker-user            });
134643d2073cStracker-user        }).catch(function () {
1347*da56206cStracker-user            alert(t('error_save', 'Could not save — please try again.'));
134843d2073cStracker-user        });
134943d2073cStracker-user    }
135043d2073cStracker-user
135143d2073cStracker-user    /**
135243d2073cStracker-user     * POST edit_annotation and re-render.
135343d2073cStracker-user     *
135443d2073cStracker-user     * @param {string} annId
135543d2073cStracker-user     * @param {string} body
135643d2073cStracker-user     */
135743d2073cStracker-user    function doEditAnnotation(annId, body) {
135843d2073cStracker-user        ajax({
135943d2073cStracker-user            action: 'edit_annotation',
136043d2073cStracker-user            id:     _info.pageId,
136143d2073cStracker-user            annId:  annId,
136243d2073cStracker-user            body:   body,
136343d2073cStracker-user        }).then(function (data) {
136443d2073cStracker-user            if (!data.success) {
1365*da56206cStracker-user                showError(t('error_save', 'Could not save — please try again.'), data);
136643d2073cStracker-user                return;
136743d2073cStracker-user            }
136843d2073cStracker-user            var updated = data.annotation;
136943d2073cStracker-user            _annotations.set(updated.id, updated);
137043d2073cStracker-user            reopenPanel(annId);
137143d2073cStracker-user        });
137243d2073cStracker-user    }
137343d2073cStracker-user
137443d2073cStracker-user    /**
137543d2073cStracker-user     * POST edit_reply and re-render.
137643d2073cStracker-user     *
137743d2073cStracker-user     * @param {string} annId
137843d2073cStracker-user     * @param {string} replyId
137943d2073cStracker-user     * @param {string} body
138043d2073cStracker-user     */
138143d2073cStracker-user    function doEditReply(annId, replyId, body) {
138243d2073cStracker-user        ajax({
138343d2073cStracker-user            action:   'edit_reply',
138443d2073cStracker-user            id:       _info.pageId,
138543d2073cStracker-user            annId:    annId,
138643d2073cStracker-user            replyId:  replyId,
138743d2073cStracker-user            body:     body,
138843d2073cStracker-user        }).then(function (data) {
138943d2073cStracker-user            if (!data.success) {
1390*da56206cStracker-user                showError(t('error_save', 'Could not save — please try again.'), data);
139143d2073cStracker-user                return;
139243d2073cStracker-user            }
139343d2073cStracker-user            var updated = data.annotation;
139443d2073cStracker-user            _annotations.set(updated.id, updated);
139543d2073cStracker-user            reopenPanel(annId);
139643d2073cStracker-user        });
139743d2073cStracker-user    }
139843d2073cStracker-user
139943d2073cStracker-user    /**
140043d2073cStracker-user     * POST delete_annotation.
140143d2073cStracker-user     *
140243d2073cStracker-user     * @param {string} annId
140343d2073cStracker-user     */
140443d2073cStracker-user    function doDeleteAnnotation(annId) {
140543d2073cStracker-user        ajax({
140643d2073cStracker-user            action: 'delete_annotation',
140743d2073cStracker-user            id:     _info.pageId,
140843d2073cStracker-user            annId:  annId,
140943d2073cStracker-user        }).then(function (data) {
141043d2073cStracker-user            if (!data.success) {
1411*da56206cStracker-user                showError(t('error_delete', 'Could not delete — please try again.'), data);
141243d2073cStracker-user                return;
141343d2073cStracker-user            }
141443d2073cStracker-user            _annotations.delete(annId);
141543d2073cStracker-user            closePanel();
141643d2073cStracker-user            renderAll();
141743d2073cStracker-user        });
141843d2073cStracker-user    }
141943d2073cStracker-user
142043d2073cStracker-user    /**
142143d2073cStracker-user     * POST delete_reply and re-render.
142243d2073cStracker-user     *
142343d2073cStracker-user     * @param {string} annId
142443d2073cStracker-user     * @param {string} replyId
142543d2073cStracker-user     */
142643d2073cStracker-user    function doDeleteReply(annId, replyId) {
142743d2073cStracker-user        ajax({
142843d2073cStracker-user            action:  'delete_reply',
142943d2073cStracker-user            id:      _info.pageId,
143043d2073cStracker-user            annId:   annId,
143143d2073cStracker-user            replyId: replyId,
143243d2073cStracker-user        }).then(function (data) {
143343d2073cStracker-user            if (!data.success) {
1434*da56206cStracker-user                showError(t('error_delete', 'Could not delete — please try again.'), data);
143543d2073cStracker-user                return;
143643d2073cStracker-user            }
143743d2073cStracker-user            var updated = data.annotation;
143843d2073cStracker-user            _annotations.set(updated.id, updated);
143943d2073cStracker-user            reopenPanel(annId);
144043d2073cStracker-user        });
144143d2073cStracker-user    }
144243d2073cStracker-user
144343d2073cStracker-user    /**
144443d2073cStracker-user     * POST resolve/reopen action.
144543d2073cStracker-user     *
144643d2073cStracker-user     * @param {string} annId
144743d2073cStracker-user     * @param {string} status  'open' | 'resolved'
144843d2073cStracker-user     */
144943d2073cStracker-user    function doResolve(annId, status) {
145043d2073cStracker-user        ajax({
145143d2073cStracker-user            action: 'resolve',
145243d2073cStracker-user            id:     _info.pageId,
145343d2073cStracker-user            annId:  annId,
145443d2073cStracker-user            status: status,
145543d2073cStracker-user        }).then(function (data) {
145643d2073cStracker-user            if (!data.success) {
1457*da56206cStracker-user                showError(t('error_status', 'Could not update the status — please try again.'), data);
145843d2073cStracker-user                return;
145943d2073cStracker-user            }
146043d2073cStracker-user            var updated = data.annotation;
146143d2073cStracker-user            _annotations.set(updated.id, updated);
146243d2073cStracker-user            renderAll();
146343d2073cStracker-user            reopenPanel(annId);
146443d2073cStracker-user        });
146543d2073cStracker-user    }
146643d2073cStracker-user
146743d2073cStracker-user    /**
146843d2073cStracker-user     * POST clear_resolved (admin).
146943d2073cStracker-user     */
147043d2073cStracker-user    function doClearResolved() {
1471*da56206cStracker-user        if (!confirm(t('confirm_clear_resolved', 'Delete all resolved annotations on this page?'))) return;
147243d2073cStracker-user        ajax({
147343d2073cStracker-user            action: 'clear_resolved',
147443d2073cStracker-user            id:     _info.pageId,
147543d2073cStracker-user        }).then(function (data) {
147643d2073cStracker-user            if (!data.success) {
1477*da56206cStracker-user                showError(t('error_clear', 'Could not clear — please try again.'), data);
147843d2073cStracker-user                return;
147943d2073cStracker-user            }
148043d2073cStracker-user            // Remove resolved from local state.
148143d2073cStracker-user            _annotations.forEach(function (ann, id) {
148243d2073cStracker-user                if (ann.status === 'resolved') _annotations.delete(id);
148343d2073cStracker-user            });
148443d2073cStracker-user            closePanel();
148543d2073cStracker-user            renderAll();
148643d2073cStracker-user        });
148743d2073cStracker-user    }
148843d2073cStracker-user
148943d2073cStracker-user    /**
149043d2073cStracker-user     * POST clear_orphaned (admin).
149143d2073cStracker-user     */
149243d2073cStracker-user    function doClearOrphaned() {
1493*da56206cStracker-user        if (!confirm(t('confirm_clear_orphaned', 'Delete all orphaned annotations on this page?'))) return;
149443d2073cStracker-user        ajax({
149543d2073cStracker-user            action: 'clear_orphaned',
149643d2073cStracker-user            id:     _info.pageId,
149743d2073cStracker-user        }).then(function (data) {
149843d2073cStracker-user            if (!data.success) {
1499*da56206cStracker-user                showError(t('error_clear', 'Could not clear — please try again.'), data);
150043d2073cStracker-user                return;
150143d2073cStracker-user            }
150243d2073cStracker-user            _annotations.forEach(function (ann, id) {
150343d2073cStracker-user                if (ann._orphaned) _annotations.delete(id);
150443d2073cStracker-user            });
150543d2073cStracker-user            closePanel();
150643d2073cStracker-user            renderAll();
150743d2073cStracker-user        });
150843d2073cStracker-user    }
150943d2073cStracker-user
151043d2073cStracker-user    // -----------------------------------------------------------------------
151143d2073cStracker-user    // Panel management helpers
151243d2073cStracker-user    // -----------------------------------------------------------------------
151343d2073cStracker-user
151443d2073cStracker-user    /**
151543d2073cStracker-user     * Re-fetch one annotation from the server and update local state.
151643d2073cStracker-user     *
151743d2073cStracker-user     * Note: the AJAX endpoint doesn't have a standalone "get one" action,
151843d2073cStracker-user     * so we ask the load endpoint (GET) and pull the matching entry out.
151943d2073cStracker-user     *
152043d2073cStracker-user     * @param {string}   annId
152143d2073cStracker-user     * @param {Function} cb
152243d2073cStracker-user     */
152343d2073cStracker-user    function refreshAnnotation(annId, cb) {
152443d2073cStracker-user        fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), {
152543d2073cStracker-user            method: 'GET',
152643d2073cStracker-user        }).then(function (res) {
152743d2073cStracker-user            return res.json();
152843d2073cStracker-user        }).then(function (data) {
152943d2073cStracker-user            if (data && Array.isArray(data.annotations)) {
153043d2073cStracker-user                data.annotations.forEach(function (ann) {
153143d2073cStracker-user                    _annotations.set(ann.id, ann);
153243d2073cStracker-user                });
153343d2073cStracker-user            }
153443d2073cStracker-user            if (typeof cb === 'function') cb();
153543d2073cStracker-user        }).catch(function () {
153643d2073cStracker-user            if (typeof cb === 'function') cb();
153743d2073cStracker-user        });
153843d2073cStracker-user    }
153943d2073cStracker-user
154043d2073cStracker-user    /**
154143d2073cStracker-user     * Close the current panel and re-open it (preserves scroll position and
154243d2073cStracker-user     * re-renders the thread with fresh data).
154343d2073cStracker-user     *
154443d2073cStracker-user     * @param {string} annId
154543d2073cStracker-user     */
154643d2073cStracker-user    function reopenPanel(annId) {
154743d2073cStracker-user        closePanel();
154843d2073cStracker-user        openPanel(annId);
154943d2073cStracker-user    }
155043d2073cStracker-user
155143d2073cStracker-user    // -----------------------------------------------------------------------
155243d2073cStracker-user    // Utilities
155343d2073cStracker-user    // -----------------------------------------------------------------------
155443d2073cStracker-user
155543d2073cStracker-user    /**
1556*da56206cStracker-user     * The per-plugin JS language bundle, exposed by DokuWiki as
1557*da56206cStracker-user     * LANG.plugins.annotations (built from lang/<iso>/lang.php $lang['js']).
1558*da56206cStracker-user     *
1559*da56206cStracker-user     * @returns {object}
1560*da56206cStracker-user     */
1561*da56206cStracker-user    function uiLang() {
1562*da56206cStracker-user        if (typeof LANG !== 'undefined' && LANG && LANG.plugins && LANG.plugins.annotations) {
1563*da56206cStracker-user            return LANG.plugins.annotations;
1564*da56206cStracker-user        }
1565*da56206cStracker-user        return {};
1566*da56206cStracker-user    }
1567*da56206cStracker-user
1568*da56206cStracker-user    /**
1569*da56206cStracker-user     * Look up a UI string by key, falling back to the supplied English text if
1570*da56206cStracker-user     * the bundle is missing the key (e.g. a lang file not yet updated).
1571*da56206cStracker-user     *
1572*da56206cStracker-user     * @param {string} key
1573*da56206cStracker-user     * @param {string} fallback  English default
1574*da56206cStracker-user     * @returns {string}
1575*da56206cStracker-user     */
1576*da56206cStracker-user    function t(key, fallback) {
1577*da56206cStracker-user        var s = _lang[key];
1578*da56206cStracker-user        return (s === undefined || s === null || s === '') ? fallback : s;
1579*da56206cStracker-user    }
1580*da56206cStracker-user
1581*da56206cStracker-user    /**
1582*da56206cStracker-user     * Substitute a single %d placeholder with a number.
1583*da56206cStracker-user     *
1584*da56206cStracker-user     * @param {string} str
1585*da56206cStracker-user     * @param {number} n
1586*da56206cStracker-user     * @returns {string}
1587*da56206cStracker-user     */
1588*da56206cStracker-user    function fmt(str, n) {
1589*da56206cStracker-user        return String(str).replace('%d', n);
1590*da56206cStracker-user    }
1591*da56206cStracker-user
1592*da56206cStracker-user    /**
1593*da56206cStracker-user     * Show a localised error, appending the server's reason in parentheses
1594*da56206cStracker-user     * when one is present.
1595*da56206cStracker-user     *
1596*da56206cStracker-user     * @param {string} base  localised message
1597*da56206cStracker-user     * @param {object} data  AJAX response ({error?:string})
1598*da56206cStracker-user     */
1599*da56206cStracker-user    function showError(base, data) {
1600*da56206cStracker-user        var reason = (data && data.error) ? data.error : '';
1601*da56206cStracker-user        alert(reason ? base + ' (' + reason + ')' : base);
1602*da56206cStracker-user    }
1603*da56206cStracker-user
1604*da56206cStracker-user    /**
160543d2073cStracker-user     * Collapse consecutive whitespace to a single space and trim.
160643d2073cStracker-user     *
160743d2073cStracker-user     * @param {string} s
160843d2073cStracker-user     * @returns {string}
160943d2073cStracker-user     */
161043d2073cStracker-user    function normalizeWS(s) {
161143d2073cStracker-user        return String(s || '').replace(/\s+/g, ' ').trim();
161243d2073cStracker-user    }
161343d2073cStracker-user
161443d2073cStracker-user    /**
161543d2073cStracker-user     * Return the current DokuWiki username from JSINFO.
161643d2073cStracker-user     *
161743d2073cStracker-user     * @returns {string}
161843d2073cStracker-user     */
161943d2073cStracker-user    function currentUser() {
162043d2073cStracker-user        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
16217d2714c7Stracker-user        return (jsinfo.annotations && jsinfo.annotations.user) ? jsinfo.annotations.user : '';
162243d2073cStracker-user    }
162343d2073cStracker-user
162443d2073cStracker-user    /**
162543d2073cStracker-user     * Format a Date for display.
162643d2073cStracker-user     *
162743d2073cStracker-user     * @param {Date} d
162843d2073cStracker-user     * @returns {string}
162943d2073cStracker-user     */
163043d2073cStracker-user    function formatDate(d) {
163143d2073cStracker-user        var now  = new Date();
163243d2073cStracker-user        var diff = (now - d) / 1000; // seconds
1633*da56206cStracker-user        if (diff < 60)        return t('time_now', 'just now');
1634*da56206cStracker-user        if (diff < 3600)      return fmt(t('time_minutes', '%dm ago'), Math.floor(diff / 60));
1635*da56206cStracker-user        if (diff < 86400)     return fmt(t('time_hours',   '%dh ago'), Math.floor(diff / 3600));
1636*da56206cStracker-user        if (diff < 86400 * 7) return fmt(t('time_days',    '%dd ago'), Math.floor(diff / 86400));
163743d2073cStracker-user        return d.toLocaleDateString();
163843d2073cStracker-user    }
163943d2073cStracker-user
164043d2073cStracker-user    // -----------------------------------------------------------------------
164143d2073cStracker-user    // Init
164243d2073cStracker-user    // -----------------------------------------------------------------------
164343d2073cStracker-user
164443d2073cStracker-user    if (document.readyState === 'loading') {
164543d2073cStracker-user        document.addEventListener('DOMContentLoaded', boot);
164643d2073cStracker-user    } else {
164743d2073cStracker-user        boot();
164843d2073cStracker-user    }
164943d2073cStracker-user
165043d2073cStracker-user}());
1651