@khanglvm/relay 0.5.3 → 0.6.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.
- package/README.md +54 -3
- package/docs/AGENT.md +34 -14
- package/package.json +1 -1
- package/skills/relay/SKILL.md +8 -3
- package/src/cli.js +5 -4
- package/src/server.js +22 -3
- package/src/spec.js +6 -3
- package/src/ui/app.js +65 -23
- package/src/ui/blocks.css +21 -7
- package/src/ui/kit.js +335 -27
package/README.md
CHANGED
|
@@ -19,14 +19,49 @@ note, Submit — and the agent picks up your answers and keeps going:
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
# the rly CLI
|
|
23
|
+
npm i -g @khanglvm/relay
|
|
24
|
+
|
|
25
|
+
# the agent skill → every detected agent (Claude Code, Codex, Cursor, …)
|
|
26
|
+
npx skills add khanglvm/relay --skill relay --all
|
|
27
|
+
|
|
28
|
+
# enforce it (recommended) — a skill alone is an ignorable hint; append relay's
|
|
29
|
+
# short usage rules to your agent's global instructions (per-agent commands below)
|
|
30
|
+
rly skill rules
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
That's it. Next time your agent needs a decision or wants to show you a plan,
|
|
28
34
|
it opens a board like the ones above and waits for your Submit.
|
|
29
35
|
|
|
36
|
+
### Tell your agent to actually use it
|
|
37
|
+
|
|
38
|
+
`rly skill rules` prints relay's short usage rules. A skill is just an ignorable
|
|
39
|
+
hint — these rules in the file your agent *always* reads are what make it reach for
|
|
40
|
+
relay. Append them to your agent's **global** instructions:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
rly skill rules >> ~/.claude/CLAUDE.md # Claude Code
|
|
44
|
+
rly skill rules >> ~/.codex/AGENTS.md # Codex
|
|
45
|
+
rly skill rules >> ~/.gemini/GEMINI.md # Gemini CLI
|
|
46
|
+
rly skill rules >> ~/.config/opencode/AGENTS.md # OpenCode
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Cursor and GitHub Copilot keep global rules in a settings panel, not a file — run
|
|
50
|
+
`rly skill rules` and paste the output into Cursor's *Settings → Rules → User Rules*
|
|
51
|
+
or Copilot's custom-instructions.
|
|
52
|
+
|
|
53
|
+
<details>
|
|
54
|
+
<summary>More agents</summary>
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
rly skill rules >> ~/.codeium/windsurf/memories/global_rules.md # Windsurf
|
|
58
|
+
rly skill rules >> ~/.factory/AGENTS.md # Droid (Factory)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Any other agent: run `rly skill rules` and paste the block into whatever file or
|
|
62
|
+
settings panel it reads as global instructions.
|
|
63
|
+
</details>
|
|
64
|
+
|
|
30
65
|
## What it improves
|
|
31
66
|
|
|
32
67
|
| Without relay | With relay |
|
|
@@ -57,6 +92,22 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
57
92
|
npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
58
93
|
```
|
|
59
94
|
|
|
95
|
+
## Changelog
|
|
96
|
+
|
|
97
|
+
### 0.6.0 — comment on anything
|
|
98
|
+
- **Comment on any part of a custom-HTML mockup.** Hover any element — a heading,
|
|
99
|
+
a button, a card, the price — and a pin appears to leave an inline note. No
|
|
100
|
+
setup needed; the agent writes zero annotation code. Want to scope it? Mark
|
|
101
|
+
specific elements with `data-relay-annotate="label"`.
|
|
102
|
+
- **Radio questions can carry a note.** Pick an option *and* say why, in one
|
|
103
|
+
optional field — now shown by default (set `"note": false` to hide it).
|
|
104
|
+
- **Edge-to-edge fullscreen** for charts and HTML mockups, with the toolbar
|
|
105
|
+
pinned to the top while you scroll.
|
|
106
|
+
- The board **title and intro are commentable** too.
|
|
107
|
+
|
|
108
|
+
### 0.5.0
|
|
109
|
+
- Visual answer options, image blocks, viewer redesign, adoption rules.
|
|
110
|
+
|
|
60
111
|
## Migration from quest-board
|
|
61
112
|
|
|
62
113
|
relay was formerly `@khanglvm/quest-board` (CLI: `qbd`) — that package is
|
package/docs/AGENT.md
CHANGED
|
@@ -6,9 +6,10 @@ then **wait for them to click Submit** and read the answers as JSON from stdout.
|
|
|
6
6
|
No "type 'done' in the terminal", no hand-rolled HTML+server.
|
|
7
7
|
|
|
8
8
|
**Tell the user** at the start of your intro text that they can hover chart
|
|
9
|
-
points, diagram nodes,
|
|
10
|
-
markdown blocks to annotate — their comments
|
|
11
|
-
alongside their answers. Treat annotations as
|
|
9
|
+
points, diagram nodes, table cells, and any element of a custom-HTML block to
|
|
10
|
+
leave comments, and select text in markdown blocks to annotate — their comments
|
|
11
|
+
come back in `result.annotations` alongside their answers. Treat annotations as
|
|
12
|
+
first-class feedback.
|
|
12
13
|
|
|
13
14
|
Everything machine-relevant is on **stdout as JSON**; human-facing logs go to
|
|
14
15
|
stderr. Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
|
|
@@ -137,7 +138,9 @@ boolean/bool/yn→yesno, input→text, longtext→textarea, rating/likert→scal
|
|
|
137
138
|
|
|
138
139
|
Unanswered questions are absent from `answers` and listed in `skipped`.
|
|
139
140
|
Questions with `"note": true` show a small optional free-text field; non-empty
|
|
140
|
-
notes come back in `notes` keyed by question id.
|
|
141
|
+
notes come back in `notes` keyed by question id. **`single` (radio) questions
|
|
142
|
+
show this note by default** so the user can qualify their pick — set
|
|
143
|
+
`"note": false` to hide it. On `timeout`/`cancelled`, a
|
|
141
144
|
`draft` field carries the autosaved partial answers and any annotations written
|
|
142
145
|
so far.
|
|
143
146
|
|
|
@@ -295,23 +298,40 @@ Rules of thumb:
|
|
|
295
298
|
background/text match the user's current theme. **Full documents** are served
|
|
296
299
|
verbatim and receive a `?theme=light|dark` query param on theme toggle.
|
|
297
300
|
|
|
298
|
-
###
|
|
301
|
+
### Custom HTML is hover-commentable automatically
|
|
299
302
|
|
|
300
|
-
|
|
301
|
-
|
|
303
|
+
Every custom-HTML block is annotatable out of the box — relay injects a tiny
|
|
304
|
+
runtime that lets the user **hover any meaningful element** (headings,
|
|
305
|
+
paragraphs, list items, buttons, images, cards, table cells…) to get a comment
|
|
306
|
+
pin, exactly like the rest of the board. You don't have to do anything. Comments
|
|
307
|
+
come back in `result.annotations` with `target.kind = "html-element"`, a stable
|
|
308
|
+
`target.ref` (the element), and a `target.label` derived from the element.
|
|
309
|
+
|
|
310
|
+
Reach for the controls below only when you want to **scope or label** what's
|
|
311
|
+
annotatable — typically for an interactive prototype where blanket hover targets
|
|
312
|
+
would get in the way:
|
|
313
|
+
|
|
314
|
+
**Declarative signal (preferred)** — mark the elements you want commented. Any
|
|
315
|
+
signal present switches the auto-pick off, so only your marked elements are
|
|
316
|
+
annotatable:
|
|
317
|
+
|
|
318
|
+
```html
|
|
319
|
+
<button data-relay-annotate="Primary CTA" data-relay-detail="checkout flow">Buy now</button>
|
|
320
|
+
<section data-relay-annotate="Pricing table">…</section>
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Imperative** — same effect from script (needs `/kit.js`, which relay also
|
|
324
|
+
auto-loads):
|
|
302
325
|
|
|
303
326
|
```html
|
|
304
327
|
<script src="/kit.js"></script>
|
|
305
328
|
<script>
|
|
306
329
|
relayKit.commentable(document.getElementById('chart'), 'Revenue chart', 'Q1 2026');
|
|
307
|
-
relayKit.commentable(document.getElementById('hero-cta'), 'CTA button');
|
|
308
330
|
</script>
|
|
309
331
|
```
|
|
310
332
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
Annotations come back in `result.annotations` with `target.kind = "html-element"`,
|
|
314
|
-
`target.label`, and (if provided) `target.detail`.
|
|
333
|
+
**Opt out** of element annotation for a block with `data-relay-annotate="off"`
|
|
334
|
+
on `<html>` or `<body>`.
|
|
315
335
|
|
|
316
336
|
## Annotations
|
|
317
337
|
|
|
@@ -349,7 +369,7 @@ intro. Annotations are autosaved with the draft and returned in the final result
|
|
|
349
369
|
| `graphviz-node` | `nodeId`, `text` | clicking a Graphviz node or edge |
|
|
350
370
|
| `table-cell` | `row` (0-based), `col` (column key), `value` | clicking a table cell |
|
|
351
371
|
| `text` | `quote`, `prefix` (≤30 chars before), `suffix` (≤30 after) | selecting text in a markdown block |
|
|
352
|
-
| `html-element` | `label`, `detail?` |
|
|
372
|
+
| `html-element` | `ref`, `label`, `detail?` | hovering any element in a custom-HTML block (auto), or a `data-relay-annotate` / `relayKit.commentable()` element |
|
|
353
373
|
| `image` | `label` | clicking a PlantUML diagram or an image block |
|
|
354
374
|
|
|
355
375
|
Read annotations as first-class feedback — they often carry the sharpest insight
|
|
@@ -479,4 +499,4 @@ call rather than calling it repeatedly in a loop.
|
|
|
479
499
|
- For sensitive PlantUML diagrams, set `"server": "https://your-server"` to avoid
|
|
480
500
|
sending source to the public plantuml.com server.
|
|
481
501
|
- Bundled universal skill (Claude Code, Codex, any SKILL.md-aware agent):
|
|
482
|
-
`rly skill install` — or `npx skills add khanglvm/relay`.
|
|
502
|
+
`rly skill install` — or `npx skills add khanglvm/relay --skill relay --all`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -93,7 +93,8 @@ submit with unanswered questions (returned in `skipped`) unless
|
|
|
93
93
|
|
|
94
94
|
Set `"note": true` on a question to add a small optional free-text field under
|
|
95
95
|
it — use when the user may want to qualify their choice. Returned as
|
|
96
|
-
`result.notes[questionId]`.
|
|
96
|
+
`result.notes[questionId]`. `single` (radio) questions include this note by
|
|
97
|
+
default (so a pick can carry a comment); set `"note": false` to hide it.
|
|
97
98
|
|
|
98
99
|
Quick one-liners without a spec file:
|
|
99
100
|
|
|
@@ -153,8 +154,12 @@ pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
|
|
|
153
154
|
|
|
154
155
|
## Annotations
|
|
155
156
|
|
|
156
|
-
Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
|
|
157
|
-
select text in markdown to leave inline
|
|
157
|
+
Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
|
|
158
|
+
any element of a custom-HTML block, or select text in markdown to leave inline
|
|
159
|
+
comments. Custom HTML is hover-commentable automatically — to scope/label what's
|
|
160
|
+
annotatable, mark elements with `data-relay-annotate="Label"` (any signal turns
|
|
161
|
+
the auto-pick off); opt a block out with `data-relay-annotate="off"`. Always
|
|
162
|
+
mention annotation in the board intro.
|
|
158
163
|
|
|
159
164
|
`result.annotations` is an array of:
|
|
160
165
|
|
package/src/cli.js
CHANGED
|
@@ -782,9 +782,10 @@ function cmdSkill(rest) {
|
|
|
782
782
|
bundled at: ${SKILL_SRC}
|
|
783
783
|
install it: rly skill install # auto-detects ~/.claude, ~/.codex, ~/.agents
|
|
784
784
|
rly skill install --target claude|codex|both|<dir>
|
|
785
|
-
from repo: npx skills add khanglvm/relay
|
|
786
|
-
enforce it: rly skill rules
|
|
787
|
-
#
|
|
785
|
+
from repo: npx skills add khanglvm/relay --skill relay --all
|
|
786
|
+
enforce it: rly skill rules # short always-read rules — paste into the file your
|
|
787
|
+
# agent reads (CLAUDE.md, AGENTS.md, .cursor/rules, …);
|
|
788
|
+
# a skill alone is an ignorable hint
|
|
788
789
|
|
|
789
790
|
The skill teaches your agent the board spec format (questions + rich blocks +
|
|
790
791
|
annotations), the blocking vs --detach patterns, and visualization sizing.
|
|
@@ -853,7 +854,7 @@ NOTES answers & annotations autosave in real time (drafts survive timeout
|
|
|
853
854
|
|
|
854
855
|
AI AGENTS run \`rly agent\` for the complete machine-oriented guide.
|
|
855
856
|
a universal skill is bundled — install with \`rly skill install\`
|
|
856
|
-
(or from the repo: npx skills add khanglvm/relay)`);
|
|
857
|
+
(or from the repo: npx skills add khanglvm/relay --skill relay --all)`);
|
|
857
858
|
return 0;
|
|
858
859
|
}
|
|
859
860
|
|
package/src/server.js
CHANGED
|
@@ -230,16 +230,35 @@ function sendFromDir(res, dir, name, contentType) {
|
|
|
230
230
|
return true;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// Loaded into every custom-HTML iframe so users can hover any element to leave a
|
|
234
|
+
// comment (relayKit.annotate.auto). Idempotent with an author-added /kit.js, and
|
|
235
|
+
// a no-op when the author opts out via data-relay-annotate="off".
|
|
236
|
+
const ANNOTATE_BOOTSTRAP =
|
|
237
|
+
'<script>(function(){function go(){try{window.relayKit&&window.relayKit.annotate&&window.relayKit.annotate.auto();}catch(e){}}' +
|
|
238
|
+
'if(window.relayKit&&window.relayKit.annotate)return go();' +
|
|
239
|
+
"var s=document.createElement('script');s.src='/kit.js';s.onload=go;s.onerror=go;" +
|
|
240
|
+
'(document.head||document.documentElement).appendChild(s);})();<\/script>';
|
|
241
|
+
|
|
242
|
+
// Insert a snippet right before </body> (else </html>, else append).
|
|
243
|
+
function injectBeforeBodyEnd(html, snippet) {
|
|
244
|
+
const lower = html.toLowerCase();
|
|
245
|
+
let idx = lower.lastIndexOf('</body>');
|
|
246
|
+
if (idx === -1) idx = lower.lastIndexOf('</html>');
|
|
247
|
+
if (idx === -1) return html + snippet;
|
|
248
|
+
return html.slice(0, idx) + snippet + html.slice(idx);
|
|
249
|
+
}
|
|
250
|
+
|
|
233
251
|
// Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
|
|
234
252
|
// matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
|
|
235
253
|
// block in dark mode. Full documents are served verbatim — their authors can
|
|
236
|
-
// read the ?theme=light|dark query param themselves.
|
|
254
|
+
// read the ?theme=light|dark query param themselves. Either way the annotate
|
|
255
|
+
// bootstrap is injected so every element is hover-commentable.
|
|
237
256
|
function wrapFragment(content, theme) {
|
|
238
|
-
if (/<html[\s>]/i.test(content)) return content;
|
|
257
|
+
if (/<html[\s>]/i.test(content)) return injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP);
|
|
239
258
|
const dark = theme === 'dark';
|
|
240
259
|
const bg = dark ? '#282624' : '#ffffff';
|
|
241
260
|
const fg = dark ? '#edeae4' : '#1c1b19';
|
|
242
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}</body></html>`;
|
|
261
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${ANNOTATE_BOOTSTRAP}</body></html>`;
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
function readBody(req, limit = 5 * 1024 * 1024) {
|
package/src/spec.js
CHANGED
|
@@ -332,7 +332,10 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
332
332
|
label,
|
|
333
333
|
description: asStr(rq.description),
|
|
334
334
|
required: rq.required === true,
|
|
335
|
-
|
|
335
|
+
// Radio (single) questions show the optional per-answer note by default so
|
|
336
|
+
// the user can qualify their pick; other types stay opt-in. An explicit
|
|
337
|
+
// note:false turns it off for a single question.
|
|
338
|
+
note: rq.note === undefined ? type === 'single' : rq.note === true,
|
|
336
339
|
blocks: buildBlocks(rq, cwd, where, `${id}-`),
|
|
337
340
|
placeholder: asStr(rq.placeholder),
|
|
338
341
|
};
|
|
@@ -486,7 +489,7 @@ export const SPEC_SCHEMA = {
|
|
|
486
489
|
},
|
|
487
490
|
},
|
|
488
491
|
other: { type: 'boolean', default: false, description: 'single/multi: add a free-text "Other" option. Its text is returned verbatim as the value.' },
|
|
489
|
-
note: { type: 'boolean',
|
|
492
|
+
note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
|
|
490
493
|
placeholder: { type: 'string', description: 'For text/textarea.' },
|
|
491
494
|
default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
|
|
492
495
|
min: { type: 'integer', default: 1, description: 'scale only' },
|
|
@@ -504,7 +507,7 @@ export const SPEC_SCHEMA = {
|
|
|
504
507
|
type: 'array',
|
|
505
508
|
readOnly: true,
|
|
506
509
|
description:
|
|
507
|
-
'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
|
|
510
|
+
'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"graphviz-node"|"table-cell"|"text"|"html-element"|"image", …}, text, createdAt}. For html-element, target carries a stable ref + label; users can hover any element of a custom-HTML block (automatic) or ones the author marked with data-relay-annotate.',
|
|
508
511
|
},
|
|
509
512
|
blockEdits: {
|
|
510
513
|
type: 'object',
|
package/src/ui/app.js
CHANGED
|
@@ -232,6 +232,9 @@
|
|
|
232
232
|
onChange: (list) => {
|
|
233
233
|
state.annotations = list;
|
|
234
234
|
scheduleSave();
|
|
235
|
+
// Refresh per-element comment badges inside custom-HTML iframes (bridge
|
|
236
|
+
// sets this once the iframe annotate plumbing is wired below).
|
|
237
|
+
if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
|
|
235
238
|
},
|
|
236
239
|
});
|
|
237
240
|
}
|
|
@@ -516,7 +519,13 @@
|
|
|
516
519
|
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
517
520
|
}
|
|
518
521
|
|
|
519
|
-
|
|
522
|
+
const titleEl = el('h1', {}, spec.title);
|
|
523
|
+
app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
|
|
524
|
+
// The board title is commentable too: hovering it shows the pin, like the
|
|
525
|
+
// intro and every other content element. (themeBtn stays out of it.)
|
|
526
|
+
if (spec.title) {
|
|
527
|
+
Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
|
|
528
|
+
}
|
|
520
529
|
if (spec.intro) {
|
|
521
530
|
const intro = el('p', { class: 'intro' }, spec.intro);
|
|
522
531
|
app.append(intro);
|
|
@@ -581,7 +590,7 @@
|
|
|
581
590
|
const hint = el('span', { class: 'hint' },
|
|
582
591
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
583
592
|
app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
|
|
584
|
-
app.append(el('footer', { class: 'qb-footer' }, `
|
|
593
|
+
app.append(el('footer', { class: 'qb-footer' }, `relay · ${boot.boardId}`));
|
|
585
594
|
|
|
586
595
|
// ---------- validation & submit ----------
|
|
587
596
|
function validate() {
|
|
@@ -664,33 +673,66 @@
|
|
|
664
673
|
}
|
|
665
674
|
|
|
666
675
|
// ---------- iframe annotate bridge ----------
|
|
667
|
-
// Custom-HTML iframes (via /kit.js relayKit.
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
676
|
+
// Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
|
|
677
|
+
// server) talk to the parent over postMessage:
|
|
678
|
+
// iframe → {relay:'annotate-ready'} we send it counts
|
|
679
|
+
// iframe → {relay:'annotate-request', ref, label, detail?, rect}
|
|
680
|
+
// parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
|
|
681
|
+
// We own the annotation state, popover, and the submitted result; the iframe
|
|
682
|
+
// owns hover/pin/badges over its own (cross-origin) DOM.
|
|
671
683
|
if (Annotate) {
|
|
672
|
-
|
|
673
|
-
const msg = e.data;
|
|
674
|
-
if (!msg || typeof msg !== 'object' || msg.relay !== 'annotate-request') return;
|
|
675
|
-
let frame = null;
|
|
684
|
+
const frameOf = (source) => {
|
|
676
685
|
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
677
|
-
if (f.contentWindow ===
|
|
686
|
+
if (f.contentWindow === source) return f;
|
|
678
687
|
}
|
|
688
|
+
return null;
|
|
689
|
+
};
|
|
690
|
+
// Per-element comment counts for one iframe, keyed by target.ref.
|
|
691
|
+
const postCountsTo = (frame) => {
|
|
692
|
+
if (!frame || !frame.contentWindow) return;
|
|
693
|
+
const blockId = frame.getAttribute('data-block-id') || null;
|
|
694
|
+
const counts = {};
|
|
695
|
+
for (const a of Annotate.list()) {
|
|
696
|
+
if ((a.blockId ?? null) !== (blockId ?? null)) continue;
|
|
697
|
+
const t = a.target || {};
|
|
698
|
+
if (t.kind !== 'html-element' || !t.ref) continue;
|
|
699
|
+
counts[t.ref] = (counts[t.ref] || 0) + 1;
|
|
700
|
+
}
|
|
701
|
+
try { frame.contentWindow.postMessage({ relay: 'annotate-counts', counts }, '*'); } catch {}
|
|
702
|
+
};
|
|
703
|
+
// Refresh badges in every html iframe (called whenever annotations change).
|
|
704
|
+
window.__relayBroadcastCounts = () => {
|
|
705
|
+
for (const f of document.querySelectorAll('iframe.viz')) postCountsTo(f);
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
window.addEventListener('message', (e) => {
|
|
709
|
+
const msg = e.data;
|
|
710
|
+
if (!msg || typeof msg !== 'object' || typeof msg.relay !== 'string') return;
|
|
711
|
+
const frame = frameOf(e.source);
|
|
679
712
|
if (!frame) return;
|
|
713
|
+
if (msg.relay === 'annotate-ready') { postCountsTo(frame); return; }
|
|
714
|
+
if (msg.relay !== 'annotate-request') return;
|
|
715
|
+
|
|
680
716
|
const blockId = frame.getAttribute('data-block-id') || null;
|
|
681
717
|
const questionId = frame.getAttribute('data-question-id') || null;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
718
|
+
const target = { kind: 'html-element', label: typeof msg.label === 'string' ? msg.label : 'Element' };
|
|
719
|
+
if (typeof msg.ref === 'string') target.ref = msg.ref;
|
|
720
|
+
if (typeof msg.detail === 'string') target.detail = msg.detail;
|
|
721
|
+
|
|
722
|
+
// Anchor the popover to the actual element: translate the iframe-local
|
|
723
|
+
// viewport rect the iframe sent into page coords. Fall back to the iframe.
|
|
724
|
+
let anchor = frame;
|
|
725
|
+
if (msg.rect && typeof msg.rect === 'object') {
|
|
726
|
+
const fr = frame.getBoundingClientRect();
|
|
727
|
+
const r = msg.rect;
|
|
728
|
+
const left = fr.left + (r.left || 0);
|
|
729
|
+
const top = fr.top + (r.top || 0);
|
|
730
|
+
const width = r.width || 0;
|
|
731
|
+
const height = r.height || 0;
|
|
732
|
+
const pageRect = { left, top, width, height, right: left + width, bottom: top + height };
|
|
733
|
+
anchor = { getBoundingClientRect: () => pageRect };
|
|
734
|
+
}
|
|
735
|
+
Annotate.openExternal({ blockId, questionId: questionId || null, target }, anchor);
|
|
694
736
|
});
|
|
695
737
|
}
|
|
696
738
|
|
package/src/ui/blocks.css
CHANGED
|
@@ -307,14 +307,28 @@
|
|
|
307
307
|
.blk-tools .tool-pct { color: var(--muted); font-size: 0.72rem; min-width: 30px; text-align: center; }
|
|
308
308
|
|
|
309
309
|
/* full-screen = fixed overlay (NOT native fullscreen: annotation pins/badges/
|
|
310
|
-
popover live on <body> and must stay visible above the expanded block)
|
|
310
|
+
popover live on <body> and must stay visible above the expanded block).
|
|
311
|
+
Edge-to-edge (no modal gap); the toolbar pins to the top as a fixed bar so
|
|
312
|
+
the close button stays on screen while the content scrolls. */
|
|
311
313
|
.blk-full {
|
|
312
|
-
position: fixed !important; inset:
|
|
313
|
-
background: var(--card); border:
|
|
314
|
-
border-radius: 12px; box-shadow: var(--shadow-lift);
|
|
314
|
+
position: fixed !important; inset: 0; z-index: 49;
|
|
315
|
+
background: var(--card); border: 0; border-radius: 0; box-shadow: none;
|
|
315
316
|
overflow: auto; max-height: none !important; margin: 0 !important;
|
|
316
|
-
padding:
|
|
317
|
+
padding: 44px 0 0;
|
|
317
318
|
}
|
|
318
319
|
body.blk-full-open { overflow: hidden; }
|
|
319
|
-
|
|
320
|
-
.blk-
|
|
320
|
+
/* detach the toolbar from the scrolling content and fix it to the viewport top */
|
|
321
|
+
.blk-full > .blk-tools {
|
|
322
|
+
position: fixed; top: 0; left: 0; right: 0; height: 44px;
|
|
323
|
+
margin: 0; padding: 0 12px;
|
|
324
|
+
background: var(--card); border-bottom: 1px solid var(--border);
|
|
325
|
+
z-index: 51;
|
|
326
|
+
}
|
|
327
|
+
.blk-chart.blk-full {
|
|
328
|
+
height: 100vh !important; width: auto !important;
|
|
329
|
+
padding: 44px 16px 16px;
|
|
330
|
+
}
|
|
331
|
+
.blk-htmlwrap.blk-full iframe.viz {
|
|
332
|
+
height: calc(100vh - 44px) !important; width: 100%;
|
|
333
|
+
margin: 0; border: 0; border-radius: 0;
|
|
334
|
+
}
|
package/src/ui/kit.js
CHANGED
|
@@ -32,13 +32,26 @@
|
|
|
32
32
|
* sortable: click column header to sort ascending/descending.
|
|
33
33
|
*
|
|
34
34
|
* relayKit.commentable(el, label, detail?)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Make one element commentable: hover shows a comment pin, a per-element
|
|
36
|
+
* badge counts comments, click opens the board's annotation popover anchored
|
|
37
|
+
* to the element. Thin shim over relayKit.annotate.register().
|
|
38
|
+
*
|
|
39
|
+
* relayKit.annotate.auto()
|
|
40
|
+
* Injected automatically by the server into every custom-HTML iframe. With no
|
|
41
|
+
* explicit signals it makes a sensible set of content/interactive elements
|
|
42
|
+
* hover-commentable so the user can annotate ANY meaningful part of the HTML.
|
|
43
|
+
* If the author marks elements with data-relay-annotate[="Label"] (+ optional
|
|
44
|
+
* data-relay-detail) or calls commentable(), auto-mode backs off to just those.
|
|
45
|
+
* Opt out with data-relay-annotate="off" on <html> or <body>.
|
|
37
46
|
*/
|
|
38
47
|
|
|
39
48
|
(function () {
|
|
40
49
|
'use strict';
|
|
41
50
|
|
|
51
|
+
// Idempotent: the server may inject a /kit.js load on top of one the author
|
|
52
|
+
// already added. First load wins; a second execution no-ops.
|
|
53
|
+
if (window.relayKit) return;
|
|
54
|
+
|
|
42
55
|
// ---------------------------------------------------------------------------
|
|
43
56
|
// Theme
|
|
44
57
|
// ---------------------------------------------------------------------------
|
|
@@ -357,39 +370,333 @@
|
|
|
357
370
|
}
|
|
358
371
|
|
|
359
372
|
// ---------------------------------------------------------------------------
|
|
360
|
-
//
|
|
373
|
+
// annotate — element-level commenting inside the sandboxed iframe
|
|
361
374
|
// ---------------------------------------------------------------------------
|
|
375
|
+
// The board's annotation engine lives in the PARENT page and can't reach into
|
|
376
|
+
// this cross-origin iframe (sandbox without allow-same-origin), so everything
|
|
377
|
+
// here runs iframe-side and talks to the parent over postMessage:
|
|
378
|
+
// iframe → parent {relay:'annotate-ready'} request counts
|
|
379
|
+
// iframe → parent {relay:'annotate-request', ref, label, detail?, rect}
|
|
380
|
+
// parent → iframe {relay:'annotate-counts', counts:{ref:n}} draw badges
|
|
381
|
+
//
|
|
382
|
+
// `ref` is a compact, reload-stable CSS path used as the element's identity so
|
|
383
|
+
// comments re-bind and badges count per element.
|
|
384
|
+
|
|
385
|
+
const annotate = (() => {
|
|
386
|
+
// Auto-mode picks anything the user would plausibly point at: every element
|
|
387
|
+
// with its OWN direct text (so div/span-based mockups work, not just
|
|
388
|
+
// semantic tags), plus meaningful leaves (icons, buttons, media) and a few
|
|
389
|
+
// semantic containers worth commenting whole. closest() resolves overlaps to
|
|
390
|
+
// the innermost target on hover.
|
|
391
|
+
const LEAF_TAGS = /^(IMG|BUTTON|A|INPUT|SELECT|TEXTAREA|svg|VIDEO|CANVAS|SUMMARY)$/;
|
|
392
|
+
const SKIP_TAGS = /^(SCRIPT|STYLE|HEAD|META|LINK|TITLE|BASE|NOSCRIPT|TEMPLATE)$/;
|
|
393
|
+
const CONTAINER_SELECTOR =
|
|
394
|
+
'li,td,th,figure,blockquote,article,.card,[role="button"],[role="listitem"],[role="option"]';
|
|
395
|
+
const MAX_AUTO = 400;
|
|
396
|
+
// Replaced elements that can't host a badge child — overlay the badge instead.
|
|
397
|
+
const NO_CHILD = /^(IMG|INPUT|HR|BR|EMBED|CANVAS|VIDEO|svg)$/;
|
|
398
|
+
|
|
399
|
+
const byRef = new Map(); // ref -> element
|
|
400
|
+
let mode = null; // 'auto' | 'explicit' (decided on first scan)
|
|
401
|
+
let started = false;
|
|
402
|
+
let hot = null; // currently outlined element
|
|
403
|
+
let pin = null; // floating pin button
|
|
404
|
+
let hideTimer = 0;
|
|
405
|
+
let lastCounts = {};
|
|
406
|
+
let overlayBadges = []; // [{el, badge}] for replaced elements
|
|
407
|
+
let repoTimer = 0;
|
|
408
|
+
|
|
409
|
+
const accent = () => theme.colors.accent;
|
|
410
|
+
|
|
411
|
+
// Compact, reload-stable CSS path — the element's identity.
|
|
412
|
+
function refOf(el) {
|
|
413
|
+
if (el.__relayRef) return el.__relayRef;
|
|
414
|
+
const parts = [];
|
|
415
|
+
let n = el;
|
|
416
|
+
while (n && n.nodeType === 1 && n !== document.body && parts.length < 10) {
|
|
417
|
+
let seg = n.tagName.toLowerCase();
|
|
418
|
+
const p = n.parentNode;
|
|
419
|
+
if (p && p.children) {
|
|
420
|
+
const sibs = Array.prototype.filter.call(p.children, (c) => c.tagName === n.tagName);
|
|
421
|
+
if (sibs.length > 1) seg += ':nth-of-type(' + (sibs.indexOf(n) + 1) + ')';
|
|
422
|
+
}
|
|
423
|
+
parts.unshift(seg);
|
|
424
|
+
n = p;
|
|
425
|
+
}
|
|
426
|
+
const ref = parts.join('>') || el.tagName.toLowerCase();
|
|
427
|
+
el.__relayRef = ref;
|
|
428
|
+
return ref;
|
|
429
|
+
}
|
|
362
430
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
431
|
+
// Human label when the author didn't supply one.
|
|
432
|
+
function labelOf(el) {
|
|
433
|
+
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
434
|
+
if (aria) return aria.trim().slice(0, 80) || el.tagName.toLowerCase();
|
|
435
|
+
if (el.tagName === 'IMG') return (el.getAttribute('alt') || 'Image').trim().slice(0, 80) || 'Image';
|
|
436
|
+
const txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
437
|
+
return txt ? txt.slice(0, 80) : el.tagName.toLowerCase();
|
|
438
|
+
}
|
|
368
439
|
|
|
369
|
-
|
|
370
|
-
|
|
440
|
+
function injectStyle() {
|
|
441
|
+
if (document.getElementById('relay-ann-style')) return;
|
|
442
|
+
const s = document.createElement('style');
|
|
443
|
+
s.id = 'relay-ann-style';
|
|
444
|
+
s.textContent = [
|
|
445
|
+
'.relay-ann-hot{outline:2px solid ' + accent() + ' !important;outline-offset:1px !important;}',
|
|
446
|
+
'.relay-ann-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;padding:0 4px;' +
|
|
447
|
+
'box-sizing:border-box;border-radius:9px;background:' + accent() + ';color:#fff;font:600 10px/16px ' + SANS + ';' +
|
|
448
|
+
'text-align:center;z-index:2147483646;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.25);}',
|
|
449
|
+
].join('\n');
|
|
450
|
+
(document.head || document.documentElement).appendChild(s);
|
|
451
|
+
}
|
|
371
452
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
453
|
+
function ensurePin() {
|
|
454
|
+
if (pin || !document.body) return pin;
|
|
455
|
+
pin = document.createElement('button');
|
|
456
|
+
pin.type = 'button';
|
|
457
|
+
pin.setAttribute('aria-label', 'Add a comment');
|
|
458
|
+
pin.title = 'Add a comment';
|
|
459
|
+
pin.style.cssText = [
|
|
460
|
+
'position:fixed', 'z-index:2147483647', 'display:none', 'width:22px', 'height:22px',
|
|
461
|
+
'padding:0', 'border:none', 'border-radius:50%', 'cursor:pointer', 'background:' + accent(),
|
|
462
|
+
'color:#fff', 'align-items:center', 'justify-content:center', 'line-height:0',
|
|
463
|
+
'box-shadow:0 1px 4px rgba(0,0,0,.3)',
|
|
464
|
+
].join(';');
|
|
465
|
+
pin.innerHTML =
|
|
466
|
+
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
467
|
+
'<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>';
|
|
468
|
+
pin.addEventListener('mouseenter', () => clearTimeout(hideTimer));
|
|
469
|
+
pin.addEventListener('mouseleave', scheduleHide);
|
|
470
|
+
pin.addEventListener('click', (e) => {
|
|
471
|
+
e.preventDefault();
|
|
381
472
|
e.stopPropagation();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
473
|
+
if (hot) request(hot);
|
|
474
|
+
hidePin();
|
|
475
|
+
});
|
|
476
|
+
document.body.appendChild(pin);
|
|
477
|
+
return pin;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function showPinFor(el) {
|
|
481
|
+
if (!ensurePin()) return;
|
|
482
|
+
clearTimeout(hideTimer);
|
|
483
|
+
if (hot && hot !== el) hot.classList.remove('relay-ann-hot');
|
|
484
|
+
hot = el;
|
|
485
|
+
el.classList.add('relay-ann-hot');
|
|
486
|
+
const r = el.getBoundingClientRect();
|
|
487
|
+
const w = 22;
|
|
488
|
+
pin.style.display = 'flex';
|
|
489
|
+
pin.style.left = Math.max(2, Math.min(r.right - w / 2, window.innerWidth - w - 2)) + 'px';
|
|
490
|
+
pin.style.top = Math.max(2, Math.min(r.top - w / 2, window.innerHeight - w - 2)) + 'px';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function scheduleHide() {
|
|
494
|
+
clearTimeout(hideTimer);
|
|
495
|
+
hideTimer = setTimeout(hidePin, 220); // grace so the pin itself stays clickable
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function hidePin() {
|
|
499
|
+
clearTimeout(hideTimer);
|
|
500
|
+
if (hot) hot.classList.remove('relay-ann-hot');
|
|
501
|
+
hot = null;
|
|
502
|
+
if (pin) pin.style.display = 'none';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Ask the parent to open its annotation popover for this element.
|
|
506
|
+
function request(el) {
|
|
507
|
+
const r = el.getBoundingClientRect();
|
|
508
|
+
const msg = {
|
|
509
|
+
relay: 'annotate-request',
|
|
510
|
+
ref: refOf(el),
|
|
511
|
+
label: String(el.__relayLabel || labelOf(el)).slice(0, 200),
|
|
512
|
+
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
|
|
513
|
+
};
|
|
514
|
+
if (el.__relayDetail != null) msg.detail = String(el.__relayDetail).slice(0, 500);
|
|
515
|
+
try { parent.postMessage(msg, '*'); } catch (_) {}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function positionOverlay(el, badge) {
|
|
519
|
+
const r = el.getBoundingClientRect();
|
|
520
|
+
badge.style.left = (r.right + window.scrollX - 9) + 'px';
|
|
521
|
+
badge.style.top = (r.top + window.scrollY - 7) + 'px';
|
|
522
|
+
badge.style.right = 'auto';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function repositionOverlays() {
|
|
526
|
+
for (const o of overlayBadges) if (o.el.isConnected) positionOverlay(o.el, o.badge);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function renderBadges(counts) {
|
|
530
|
+
lastCounts = counts || {};
|
|
531
|
+
for (const b of document.querySelectorAll('.relay-ann-badge')) b.remove();
|
|
532
|
+
overlayBadges = [];
|
|
533
|
+
for (const ref of Object.keys(lastCounts)) {
|
|
534
|
+
const n = lastCounts[ref];
|
|
535
|
+
if (!n) continue;
|
|
536
|
+
const el = byRef.get(ref);
|
|
537
|
+
if (!el || !el.isConnected) continue;
|
|
538
|
+
const badge = document.createElement('span');
|
|
539
|
+
badge.className = 'relay-ann-badge';
|
|
540
|
+
badge.textContent = String(n);
|
|
541
|
+
badge.title = n + (n === 1 ? ' comment' : ' comments');
|
|
542
|
+
badge.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); request(el); });
|
|
543
|
+
if (NO_CHILD.test(el.tagName)) {
|
|
544
|
+
badge.style.position = 'absolute';
|
|
545
|
+
document.body.appendChild(badge);
|
|
546
|
+
overlayBadges.push({ el, badge });
|
|
547
|
+
positionOverlay(el, badge);
|
|
548
|
+
} else {
|
|
549
|
+
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
|
|
550
|
+
el.appendChild(badge);
|
|
388
551
|
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function add(el, label, detail) {
|
|
556
|
+
if (!el || el.nodeType !== 1) return;
|
|
557
|
+
byRef.set(refOf(el), el);
|
|
558
|
+
el.classList.add('relay-annotatable');
|
|
559
|
+
if (label != null && label !== '') el.__relayLabel = String(label);
|
|
560
|
+
if (detail != null) el.__relayDetail = detail;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// True when the element holds non-whitespace text of its OWN (a direct text
|
|
564
|
+
// node), not just text inherited from descendants.
|
|
565
|
+
function hasDirectText(el) {
|
|
566
|
+
for (const n of el.childNodes) {
|
|
567
|
+
if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim()) return true;
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function autoPick() {
|
|
573
|
+
let all;
|
|
574
|
+
try { all = document.body.querySelectorAll('*'); } catch (_) { return []; }
|
|
575
|
+
let containers;
|
|
576
|
+
try { containers = new Set(document.querySelectorAll(CONTAINER_SELECTOR)); } catch (_) { containers = new Set(); }
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const el of all) {
|
|
579
|
+
if (out.length >= MAX_AUTO) break;
|
|
580
|
+
if (SKIP_TAGS.test(el.tagName)) continue;
|
|
581
|
+
if (pin && pin.contains(el)) continue; // our own pin
|
|
582
|
+
if (el.classList && el.classList.contains('relay-ann-badge')) continue;
|
|
583
|
+
if (LEAF_TAGS.test(el.tagName) || containers.has(el) || hasDirectText(el)) out.push(el);
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function scan() {
|
|
589
|
+
const signalled = Array.prototype.filter.call(
|
|
590
|
+
document.querySelectorAll('[data-relay-annotate]'),
|
|
591
|
+
(el) => el.getAttribute('data-relay-annotate') !== 'off'
|
|
592
|
+
);
|
|
593
|
+
// ANY explicit signal (attribute or a prior commentable() call) → the
|
|
594
|
+
// author scopes what's annotatable; auto-mode stays off.
|
|
595
|
+
if (mode === null) mode = signalled.length > 0 || byRef.size > 0 ? 'explicit' : 'auto';
|
|
596
|
+
if (mode === 'explicit') {
|
|
597
|
+
for (const el of signalled) add(el, el.getAttribute('data-relay-annotate') || null, el.getAttribute('data-relay-detail'));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
for (const el of autoPick()) add(el, null, undefined);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function optedOut() {
|
|
604
|
+
const v = (document.body && document.body.getAttribute('data-relay-annotate')) ||
|
|
605
|
+
document.documentElement.getAttribute('data-relay-annotate');
|
|
606
|
+
return v === 'off';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function announce() {
|
|
610
|
+
try { parent.postMessage({ relay: 'annotate-ready' }, '*'); } catch (_) {}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function start() {
|
|
614
|
+
if (started) return;
|
|
615
|
+
if (!document.body) { document.addEventListener('DOMContentLoaded', start, { once: true }); return; }
|
|
616
|
+
started = true;
|
|
617
|
+
injectStyle();
|
|
618
|
+
ensurePin();
|
|
619
|
+
// Delegated hover — closest() resolves nested targets to the innermost.
|
|
620
|
+
document.addEventListener('mouseover', (e) => {
|
|
621
|
+
const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
|
|
622
|
+
if (t) showPinFor(t);
|
|
623
|
+
}, true);
|
|
624
|
+
document.addEventListener('mouseout', (e) => {
|
|
625
|
+
const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
|
|
626
|
+
if (t) scheduleHide();
|
|
627
|
+
}, true);
|
|
628
|
+
window.addEventListener('scroll', () => {
|
|
629
|
+
hidePin();
|
|
630
|
+
if (overlayBadges.length) { cancelAnimationFrame(repoTimer); repoTimer = requestAnimationFrame(repositionOverlays); }
|
|
631
|
+
}, true);
|
|
632
|
+
window.addEventListener('resize', () => { hidePin(); repositionOverlays(); });
|
|
633
|
+
window.addEventListener('message', (e) => {
|
|
634
|
+
const m = e.data;
|
|
635
|
+
if (m && typeof m === 'object' && m.relay === 'annotate-counts') renderBadges(m.counts || {});
|
|
389
636
|
});
|
|
390
|
-
|
|
391
|
-
|
|
637
|
+
// Re-pick up DOM that author JS builds after load. We observe childList
|
|
638
|
+
// only (not attributes, so our own hover-class toggles don't fire it) and
|
|
639
|
+
// ignore mutations that only touch our own pin/badge nodes — otherwise
|
|
640
|
+
// rendering a badge would re-trigger the observer in a tight loop.
|
|
641
|
+
try {
|
|
642
|
+
const ours = (n) =>
|
|
643
|
+
n.nodeType === 1 &&
|
|
644
|
+
(n === pin || (pin && pin.contains(n)) || n.id === 'relay-ann-style' ||
|
|
645
|
+
(n.classList && n.classList.contains('relay-ann-badge')));
|
|
646
|
+
const mo = new MutationObserver((muts) => {
|
|
647
|
+
let relevant = false;
|
|
648
|
+
for (const m of muts) {
|
|
649
|
+
for (const n of m.addedNodes) if (!ours(n)) { relevant = true; break; }
|
|
650
|
+
if (relevant) break;
|
|
651
|
+
for (const n of m.removedNodes) if (!ours(n)) { relevant = true; break; }
|
|
652
|
+
if (relevant) break;
|
|
653
|
+
}
|
|
654
|
+
if (!relevant) return;
|
|
655
|
+
clearTimeout(mo._t);
|
|
656
|
+
mo._t = setTimeout(() => { scan(); renderBadges(lastCounts); announce(); }, 250);
|
|
657
|
+
});
|
|
658
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
659
|
+
} catch (_) {}
|
|
660
|
+
announce();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Server-injected entrypoint. Idempotent.
|
|
664
|
+
function auto() {
|
|
665
|
+
try {
|
|
666
|
+
if (optedOut()) return;
|
|
667
|
+
if (!document.body) { document.addEventListener('DOMContentLoaded', auto, { once: true }); return; }
|
|
668
|
+
scan();
|
|
669
|
+
start();
|
|
670
|
+
} catch (err) {
|
|
671
|
+
console.warn('[relayKit] annotate.auto error:', err);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Explicit per-element registration (also used by commentable()).
|
|
676
|
+
function register(el, label, detail) {
|
|
677
|
+
try {
|
|
678
|
+
add(el, label, detail);
|
|
679
|
+
if (started) renderBadges(lastCounts);
|
|
680
|
+
else start();
|
|
681
|
+
} catch (err) {
|
|
682
|
+
console.warn('[relayKit] annotate.register error:', err);
|
|
683
|
+
}
|
|
392
684
|
}
|
|
685
|
+
|
|
686
|
+
return { auto, register };
|
|
687
|
+
})();
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// commentable() — back-compat shim over annotate.register()
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
function commentable(el, label, detail) {
|
|
694
|
+
if (!el) return;
|
|
695
|
+
annotate.register(
|
|
696
|
+
el,
|
|
697
|
+
label != null ? String(label).slice(0, 200) : null,
|
|
698
|
+
detail != null ? String(detail).slice(0, 500) : undefined
|
|
699
|
+
);
|
|
393
700
|
}
|
|
394
701
|
|
|
395
702
|
// ---------------------------------------------------------------------------
|
|
@@ -403,6 +710,7 @@
|
|
|
403
710
|
mermaid,
|
|
404
711
|
table,
|
|
405
712
|
commentable,
|
|
713
|
+
annotate,
|
|
406
714
|
};
|
|
407
715
|
|
|
408
716
|
window.relayKit = relayKit;
|