@khanglvm/relay 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /* relay annotate — element-level comment UI (pin, badge, popover,
2
+ selection button, summary). Uses only the shared theme tokens. */
3
+
4
+ /* ---------- annotatable targets ---------- */
5
+ .ann-target:hover { outline: 1px dashed var(--accent); outline-offset: 2px; }
6
+ .ann-rel { position: relative; }
7
+
8
+ /* ---------- hover pin (shared singleton) ---------- */
9
+ .ann-pin {
10
+ position: fixed; z-index: 59;
11
+ display: none; align-items: center; justify-content: center;
12
+ width: 22px; height: 22px; padding: 0;
13
+ background: var(--card); color: var(--fg-2);
14
+ border: 1px solid var(--border); border-radius: 50%;
15
+ box-shadow: var(--shadow-card);
16
+ cursor: pointer;
17
+ transition: border-color 150ms var(--ease), color 150ms var(--ease);
18
+ }
19
+ .ann-pin:hover { border-color: var(--accent); color: var(--accent); }
20
+
21
+ /* ---------- count badge ---------- */
22
+ .ann-badge {
23
+ position: absolute; top: -6px; right: -6px; z-index: 5;
24
+ width: 16px; height: 16px;
25
+ background: var(--accent); color: var(--accent-fg);
26
+ border-radius: 50%;
27
+ font: 600 10px/16px var(--sans);
28
+ text-align: center;
29
+ cursor: pointer;
30
+ user-select: none;
31
+ }
32
+ .ann-badge:hover { background: var(--accent-hover); }
33
+ /* SVG targets (mermaid nodes) get the badge overlaid in document coords */
34
+ .ann-badge-overlay { top: auto; right: auto; z-index: 58; }
35
+
36
+ /* ---------- comment popover ---------- */
37
+ .ann-pop {
38
+ position: fixed; z-index: 60;
39
+ display: none;
40
+ width: 300px; max-width: 320px;
41
+ background: var(--card); color: var(--fg);
42
+ border: 1px solid var(--border); border-radius: 12px;
43
+ box-shadow: var(--shadow-lift);
44
+ padding: 12px 14px;
45
+ font: 14px/1.5 var(--sans);
46
+ }
47
+ .ann-pop-label {
48
+ color: var(--muted);
49
+ font-size: 0.78rem;
50
+ margin-bottom: 8px;
51
+ overflow-wrap: anywhere;
52
+ }
53
+ .ann-pop-existing { margin-bottom: 8px; }
54
+ .ann-pop-existing:empty { display: none; margin: 0; }
55
+ .ann-pop-item {
56
+ display: flex; align-items: flex-start; gap: 8px;
57
+ padding: 6px 0;
58
+ border-top: 1px solid var(--border);
59
+ }
60
+ .ann-pop-text { flex: 1; font-size: 0.88rem; white-space: pre-wrap; overflow-wrap: anywhere; }
61
+ .ann-pop textarea.ann-ta {
62
+ width: 100%; min-height: 64px;
63
+ background: var(--bg-sunken); color: var(--fg);
64
+ border: 1px solid var(--border); border-radius: 10px;
65
+ padding: 8px 10px;
66
+ font: 0.9rem/1.5 var(--sans);
67
+ resize: vertical;
68
+ }
69
+ .ann-pop textarea.ann-ta:focus {
70
+ outline: none;
71
+ border-color: var(--accent);
72
+ box-shadow: 0 0 0 3px var(--accent-soft);
73
+ }
74
+ .ann-pop-actions { display: flex; gap: 8px; margin-top: 10px; }
75
+ .ann-save {
76
+ background: var(--accent); color: var(--accent-fg);
77
+ border: none; border-radius: 10px;
78
+ padding: 7px 18px;
79
+ font: 600 0.88rem var(--sans);
80
+ cursor: pointer;
81
+ transition: background 150ms var(--ease);
82
+ }
83
+ .ann-save:hover { background: var(--accent-hover); }
84
+ .ann-cancel {
85
+ background: transparent; color: var(--fg-2);
86
+ border: 1px solid var(--border-strong); border-radius: 10px;
87
+ padding: 7px 14px;
88
+ font: 0.88rem var(--sans);
89
+ cursor: pointer;
90
+ transition: border-color 150ms var(--ease), color 150ms var(--ease);
91
+ }
92
+ .ann-cancel:hover { border-color: var(--accent); color: var(--accent); }
93
+
94
+ /* delete button (popover items + summary rows): muted, danger on hover */
95
+ .ann-del {
96
+ flex: none;
97
+ background: transparent; color: var(--muted);
98
+ border: none; border-radius: 6px;
99
+ padding: 2px 6px;
100
+ font-size: 1.05rem; line-height: 1;
101
+ cursor: pointer;
102
+ transition: color 150ms var(--ease);
103
+ }
104
+ .ann-del:hover { color: var(--danger); }
105
+
106
+ /* ---------- floating selection button ---------- */
107
+ .ann-selbtn {
108
+ position: fixed; z-index: 59;
109
+ display: none;
110
+ background: var(--accent); color: var(--accent-fg);
111
+ border: none; border-radius: 999px;
112
+ padding: 6px 14px;
113
+ font: 600 0.8rem var(--sans);
114
+ box-shadow: var(--shadow-lift);
115
+ cursor: pointer;
116
+ transition: background 150ms var(--ease);
117
+ }
118
+ .ann-selbtn:hover { background: var(--accent-hover); }
119
+
120
+ /* ---------- summary list ---------- */
121
+ .ann-sum-head {
122
+ font: 600 0.95rem var(--sans);
123
+ margin: 0 0 2px;
124
+ }
125
+ .ann-sum-row {
126
+ display: flex; align-items: flex-start; gap: 10px;
127
+ padding: 9px 0;
128
+ cursor: pointer;
129
+ }
130
+ .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
131
+ .ann-sum-main { flex: 1; min-width: 0; }
132
+ .ann-sum-target { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
133
+ .ann-sum-text { font-size: 0.92rem; white-space: pre-wrap; overflow-wrap: anywhere; }
134
+
135
+ /* ---------- jump-to flash ---------- */
136
+ .ann-flash { animation: ann-flash 1s var(--ease); }
137
+ @keyframes ann-flash {
138
+ 0%, 55% {
139
+ background-color: var(--accent-soft);
140
+ outline: 2px solid var(--accent);
141
+ outline-offset: 2px;
142
+ }
143
+ 100% {
144
+ background-color: transparent;
145
+ outline: 2px solid transparent;
146
+ outline-offset: 2px;
147
+ }
148
+ }
149
+ @media (prefers-reduced-motion: reduce) {
150
+ .ann-flash { animation: none; }
151
+ }
@@ -0,0 +1,440 @@
1
+ /* relay annotate — element-level comments (window.RelayAnnotate).
2
+ Self-contained vanilla JS: hover pin, count badges, comment popover,
3
+ text-selection capture, and an editable summary list. Zero dependencies.
4
+ Load order: blocks.js, annotate.js, app.js (all inlined by the server). */
5
+ (() => {
6
+ 'use strict';
7
+
8
+ // ---------- helpers ----------
9
+ function el(tag, attrs = {}, ...children) {
10
+ const n = document.createElement(tag);
11
+ for (const [k, v] of Object.entries(attrs)) {
12
+ if (v === undefined || v === null) continue;
13
+ if (k === 'class') n.className = v;
14
+ else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
15
+ else n.setAttribute(k, v);
16
+ }
17
+ for (const c of children.flat()) {
18
+ if (c !== null && c !== undefined) n.append(c.nodeType ? c : document.createTextNode(c));
19
+ }
20
+ return n;
21
+ }
22
+
23
+ function truncate(s, n) {
24
+ s = String(s);
25
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
26
+ }
27
+
28
+ // Humanized target label shown in the popover and the summary list.
29
+ function humanize(t) {
30
+ if (!t || typeof t !== 'object') return 'Element';
31
+ switch (t.kind) {
32
+ case 'chart-element': {
33
+ const parts = [];
34
+ if (t.label !== undefined && t.label !== null && t.label !== '') parts.push(truncate(t.label, 40));
35
+ if (typeof t.datasetIndex === 'number') parts.push(`series ${t.datasetIndex + 1}`);
36
+ if (t.value !== undefined && t.value !== null) {
37
+ parts.push(truncate(typeof t.value === 'object' ? JSON.stringify(t.value) : t.value, 30));
38
+ }
39
+ return 'Chart · ' + (parts.join(' · ') || 'element');
40
+ }
41
+ case 'mermaid-node':
42
+ return 'Diagram · ' + truncate(t.text || t.nodeId || 'node', 50);
43
+ case 'table-cell': {
44
+ let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
45
+ if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
46
+ return s;
47
+ }
48
+ case 'text':
49
+ return `“${truncate(t.quote || '', 60)}”`;
50
+ case 'html-element':
51
+ return truncate(t.label || 'Element', 50) + (t.detail ? ` — ${truncate(t.detail, 40)}` : '');
52
+ default:
53
+ return t.kind || 'Element';
54
+ }
55
+ }
56
+
57
+ // Target equality for badge re-binding = stringify equality within a block.
58
+ const sameTarget = (a, b) => JSON.stringify(a) === JSON.stringify(b);
59
+ const sameBlock = (a, b) => (a ?? null) === (b ?? null);
60
+
61
+ // ---------- state ----------
62
+ let annotations = []; // contract annotation objects
63
+ let idCounter = 0; // continues from max existing "aN"
64
+ let onChange = null;
65
+ let registered = []; // [{el, info: {blockId, questionId, target}}]
66
+ const summaryEls = new Set();
67
+ let badges = []; // live badge nodes (rebuilt on every refresh)
68
+ let pendingSelection = null; // {info} captured at mouseup time
69
+
70
+ let dom = null; // {pin, selBtn, pop}
71
+ let pinTimer = null;
72
+ let pinEntry = null;
73
+ let popOpen = false;
74
+ let popScrollY = 0;
75
+ let popSave = null;
76
+ let badgeTimer = 0;
77
+
78
+ // ---------- singleton DOM ----------
79
+ function ensureDom() {
80
+ if (dom) return;
81
+ const pin = el('button', { class: 'ann-pin', type: 'button', title: 'Add a comment', 'aria-label': 'Add a comment' });
82
+ pin.innerHTML =
83
+ '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
84
+ '<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
85
+ pin.addEventListener('mouseenter', () => clearTimeout(pinTimer));
86
+ pin.addEventListener('mouseleave', scheduleHidePin);
87
+ pin.addEventListener('click', (e) => {
88
+ e.stopPropagation();
89
+ const entry = pinEntry;
90
+ hidePin();
91
+ if (entry) openPopover(entry.info, entry.el);
92
+ });
93
+
94
+ const selBtn = el('button', { class: 'ann-selbtn', type: 'button' }, 'Comment');
95
+ // preventDefault keeps the text selection alive through the click.
96
+ selBtn.addEventListener('mousedown', (e) => e.preventDefault());
97
+ selBtn.addEventListener('click', (e) => {
98
+ e.stopPropagation();
99
+ const p = pendingSelection;
100
+ if (p) openPopover(p.info, selBtn); // anchor rect read while still visible
101
+ hideSelBtn();
102
+ });
103
+
104
+ const pop = el('div', { class: 'ann-pop', role: 'dialog', 'aria-label': 'Comment' });
105
+ pop.addEventListener('keydown', (e) => {
106
+ if (e.key === 'Escape') {
107
+ e.stopPropagation();
108
+ closePopover();
109
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
110
+ e.preventDefault();
111
+ if (popSave) popSave();
112
+ }
113
+ });
114
+
115
+ document.body.append(pin, selBtn, pop);
116
+ dom = { pin, selBtn, pop };
117
+
118
+ document.addEventListener('selectionchange', () => {
119
+ if (dom.selBtn.style.display === '' || dom.selBtn.style.display === 'none') return;
120
+ const sel = window.getSelection();
121
+ const txt = sel && !sel.isCollapsed ? sel.toString() : '';
122
+ if (!pendingSelection || txt.slice(0, 500) !== pendingSelection.info.target.quote) hideSelBtn();
123
+ });
124
+
125
+ document.addEventListener('mousedown', (e) => {
126
+ if (popOpen && !dom.pop.contains(e.target)) closePopover();
127
+ if (!dom.selBtn.contains(e.target)) hideSelBtn();
128
+ });
129
+
130
+ // Capture-phase: also fires for nested scroll containers (mermaid pane…).
131
+ window.addEventListener('scroll', (e) => {
132
+ hidePin();
133
+ hideSelBtn();
134
+ if (!popOpen) return;
135
+ if (dom.pop.contains(e.target)) return; // textarea scrolling inside
136
+ const isPage = e.target === document || e.target === document.documentElement || e.target === document.body;
137
+ if (!isPage || Math.abs(window.scrollY - popScrollY) > 80) closePopover();
138
+ }, true);
139
+
140
+ window.addEventListener('resize', scheduleBadgeRefresh);
141
+ }
142
+
143
+ // ---------- hover pin ----------
144
+ function showPin(entry) {
145
+ clearTimeout(pinTimer);
146
+ pinEntry = entry;
147
+ const rect = entry.el.getBoundingClientRect();
148
+ const w = 22;
149
+ const pin = dom.pin;
150
+ pin.style.display = 'flex';
151
+ pin.style.left = Math.max(4, Math.min(rect.right - w / 2, window.innerWidth - w - 4)) + 'px';
152
+ pin.style.top = Math.max(4, Math.min(rect.top - w / 2, window.innerHeight - w - 4)) + 'px';
153
+ }
154
+ function scheduleHidePin() {
155
+ clearTimeout(pinTimer);
156
+ pinTimer = setTimeout(hidePin, 250); // delay so the pin itself is clickable
157
+ }
158
+ function hidePin() {
159
+ clearTimeout(pinTimer);
160
+ pinEntry = null;
161
+ if (dom) dom.pin.style.display = 'none';
162
+ }
163
+
164
+ // ---------- badges ----------
165
+ function matching(info) {
166
+ return annotations.filter((a) => sameBlock(a.blockId, info.blockId) && sameTarget(a.target, info.target));
167
+ }
168
+
169
+ function scheduleBadgeRefresh() {
170
+ if (badgeTimer) return;
171
+ badgeTimer = setTimeout(() => {
172
+ badgeTimer = 0;
173
+ refreshBadges();
174
+ }, 0);
175
+ }
176
+
177
+ function refreshBadges() {
178
+ for (const b of badges) b.remove();
179
+ badges = [];
180
+ registered = registered.filter((r) => r.el.isConnected);
181
+ for (const entry of registered) {
182
+ const count = matching(entry.info).length;
183
+ if (!count) continue;
184
+ const badge = el('span', { class: 'ann-badge', title: count + (count === 1 ? ' comment' : ' comments') }, String(count));
185
+ badge.addEventListener('mousedown', (e) => e.stopPropagation());
186
+ badge.addEventListener('click', (e) => {
187
+ e.stopPropagation();
188
+ e.preventDefault();
189
+ hidePin();
190
+ openPopover(entry.info, entry.el);
191
+ });
192
+ if (entry.el instanceof HTMLElement) {
193
+ if (getComputedStyle(entry.el).position === 'static') entry.el.classList.add('ann-rel');
194
+ entry.el.append(badge);
195
+ } else {
196
+ // SVG targets (mermaid nodes) can't host an HTML child — overlay it
197
+ // in document coordinates so it scrolls with the content.
198
+ badge.classList.add('ann-badge-overlay');
199
+ document.body.append(badge);
200
+ const r = entry.el.getBoundingClientRect();
201
+ badge.style.left = (r.right + window.scrollX - 10) + 'px';
202
+ badge.style.top = (r.top + window.scrollY - 6) + 'px';
203
+ }
204
+ badges.push(badge);
205
+ }
206
+ }
207
+
208
+ // ---------- popover ----------
209
+ function closePopover() {
210
+ if (!popOpen) return;
211
+ popOpen = false;
212
+ popSave = null;
213
+ dom.pop.style.display = 'none';
214
+ dom.pop.replaceChildren();
215
+ }
216
+
217
+ function openPopover(info, anchorEl) {
218
+ ensureDom();
219
+ closePopover();
220
+ hidePin();
221
+ const pop = dom.pop;
222
+ pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
223
+
224
+ // Existing comments on this exact target, each with a delete button.
225
+ const existingWrap = el('div', { class: 'ann-pop-existing' });
226
+ const renderExisting = () => {
227
+ existingWrap.replaceChildren();
228
+ for (const a of matching(info)) {
229
+ const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }, '×');
230
+ del.addEventListener('click', () => {
231
+ removeAnnotation(a.id);
232
+ renderExisting();
233
+ });
234
+ existingWrap.append(el('div', { class: 'ann-pop-item' }, el('span', { class: 'ann-pop-text' }, a.text), del));
235
+ }
236
+ };
237
+ renderExisting();
238
+ pop.append(existingWrap);
239
+
240
+ const ta = el('textarea', { class: 'ann-ta', placeholder: 'Add a comment…', rows: '3' });
241
+ const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
242
+ const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
243
+ popSave = () => {
244
+ const text = ta.value.trim();
245
+ if (text) addAnnotation(info, text);
246
+ closePopover();
247
+ };
248
+ save.addEventListener('click', () => popSave && popSave());
249
+ cancel.addEventListener('click', closePopover);
250
+ pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
251
+
252
+ // Position: prefer below the anchor, flip above when out of room,
253
+ // clamp to the viewport with an 8px margin. Fixed positioning, so we
254
+ // recompute on open and simply close on big scrolls.
255
+ pop.style.display = 'block';
256
+ pop.style.visibility = 'hidden';
257
+ const rect = anchorEl.getBoundingClientRect();
258
+ const pw = pop.offsetWidth;
259
+ const ph = pop.offsetHeight;
260
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
261
+ let top = rect.bottom + 8;
262
+ if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
263
+ top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
264
+ pop.style.left = left + 'px';
265
+ pop.style.top = top + 'px';
266
+ pop.style.visibility = '';
267
+ popOpen = true;
268
+ popScrollY = window.scrollY;
269
+ ta.focus();
270
+ }
271
+
272
+ // ---------- mutations ----------
273
+ function addAnnotation(info, text) {
274
+ annotations.push({
275
+ id: 'a' + (++idCounter),
276
+ questionId: info.questionId !== undefined ? info.questionId : null,
277
+ blockId: info.blockId !== undefined ? info.blockId : null,
278
+ target: info.target,
279
+ text,
280
+ createdAt: new Date().toISOString(),
281
+ });
282
+ changed();
283
+ }
284
+
285
+ function removeAnnotation(id) {
286
+ const i = annotations.findIndex((a) => a.id === id);
287
+ if (i < 0) return;
288
+ annotations.splice(i, 1);
289
+ changed();
290
+ }
291
+
292
+ function changed() {
293
+ scheduleBadgeRefresh();
294
+ renderSummaries();
295
+ if (onChange) onChange(annotations.slice());
296
+ }
297
+
298
+ // ---------- text selection ----------
299
+ function hideSelBtn() {
300
+ pendingSelection = null;
301
+ if (dom) dom.selBtn.style.display = 'none';
302
+ }
303
+
304
+ function maybeShowSelBtn(rootEl, baseInfo) {
305
+ const sel = window.getSelection();
306
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
307
+ if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
308
+ const range = sel.getRangeAt(0);
309
+ const quote = range.toString();
310
+ if (!quote.trim()) return hideSelBtn();
311
+
312
+ const pre = document.createRange();
313
+ pre.selectNodeContents(rootEl);
314
+ pre.setEnd(range.startContainer, range.startOffset);
315
+ const post = document.createRange();
316
+ post.selectNodeContents(rootEl);
317
+ post.setStart(range.endContainer, range.endOffset);
318
+ pendingSelection = {
319
+ info: {
320
+ blockId: baseInfo.blockId !== undefined ? baseInfo.blockId : null,
321
+ questionId: baseInfo.questionId !== undefined ? baseInfo.questionId : null,
322
+ target: {
323
+ kind: 'text',
324
+ quote: quote.slice(0, 500),
325
+ prefix: pre.toString().slice(-30),
326
+ suffix: post.toString().slice(0, 30),
327
+ },
328
+ },
329
+ };
330
+
331
+ // Float the button near the end of the selection.
332
+ const rects = range.getClientRects();
333
+ const last = rects.length ? rects[rects.length - 1] : range.getBoundingClientRect();
334
+ const btn = dom.selBtn;
335
+ btn.style.display = 'block';
336
+ const bw = btn.offsetWidth || 90;
337
+ const bh = btn.offsetHeight || 30;
338
+ let top = last.bottom + 6;
339
+ if (top + bh > window.innerHeight - 8) top = last.top - bh - 6;
340
+ btn.style.left = Math.max(8, Math.min(last.right - bw / 2, window.innerWidth - bw - 8)) + 'px';
341
+ btn.style.top = Math.max(8, top) + 'px';
342
+ }
343
+
344
+ // ---------- summary ----------
345
+ function renderSummaries() {
346
+ for (const t of summaryEls) {
347
+ if (!t.isConnected) {
348
+ summaryEls.delete(t);
349
+ continue;
350
+ }
351
+ renderSummaryInto(t);
352
+ }
353
+ }
354
+
355
+ function renderSummaryInto(target) {
356
+ target.classList.add('ann-summary');
357
+ target.replaceChildren();
358
+ if (!annotations.length) {
359
+ target.style.display = 'none';
360
+ return;
361
+ }
362
+ target.style.display = '';
363
+ target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
364
+ for (const a of annotations) {
365
+ const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }, '×');
366
+ del.addEventListener('click', (e) => {
367
+ e.stopPropagation();
368
+ removeAnnotation(a.id);
369
+ });
370
+ const row = el('div', { class: 'ann-sum-row' },
371
+ el('div', { class: 'ann-sum-main' },
372
+ el('div', { class: 'ann-sum-target' }, humanize(a.target)),
373
+ el('div', { class: 'ann-sum-text' }, a.text)
374
+ ),
375
+ del
376
+ );
377
+ // Clicking a row jumps to (and flashes) the matching registered element.
378
+ row.addEventListener('click', () => {
379
+ const entry = registered.find(
380
+ (r) => r.el.isConnected && sameBlock(r.info.blockId, a.blockId) && sameTarget(r.info.target, a.target)
381
+ );
382
+ if (!entry) return;
383
+ entry.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
384
+ entry.el.classList.remove('ann-flash');
385
+ void entry.el.getBoundingClientRect(); // restart the animation
386
+ entry.el.classList.add('ann-flash');
387
+ setTimeout(() => entry.el.classList.remove('ann-flash'), 1000);
388
+ });
389
+ target.append(row);
390
+ }
391
+ }
392
+
393
+ // ---------- public API ----------
394
+ function init(opts = {}) {
395
+ ensureDom();
396
+ annotations = Array.isArray(opts.initial) ? opts.initial.map((a) => ({ ...a })) : [];
397
+ onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
398
+ idCounter = 0;
399
+ for (const a of annotations) {
400
+ const m = /^a(\d+)$/.exec(String(a.id || ''));
401
+ if (m) idCounter = Math.max(idCounter, parseInt(m[1], 10));
402
+ }
403
+ scheduleBadgeRefresh();
404
+ renderSummaries();
405
+ }
406
+
407
+ function register(targetEl, info) {
408
+ ensureDom();
409
+ targetEl.classList.add('ann-target');
410
+ const entry = { el: targetEl, info };
411
+ registered.push(entry);
412
+ targetEl.addEventListener('mouseenter', () => showPin(entry));
413
+ targetEl.addEventListener('mouseleave', scheduleHidePin);
414
+ scheduleBadgeRefresh();
415
+ }
416
+
417
+ function enableTextSelection(rootEl, baseInfo) {
418
+ ensureDom();
419
+ rootEl.addEventListener('mouseup', () => {
420
+ // defer: the selection settles after mouseup
421
+ setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
422
+ });
423
+ }
424
+
425
+ function openExternal(info, anchorEl) {
426
+ openPopover(info, anchorEl);
427
+ }
428
+
429
+ function list() {
430
+ return annotations.slice();
431
+ }
432
+
433
+ function renderSummary(target) {
434
+ ensureDom();
435
+ summaryEls.add(target);
436
+ renderSummaryInto(target);
437
+ }
438
+
439
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary };
440
+ })();