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