@supersuit/artifacts 0.9.0 → 0.11.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +63 -1
  3. package/lib/artifacts/anchor.d.ts +33 -0
  4. package/lib/artifacts/anchor.js +93 -0
  5. package/lib/artifacts/comments.d.ts +38 -0
  6. package/lib/artifacts/comments.js +57 -0
  7. package/lib/artifacts/front-matter.d.ts +7 -0
  8. package/lib/artifacts/front-matter.js +19 -3
  9. package/lib/artifacts/index.d.ts +4 -1
  10. package/lib/artifacts/index.js +4 -1
  11. package/lib/artifacts/personal-store.d.ts +29 -0
  12. package/lib/artifacts/personal-store.js +100 -0
  13. package/lib/artifacts/render.js +60 -52
  14. package/lib/artifacts/state-store.d.ts +10 -0
  15. package/lib/artifacts/state-store.js +19 -0
  16. package/lib/artifacts/state.d.ts +7 -2
  17. package/lib/artifacts/state.js +7 -3
  18. package/lib/artifacts/store.d.ts +4 -0
  19. package/lib/artifacts/store.js +6 -0
  20. package/lib/artifacts/widgets.d.ts +4 -0
  21. package/lib/artifacts/widgets.js +70 -0
  22. package/lib/reader/artifact-reader.d.ts +5 -1
  23. package/lib/reader/artifact-reader.js +23 -17
  24. package/lib/reader/browser-reader.d.ts +8 -0
  25. package/lib/reader/browser-reader.js +171 -0
  26. package/lib/reader/comment-card.d.ts +18 -0
  27. package/lib/reader/comment-card.js +36 -0
  28. package/lib/reader/comment-layer.d.ts +17 -0
  29. package/lib/reader/comment-layer.js +384 -0
  30. package/lib/reader/comment-place.d.ts +45 -0
  31. package/lib/reader/comment-place.js +117 -0
  32. package/lib/reader/device-notes.d.ts +6 -0
  33. package/lib/reader/device-notes.js +35 -0
  34. package/lib/reader/index.d.ts +8 -0
  35. package/lib/reader/index.js +10 -0
  36. package/lib/reader/player-bar.d.ts +18 -0
  37. package/lib/reader/player-bar.js +16 -0
  38. package/lib/reader/speech-plan.d.ts +20 -0
  39. package/lib/reader/speech-plan.js +67 -0
  40. package/lib/routes/artifacts.d.ts +23 -0
  41. package/lib/routes/artifacts.js +26 -11
  42. package/lib/routes/personal-routes.d.ts +24 -0
  43. package/lib/routes/personal-routes.js +100 -0
  44. package/lib/routes/state-routes.d.ts +3 -0
  45. package/lib/routes/state-routes.js +71 -12
  46. package/package.json +3 -3
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // Read-along for a page with no recorded narration: the browser's own voice (speechSynthesis)
4
+ // reads the same word spans the recorded reader lights, and its word-boundary events light them.
5
+ // It sounds worse than a recorded narrator and sends nothing anywhere.
6
+ //
7
+ // One utterance per sentence (see speech-plan.ts). Safari's boundary charIndex drifts on long
8
+ // passages; every new utterance restarts it at zero against offsets we know, so a drift cannot
9
+ // outlast the sentence it began in. Every utterance carries the generation it was spoken in, and
10
+ // an event from an older generation (a sentence cancelled by a click, a seek or a speed change,
11
+ // whose end or error the engine still reports) is ignored.
12
+ //
13
+ // The mount is rendered hidden and empty on the server and on the first client render, so the
14
+ // markup always hydrates; it shows only once the browser is known to have speechSynthesis. It
15
+ // never carries data-artifact-player while hidden, because the popovers measure that element to
16
+ // keep clear of the bar, and a hidden element measures as the whole screen.
17
+ import { useEffect, useRef, useState } from 'react';
18
+ import { BLOCK, seekableWord, wrapWords } from './artifact-reader.js';
19
+ import { nextRate, PlayerBar } from './player-bar.js';
20
+ import { fromWord, planSentences, wordAt } from './speech-plan.js';
21
+ export const BROWSER_READER_LABEL = 'Read aloud by your browser';
22
+ function spokenWords(spans) {
23
+ const words = [];
24
+ const starts = new Set();
25
+ let prevBlock = null;
26
+ spans.forEach((span, i) => {
27
+ // Punctuation glued on across an element edge ("**bold**.") is a tail, not a word; it is
28
+ // spoken with the word it follows, which is also what lets it end a sentence.
29
+ const tail = span.nextSibling instanceof HTMLElement && span.nextSibling.classList.contains('artifact-word-tail') ? span.nextSibling.textContent ?? '' : '';
30
+ words.push((span.textContent ?? '') + tail);
31
+ const block = span.closest(BLOCK);
32
+ if (i > 0 && block !== prevBlock)
33
+ starts.add(i);
34
+ prevBlock = block;
35
+ });
36
+ return { words, starts };
37
+ }
38
+ export function BrowserReader({ rootId, label = BROWSER_READER_LABEL, accent, ground, }) {
39
+ const [ready, setReady] = useState(false);
40
+ const [playing, setPlaying] = useState(false);
41
+ const [current, setCurrent] = useState(-1);
42
+ const [count, setCount] = useState(0);
43
+ const [rate, setRate] = useState(1);
44
+ const spansRef = useRef([]);
45
+ const planRef = useRef([]);
46
+ const genRef = useRef(0);
47
+ const rateRef = useRef(1);
48
+ const currentRef = useRef(-1);
49
+ const playingRef = useRef(false);
50
+ // True from the first play until the page is finished: pause and resume act on a live queue.
51
+ const startedRef = useRef(false);
52
+ const litRef = useRef(null);
53
+ const setPlay = (p) => {
54
+ playingRef.current = p;
55
+ setPlaying(p);
56
+ };
57
+ const light = (i) => {
58
+ const el = i >= 0 ? spansRef.current[i] ?? null : null;
59
+ if (el !== litRef.current) {
60
+ litRef.current?.classList.remove('artifact-word-lit');
61
+ el?.classList.add('artifact-word-lit');
62
+ litRef.current = el;
63
+ if (el && playingRef.current) {
64
+ const r = el.getBoundingClientRect();
65
+ if (r.top < 80 || r.bottom > window.innerHeight - 140)
66
+ el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
67
+ }
68
+ }
69
+ currentRef.current = i;
70
+ setCurrent(i);
71
+ };
72
+ const finish = () => {
73
+ startedRef.current = false;
74
+ setPlay(false);
75
+ light(-1);
76
+ };
77
+ const speak = (s, gen) => {
78
+ const synth = window.speechSynthesis;
79
+ const u = new SpeechSynthesisUtterance(s.text);
80
+ u.rate = rateRef.current;
81
+ u.onboundary = (e) => {
82
+ if (gen !== genRef.current || e.name !== 'word')
83
+ return;
84
+ light(wordAt(s, e.charIndex));
85
+ };
86
+ u.onend = () => {
87
+ if (gen !== genRef.current)
88
+ return;
89
+ const next = s.first + s.offsets.length;
90
+ if (next < spansRef.current.length)
91
+ speak(fromWord(planRef.current, next), gen);
92
+ else
93
+ finish();
94
+ };
95
+ u.onerror = () => {
96
+ if (gen === genRef.current)
97
+ finish();
98
+ };
99
+ // Light the sentence's first word now: some engines send no boundary events at all, and the
100
+ // page should still show where it is.
101
+ light(s.first);
102
+ synth.speak(u);
103
+ };
104
+ /** Cancel whatever is speaking and read from word `i` on. */
105
+ const speakFrom = (i) => {
106
+ if (!planRef.current.length)
107
+ return;
108
+ const synth = window.speechSynthesis;
109
+ const gen = ++genRef.current;
110
+ synth.cancel();
111
+ // An engine paused before the cancel stays paused for the next utterance in some browsers.
112
+ synth.resume();
113
+ startedRef.current = true;
114
+ setPlay(true);
115
+ speak(fromWord(planRef.current, Math.max(0, Math.min(i, spansRef.current.length - 1))), gen);
116
+ };
117
+ const speakFromRef = useRef(speakFrom);
118
+ speakFromRef.current = speakFrom;
119
+ useEffect(() => {
120
+ if (typeof window === 'undefined' || !('speechSynthesis' in window))
121
+ return;
122
+ const root = document.getElementById(rootId);
123
+ if (!root)
124
+ return;
125
+ const spans = wrapWords(root);
126
+ if (!spans.length)
127
+ return;
128
+ const { words, starts } = spokenWords(spans);
129
+ spansRef.current = spans;
130
+ planRef.current = planSentences(words, starts);
131
+ setCount(spans.length);
132
+ setReady(true);
133
+ const onClick = (e) => {
134
+ const el = seekableWord(e.target);
135
+ if (!el)
136
+ return;
137
+ const idx = spansRef.current.indexOf(el);
138
+ if (idx !== -1)
139
+ speakFromRef.current(idx);
140
+ };
141
+ root.addEventListener('click', onClick);
142
+ return () => {
143
+ root.removeEventListener('click', onClick);
144
+ genRef.current++;
145
+ window.speechSynthesis.cancel();
146
+ };
147
+ }, [rootId]);
148
+ const toggle = () => {
149
+ const synth = window.speechSynthesis;
150
+ if (!startedRef.current)
151
+ speakFrom(0);
152
+ else if (playingRef.current) {
153
+ synth.pause();
154
+ setPlay(false);
155
+ }
156
+ else {
157
+ synth.resume();
158
+ setPlay(true);
159
+ }
160
+ };
161
+ if (!ready)
162
+ return _jsx("div", { "data-artifact-reader": "browser", hidden: true });
163
+ return (_jsx("div", { "data-artifact-reader": "browser", children: _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: label, accent: accent, ground: ground, value: Math.max(current, 0), max: Math.max(count - 1, 0), step: 1, onSeek: (v) => speakFrom(v), progress: `${current + 1} / ${count}`, rate: rate, onRate: () => {
164
+ const r = nextRate(rateRef.current);
165
+ rateRef.current = r;
166
+ setRate(r);
167
+ // The engine fixes the rate when an utterance starts, so restart at the current word.
168
+ if (startedRef.current && playingRef.current)
169
+ speakFrom(Math.max(currentRef.current, 0));
170
+ } }) }));
171
+ }
@@ -0,0 +1,18 @@
1
+ import { type CommentsMode } from '../artifacts/comments.js';
2
+ /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
3
+ export declare function warningLine(ownerName: string, mode: CommentsMode): string;
4
+ export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
5
+ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, onSave, onCancel, }: {
6
+ ownerName: string;
7
+ mode: CommentsMode;
8
+ canShare: boolean;
9
+ signedIn: boolean;
10
+ /** The quoted text the comment is pinned to, shown above the box. */
11
+ quote?: string;
12
+ initial?: string;
13
+ accent?: string;
14
+ signIn?: string | null;
15
+ /** Resolves to an error to show, or null when saved. */
16
+ onSave: (body: string, share: boolean) => Promise<string | null>;
17
+ onCancel: () => void;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The card a comment is written in. It says, before anything is typed, who will read it: when the
4
+ // reader cannot share, the first line in the card is the warning that this is a personal note the
5
+ // owner will never see, shown every time, because the moment it matters is the moment someone is
6
+ // about to write something they meant for the owner. When they can share, a switch picks Share
7
+ // (the default) or Just for me.
8
+ import { useState } from 'react';
9
+ import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
10
+ /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
11
+ export function warningLine(ownerName, mode) {
12
+ const why = mode === 'off'
13
+ ? `${ownerName} has not opened this page to comments`
14
+ : `${ownerName} takes comments on this page from signed-in readers`;
15
+ return `Only you will see this. ${why}, so this is a personal note. They won't see it.`;
16
+ }
17
+ export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
18
+ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, onSave, onCancel, }) {
19
+ const [text, setText] = useState(initial);
20
+ const [share, setShare] = useState(canShare);
21
+ const [busy, setBusy] = useState(false);
22
+ const [error, setError] = useState(null);
23
+ const personal = !canShare || !share;
24
+ const submit = async (e) => {
25
+ e?.preventDefault();
26
+ const body = text.trim();
27
+ if (!body || busy)
28
+ return;
29
+ setBusy(true);
30
+ const err = await onSave(body, canShare && share);
31
+ setBusy(false);
32
+ setError(err);
33
+ };
34
+ return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, onChange: (e) => setText(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
35
+ void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
36
+ }
@@ -0,0 +1,17 @@
1
+ import { type CommentsMode } from '../artifacts/comments.js';
2
+ export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }: {
3
+ artifactId: string;
4
+ /** The element holding the page's prose, the same root the read-aloud lights. */
5
+ rootId: string;
6
+ ownerName: string;
7
+ comments: CommentsMode;
8
+ /** This reader may leave shared comments here (comments: is on, and they are signed in where it asks). */
9
+ canShare: boolean;
10
+ signedIn: boolean;
11
+ isOwner: boolean;
12
+ /** The version being read, stored on every comment left on it. */
13
+ version: number;
14
+ accent: string;
15
+ ground: string;
16
+ signIn?: string | null;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,384 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // Comments on any part of a page, Figma style. Select text and a "Comment" chip appears; hold and
4
+ // drag over anything that is not text (an image, a table, the gap between paragraphs) and a box
5
+ // draws. Either opens the card (comment-card.tsx), which says before anything is typed whether
6
+ // the owner will ever read it.
7
+ //
8
+ // Two kinds, kept apart all the way down:
9
+ // SHARED the `comments` slot on the state API; filled pins in the page's accent.
10
+ // PERSONAL the reader's own notes: /personal when signed in, else this device only
11
+ // (device-notes.ts); hollow pins marked "only you". Never on the state API.
12
+ //
13
+ // Pins sit in the margin beside a quote, or on the box drawn. A comment whose quote or block is gone
14
+ // from this version is listed under "Comments on earlier versions" after the article, with its
15
+ // quote, the way the notes widget sets aside notes on a renamed heading. Replies are one level
16
+ // deep and only on shared comments. Everything drawn here is data-nospeak and data-comment-ui, so
17
+ // the read-aloud never reads it and the text index never counts it.
18
+ //
19
+ // Rendered empty on the server and on the first client render so the markup always hydrates; the
20
+ // overlay is portalled onto document.body so its coordinates are page coordinates.
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
+ import { createPortal } from 'react-dom';
23
+ import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
24
+ import { COMMENTS_SLOT } from '../artifacts/comments.js';
25
+ import { CommentCard } from './comment-card.js';
26
+ import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
27
+ import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
28
+ const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
29
+ const LONG_PRESS_MS = 400;
30
+ const DRAG_PX = 8;
31
+ /** Where a drag never starts a box: controls, and anything that is somebody's words. */
32
+ const NOT_FOR_BOXES = 'a, button, input, textarea, select, audio, video[controls], summary, label, [data-comment-ui], [data-note-toggle], [data-heading-notes], [data-artifact-notes], .artifact-defined-term, .artifact-word';
33
+ function isTextTarget(el) {
34
+ if (el.closest(NOT_FOR_BOXES))
35
+ return true;
36
+ for (const c of Array.from(el.childNodes))
37
+ if (c.nodeType === Node.TEXT_NODE && c.textContent?.trim())
38
+ return true;
39
+ return false;
40
+ }
41
+ function sharedFrom(body) {
42
+ const slot = body?.slots?.[COMMENTS_SLOT];
43
+ if (!slot)
44
+ return [];
45
+ const rows = new Map();
46
+ for (const r of Array.isArray(slot.mine) ? slot.mine : [])
47
+ rows.set(String(r.id), { ...r, mine: true });
48
+ for (const r of Array.isArray(slot.shared) ? slot.shared : [])
49
+ rows.set(String(r.id), r);
50
+ const out = [];
51
+ for (const r of rows.values()) {
52
+ if (!isValue(r.value))
53
+ continue;
54
+ out.push({ id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? '') });
55
+ }
56
+ return out.sort((a, b) => a.at.localeCompare(b.at));
57
+ }
58
+ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({ id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at }));
59
+ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }) {
60
+ const stateUrl = `/api/artifacts/${artifactId}/state`;
61
+ const personalUrl = `/api/artifacts/${artifactId}/personal`;
62
+ const [mounted, setMounted] = useState(false);
63
+ const [shared, setShared] = useState([]);
64
+ const [serverNotes, setServerNotes] = useState([]);
65
+ const [device, setDevice] = useState([]);
66
+ const [onServer, setOnServer] = useState(signedIn);
67
+ const [showPins, setShowPins] = useState(true);
68
+ const [panel, setPanel] = useState(false);
69
+ const [chip, setChip] = useState(null);
70
+ const [draft, setDraft] = useState(null);
71
+ const [thread, setThread] = useState(null);
72
+ const [drag, setDrag] = useState(null);
73
+ const [tick, setTick] = useState(0);
74
+ const call = useCallback(async (url, init) => {
75
+ try {
76
+ const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
77
+ return { status: r.status, body: (await r.json().catch(() => ({}))) };
78
+ }
79
+ catch {
80
+ return { status: 0, body: { error: 'that did not reach the page; try again' } };
81
+ }
82
+ }, []);
83
+ const refreshDevice = useCallback(() => setDevice(personalFrom(deviceNotes(artifactId), 'device')), [artifactId]);
84
+ useEffect(() => {
85
+ setMounted(true);
86
+ refreshDevice();
87
+ let live = true;
88
+ if (comments !== 'off') {
89
+ void call(stateUrl).then((r) => { if (live && r.status === 200)
90
+ setShared(sharedFrom(r.body)); });
91
+ }
92
+ if (signedIn) {
93
+ void (async () => {
94
+ const r = await call(personalUrl);
95
+ if (!live)
96
+ return;
97
+ if (r.status !== 200) {
98
+ setOnServer(false);
99
+ return;
100
+ }
101
+ let notes = r.body.notes ?? [];
102
+ // Notes kept on this device before signing in move to the account, then leave the device.
103
+ for (const n of deviceNotes(artifactId)) {
104
+ const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: n.value }) });
105
+ if (m.status !== 200)
106
+ break;
107
+ notes = m.body.notes ?? notes;
108
+ removeDeviceNote(artifactId, n.id);
109
+ }
110
+ if (!live)
111
+ return;
112
+ setServerNotes(personalFrom(notes, 'server'));
113
+ refreshDevice();
114
+ })();
115
+ }
116
+ const relayout = () => setTick((t) => t + 1);
117
+ window.addEventListener('resize', relayout);
118
+ const root = document.getElementById(rootId);
119
+ const ro = typeof ResizeObserver !== 'undefined' && root ? new ResizeObserver(relayout) : null;
120
+ if (ro && root)
121
+ ro.observe(root);
122
+ return () => { live = false; window.removeEventListener('resize', relayout); ro?.disconnect(); };
123
+ }, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice]);
124
+ // Text selection inside the page shows the Comment chip.
125
+ useEffect(() => {
126
+ const root = document.getElementById(rootId);
127
+ if (!root)
128
+ return;
129
+ const check = () => {
130
+ const sel = document.getSelection();
131
+ if (!sel || sel.isCollapsed || !sel.rangeCount)
132
+ return setChip(null);
133
+ const r = sel.getRangeAt(0);
134
+ const common = r.commonAncestorContainer;
135
+ if (!root.contains(common) || (common instanceof Element ? common : common.parentElement)?.closest('[data-comment-ui]'))
136
+ return setChip(null);
137
+ const idx = indexText(root);
138
+ let s = offsetAt(idx, r.startContainer, r.startOffset);
139
+ let e = offsetAt(idx, r.endContainer, r.endOffset);
140
+ if (s === null || e === null)
141
+ return setChip(null);
142
+ while (s < e && /\s/.test(idx.text[s]))
143
+ s++;
144
+ while (e > s && /\s/.test(idx.text[e - 1]))
145
+ e--;
146
+ if (e <= s)
147
+ return setChip(null);
148
+ e = Math.min(e, s + MAX_QUOTE_CHARS);
149
+ const anchor = textAnchorFrom(idx.text, s, e, headingAt(root, idx, s));
150
+ const rect = typeof r.getBoundingClientRect === 'function' ? r.getBoundingClientRect() : null;
151
+ setChip({ anchor, quote: anchor.quote, x: (rect?.right ?? 0) + window.scrollX, y: (rect?.bottom ?? 0) + window.scrollY + 6 });
152
+ };
153
+ let timer;
154
+ // Phones adjust a selection with handles and fire no mouseup; settle on selectionchange instead.
155
+ const settle = () => { clearTimeout(timer); timer = setTimeout(check, 350); };
156
+ root.addEventListener('mouseup', check);
157
+ root.addEventListener('keyup', check);
158
+ document.addEventListener('selectionchange', settle);
159
+ return () => { clearTimeout(timer); root.removeEventListener('mouseup', check); root.removeEventListener('keyup', check); document.removeEventListener('selectionchange', settle); };
160
+ }, [rootId]);
161
+ // Hold and drag over anything that is not text draws a box. A mouse starts after 8px of travel;
162
+ // a finger after a 400 ms long-press, so an ordinary swipe still scrolls.
163
+ const dragRef = useRef(null);
164
+ useEffect(() => {
165
+ const root = document.getElementById(rootId);
166
+ if (!root)
167
+ return;
168
+ const down = (e) => {
169
+ if (e.button !== 0 || !(e.target instanceof Element) || isTextTarget(e.target))
170
+ return;
171
+ const d = { x: e.clientX, y: e.clientY, armed: e.pointerType !== 'touch', drawing: false };
172
+ if (e.pointerType === 'touch')
173
+ d.timer = setTimeout(() => { d.armed = true; navigator.vibrate?.(10); }, LONG_PRESS_MS);
174
+ else
175
+ e.preventDefault();
176
+ dragRef.current = d;
177
+ };
178
+ const move = (e) => {
179
+ const d = dragRef.current;
180
+ if (!d)
181
+ return;
182
+ const far = Math.hypot(e.clientX - d.x, e.clientY - d.y) > DRAG_PX;
183
+ if (!d.armed) {
184
+ if (far) {
185
+ clearTimeout(d.timer);
186
+ dragRef.current = null;
187
+ }
188
+ return;
189
+ }
190
+ if (!d.drawing && !far)
191
+ return;
192
+ d.drawing = true;
193
+ setDrag({ x1: d.x, y1: d.y, x2: e.clientX, y2: e.clientY });
194
+ };
195
+ const up = (e) => {
196
+ const d = dragRef.current;
197
+ dragRef.current = null;
198
+ if (!d)
199
+ return;
200
+ clearTimeout(d.timer);
201
+ setDrag(null);
202
+ if (!d.drawing)
203
+ return;
204
+ const box = { left: Math.min(d.x, e.clientX), right: Math.max(d.x, e.clientX), top: Math.min(d.y, e.clientY), bottom: Math.max(d.y, e.clientY) };
205
+ // The box belongs to the block it covers most, so a drag across a gap still lands somewhere.
206
+ let best = null;
207
+ for (const el of root.querySelectorAll('[data-block]')) {
208
+ const b = el.getBoundingClientRect();
209
+ const area = Math.max(0, Math.min(box.right, b.right) - Math.max(box.left, b.left)) * Math.max(0, Math.min(box.bottom, b.bottom) - Math.max(box.top, b.top));
210
+ if (area > 0 && (!best || area > best.area))
211
+ best = { el, area };
212
+ }
213
+ if (!best)
214
+ return;
215
+ const anchor = regionFrom(best.el.getBoundingClientRect(), { x: d.x, y: d.y }, { x: e.clientX, y: e.clientY }, best.el.dataset.block);
216
+ if (anchor) {
217
+ setChip(null);
218
+ setDraft({ anchor, x: box.right + window.scrollX, y: box.bottom + window.scrollY + 6 });
219
+ }
220
+ };
221
+ // While a finger is drawing, the page must not scroll under it.
222
+ const touchmove = (e) => { if (dragRef.current?.armed)
223
+ e.preventDefault(); };
224
+ const noImageDrag = (e) => { if (dragRef.current)
225
+ e.preventDefault(); };
226
+ root.addEventListener('pointerdown', down);
227
+ window.addEventListener('pointermove', move);
228
+ window.addEventListener('pointerup', up);
229
+ window.addEventListener('pointercancel', up);
230
+ root.addEventListener('touchmove', touchmove, { passive: false });
231
+ root.addEventListener('dragstart', noImageDrag);
232
+ return () => {
233
+ root.removeEventListener('pointerdown', down);
234
+ window.removeEventListener('pointermove', move);
235
+ window.removeEventListener('pointerup', up);
236
+ window.removeEventListener('pointercancel', up);
237
+ root.removeEventListener('touchmove', touchmove);
238
+ root.removeEventListener('dragstart', noImageDrag);
239
+ };
240
+ }, [rootId]);
241
+ const items = useMemo(() => [...shared, ...serverNotes, ...device], [shared, serverNotes, device]);
242
+ const tops = useMemo(() => items.filter((i) => !i.value.parent || !items.some((p) => p.id === i.value.parent)), [items]);
243
+ // Where each top-level comment sits on this version, or that it is on an earlier one.
244
+ const layout = useMemo(() => {
245
+ const placed = [];
246
+ const earlier = [];
247
+ if (!mounted)
248
+ return { placed, earlier };
249
+ const root = document.getElementById(rootId);
250
+ if (!root)
251
+ return { placed, earlier: tops };
252
+ const idx = indexText(root);
253
+ const col = (root.querySelector('article') ?? root).getBoundingClientRect();
254
+ const margin = Math.min(col.right + window.scrollX + 8, window.scrollX + (window.innerWidth || col.right + 40) - 32);
255
+ for (const item of tops) {
256
+ const at = item.value.parent ? null : placeAnchor(root, idx, item.value.anchor);
257
+ if (!at) {
258
+ earlier.push(item);
259
+ continue;
260
+ }
261
+ const b = boxOf(at);
262
+ if (at.kind === 'text')
263
+ placed.push({ item, left: margin, top: b.top });
264
+ else
265
+ placed.push({ item, left: b.left + b.width - 12, top: b.top - 12, box: b });
266
+ }
267
+ // Pins on the same line stack instead of hiding one another.
268
+ placed.sort((a, b) => a.top - b.top || a.left - b.left);
269
+ for (let i = 1; i < placed.length; i++) {
270
+ const p = placed[i - 1];
271
+ if (Math.abs(placed[i].left - p.left) < 20 && placed[i].top - p.top < 26)
272
+ placed[i].top = p.top + 26;
273
+ }
274
+ return { placed, earlier };
275
+ // `tick` re-measures after a resize.
276
+ }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
277
+ const saveNew = async (body, share, d) => {
278
+ const value = { anchor: d.anchor, body, version };
279
+ if (share) {
280
+ const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
281
+ if (r.status !== 200)
282
+ return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
283
+ setShared(sharedFrom(r.body));
284
+ }
285
+ else if (signedIn && onServer) {
286
+ const r = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
287
+ if (r.status === 200)
288
+ setServerNotes(personalFrom(r.body.notes ?? [], 'server'));
289
+ else if (r.body.device === true) {
290
+ setOnServer(false);
291
+ addDeviceNote(artifactId, value);
292
+ refreshDevice();
293
+ }
294
+ else
295
+ return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
296
+ }
297
+ else {
298
+ addDeviceNote(artifactId, value);
299
+ refreshDevice();
300
+ }
301
+ document.getSelection()?.removeAllRanges();
302
+ setDraft(null);
303
+ return null;
304
+ };
305
+ const remove = async (item) => {
306
+ if (item.where === 'device') {
307
+ removeDeviceNote(artifactId, item.id);
308
+ refreshDevice();
309
+ }
310
+ else if (item.kind === 'personal') {
311
+ const r = await call(`${personalUrl}?entry=${encodeURIComponent(item.id)}`, { method: 'DELETE' });
312
+ if (r.status === 200)
313
+ setServerNotes(personalFrom(r.body.notes ?? [], 'server'));
314
+ }
315
+ else {
316
+ const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'remove', entry: item.id }) });
317
+ if (r.status === 200)
318
+ setShared(sharedFrom(r.body));
319
+ }
320
+ if (thread === item.id)
321
+ setThread(null);
322
+ };
323
+ const edit = async (item, body) => {
324
+ const value = { ...item.value, body };
325
+ if (item.where === 'device') {
326
+ replaceDeviceNote(artifactId, item.id, value);
327
+ refreshDevice();
328
+ return null;
329
+ }
330
+ const r = item.kind === 'personal'
331
+ ? await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entry: item.id, value }) })
332
+ : await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'replace', entry: item.id, value }) });
333
+ if (r.status !== 200)
334
+ return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
335
+ if (item.kind === 'personal')
336
+ setServerNotes(personalFrom(r.body.notes ?? [], 'server'));
337
+ else
338
+ setShared(sharedFrom(r.body));
339
+ return null;
340
+ };
341
+ const reply = async (parent, body) => {
342
+ const value = { anchor: parent.value.anchor, body, version, parent: parent.id };
343
+ const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
344
+ if (r.status !== 200)
345
+ return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
346
+ setShared(sharedFrom(r.body));
347
+ return null;
348
+ };
349
+ const open = layout.placed.find((p) => p.item.id === thread);
350
+ const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
351
+ const count = tops.length;
352
+ const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, onSave: (body, share) => saveNew(body, share, draft), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
353
+ return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
354
+ }
355
+ function Pin({ item, left, top, accent, ground, onOpen }) {
356
+ const personal = item.kind === 'personal';
357
+ return (_jsx("button", { type: "button", "data-comment-pin": item.kind, "data-comment-id": item.id, onClick: onOpen, "aria-label": personal ? 'Your personal note (only you)' : `Comment by ${item.name}`, className: "absolute z-30 flex h-6 min-w-6 items-center justify-center gap-1 rounded-full border-2 px-1.5 text-[10px] font-semibold shadow", style: { left, top, borderColor: accent, background: personal ? ground : accent, color: personal ? accent : 'var(--a-on-accent, #111)' }, children: personal ? _jsx("span", { children: "only you" }) : _jsx("span", { "aria-hidden": true, children: "\u25CF" }) }));
358
+ }
359
+ function Floating({ x, y, children }) {
360
+ // Kept inside the screen on a phone, where the margin a pin sits in may be the screen's edge.
361
+ const width = typeof window === 'undefined' ? 360 : Math.min(360, window.innerWidth - 16);
362
+ const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
363
+ return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
364
+ }
365
+ function Thread({ item, replies, canReply, accent, onClose, onRemove, onEdit, onReply }) {
366
+ const [replying, setReplying] = useState(false);
367
+ return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
368
+ ? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
369
+ setReplying(false); return e; } })
370
+ : _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
371
+ }
372
+ function Entry({ item, onRemove, onEdit }) {
373
+ const [editing, setEditing] = useState(false);
374
+ if (editing)
375
+ return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
376
+ setEditing(false); return e; } });
377
+ return (_jsxs("div", { children: [_jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }), _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
378
+ }
379
+ function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
380
+ const [text, setText] = useState(initial);
381
+ const [error, setError] = useState(null);
382
+ return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (text.trim())
383
+ setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2 text-[15px]" }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
384
+ }