@supersuit/artifacts 0.10.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.
- package/CHANGELOG.md +32 -0
- package/README.md +62 -0
- package/lib/artifacts/anchor.d.ts +33 -0
- package/lib/artifacts/anchor.js +93 -0
- package/lib/artifacts/comments.d.ts +38 -0
- package/lib/artifacts/comments.js +57 -0
- package/lib/artifacts/front-matter.d.ts +7 -0
- package/lib/artifacts/front-matter.js +19 -3
- package/lib/artifacts/index.d.ts +4 -1
- package/lib/artifacts/index.js +4 -1
- package/lib/artifacts/personal-store.d.ts +29 -0
- package/lib/artifacts/personal-store.js +100 -0
- package/lib/artifacts/render.js +60 -52
- package/lib/artifacts/state-store.d.ts +10 -0
- package/lib/artifacts/state-store.js +19 -0
- package/lib/artifacts/state.d.ts +7 -2
- package/lib/artifacts/state.js +7 -3
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +6 -0
- package/lib/artifacts/widgets.d.ts +4 -0
- package/lib/artifacts/widgets.js +70 -0
- package/lib/reader/comment-card.d.ts +18 -0
- package/lib/reader/comment-card.js +36 -0
- package/lib/reader/comment-layer.d.ts +17 -0
- package/lib/reader/comment-layer.js +384 -0
- package/lib/reader/comment-place.d.ts +45 -0
- package/lib/reader/comment-place.js +117 -0
- package/lib/reader/device-notes.d.ts +6 -0
- package/lib/reader/device-notes.js +35 -0
- package/lib/reader/index.d.ts +4 -0
- package/lib/reader/index.js +4 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +23 -11
- package/lib/routes/personal-routes.d.ts +24 -0
- package/lib/routes/personal-routes.js +100 -0
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +71 -12
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Anchor, type RegionAnchor } from '../artifacts/anchor.js';
|
|
2
|
+
export type TextIndex = {
|
|
3
|
+
text: string;
|
|
4
|
+
nodes: {
|
|
5
|
+
node: Text;
|
|
6
|
+
start: number;
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
9
|
+
/** Every text node under the root, in order, joined. Word spans the read-aloud wraps around the
|
|
10
|
+
* words keep the same text, so the string is the same before and after they are drawn. */
|
|
11
|
+
export declare function indexText(root: HTMLElement): TextIndex;
|
|
12
|
+
/** The offset in the index of a selection boundary (a text node and an offset in it, or an element
|
|
13
|
+
* and a child index). Null when the boundary is not under the indexed root. */
|
|
14
|
+
export declare function offsetAt(idx: TextIndex, container: Node, offset: number): number | null;
|
|
15
|
+
/** A Range over [start, end) of the index. */
|
|
16
|
+
export declare function rangeOf(idx: TextIndex, start: number, end: number): Range | null;
|
|
17
|
+
/** The slug of the last heading at or before an offset: the section a quote sat under. */
|
|
18
|
+
export declare function headingAt(root: HTMLElement, idx: TextIndex, offset: number): string | undefined;
|
|
19
|
+
export type Placement = {
|
|
20
|
+
kind: 'text';
|
|
21
|
+
range: Range;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'region';
|
|
24
|
+
block: HTMLElement;
|
|
25
|
+
anchor: RegionAnchor;
|
|
26
|
+
};
|
|
27
|
+
/** Where an anchor is on this page now, or null: the comment is then on an earlier version. */
|
|
28
|
+
export declare function placeAnchor(root: HTMLElement, idx: TextIndex, a: Anchor): Placement | null;
|
|
29
|
+
type Box = {
|
|
30
|
+
left: number;
|
|
31
|
+
top: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
35
|
+
/** Page coordinates (scroll included) of a placement: a text quote's first line, or the drawn box. */
|
|
36
|
+
export declare function boxOf(p: Placement): Box;
|
|
37
|
+
/** A box dragged over a block, as fractions of that block, clamped inside it. */
|
|
38
|
+
export declare function regionFrom(block: DOMRect, a: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}, b: {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
}, id: string): RegionAnchor | null;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Between the page in the browser and a comment's anchor: the page's text as one string (so a
|
|
2
|
+
// selection becomes a text-quote anchor and an anchor becomes a Range again), and where on the
|
|
3
|
+
// screen each anchor sits. DOM only, no React, so the layer and its tests share it.
|
|
4
|
+
import { findText } from '../artifacts/anchor.js';
|
|
5
|
+
/** Text that is on the page but not the page's prose: the notes widget, hidden definition copy,
|
|
6
|
+
* and anything the comment layer itself draws. */
|
|
7
|
+
const SKIP = 'script, style, [data-heading-notes], [data-artifact-notes], .artifact-defined-note, [data-comment-ui]';
|
|
8
|
+
/** Every text node under the root, in order, joined. Word spans the read-aloud wraps around the
|
|
9
|
+
* words keep the same text, so the string is the same before and after they are drawn. */
|
|
10
|
+
export function indexText(root) {
|
|
11
|
+
const nodes = [];
|
|
12
|
+
let text = '';
|
|
13
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
14
|
+
acceptNode: (n) => (n.parentElement?.closest(SKIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
15
|
+
});
|
|
16
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
17
|
+
nodes.push({ node: n, start: text.length });
|
|
18
|
+
text += n.data;
|
|
19
|
+
}
|
|
20
|
+
return { text, nodes };
|
|
21
|
+
}
|
|
22
|
+
/** The offset in the index of a selection boundary (a text node and an offset in it, or an element
|
|
23
|
+
* and a child index). Null when the boundary is not under the indexed root. */
|
|
24
|
+
export function offsetAt(idx, container, offset) {
|
|
25
|
+
const direct = idx.nodes.find((n) => n.node === container);
|
|
26
|
+
if (direct)
|
|
27
|
+
return direct.start + Math.min(offset, direct.node.data.length);
|
|
28
|
+
const at = document.createRange();
|
|
29
|
+
try {
|
|
30
|
+
at.setStart(container, offset);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
at.collapse(true);
|
|
36
|
+
let pos = null;
|
|
37
|
+
for (const { node, start } of idx.nodes) {
|
|
38
|
+
// A text node wholly before the boundary counts in full; the first one after it stops the count.
|
|
39
|
+
if (at.comparePoint(node, node.data.length) <= 0)
|
|
40
|
+
pos = start + node.data.length;
|
|
41
|
+
else {
|
|
42
|
+
pos ??= start;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return pos;
|
|
47
|
+
}
|
|
48
|
+
/** A Range over [start, end) of the index. */
|
|
49
|
+
export function rangeOf(idx, start, end) {
|
|
50
|
+
const locate = (i, isEnd) => {
|
|
51
|
+
for (const { node, start: s } of idx.nodes) {
|
|
52
|
+
const e = s + node.data.length;
|
|
53
|
+
if (i < e || (isEnd && i === e))
|
|
54
|
+
return { node, off: i - s };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
const a = locate(start, false);
|
|
59
|
+
const b = locate(end, true);
|
|
60
|
+
if (!a || !b)
|
|
61
|
+
return null;
|
|
62
|
+
const r = document.createRange();
|
|
63
|
+
r.setStart(a.node, a.off);
|
|
64
|
+
r.setEnd(b.node, b.off);
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
/** The slug of the last heading at or before an offset: the section a quote sat under. */
|
|
68
|
+
export function headingAt(root, idx, offset) {
|
|
69
|
+
let slug;
|
|
70
|
+
for (const h of root.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')) {
|
|
71
|
+
const at = offsetAt(idx, h, 0);
|
|
72
|
+
if (at !== null && at <= offset)
|
|
73
|
+
slug = h.id;
|
|
74
|
+
}
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
/** Where an anchor is on this page now, or null: the comment is then on an earlier version. */
|
|
78
|
+
export function placeAnchor(root, idx, a) {
|
|
79
|
+
if (a.kind === 'text') {
|
|
80
|
+
const at = findText(idx.text, a);
|
|
81
|
+
const range = at ? rangeOf(idx, at.start, at.end) : null;
|
|
82
|
+
return range ? { kind: 'text', range } : null;
|
|
83
|
+
}
|
|
84
|
+
const block = root.querySelector(`[data-block="${a.block}"]`);
|
|
85
|
+
return block ? { kind: 'region', block, anchor: a } : null;
|
|
86
|
+
}
|
|
87
|
+
const pageBox = (r) => ({ left: r.left + window.scrollX, top: r.top + window.scrollY, width: r.width, height: r.height });
|
|
88
|
+
/** Page coordinates (scroll included) of a placement: a text quote's first line, or the drawn box. */
|
|
89
|
+
export function boxOf(p) {
|
|
90
|
+
if (p.kind === 'text') {
|
|
91
|
+
// jsdom has no Range geometry; a browser always does.
|
|
92
|
+
const rects = typeof p.range.getClientRects === 'function' ? Array.from(p.range.getClientRects()) : [];
|
|
93
|
+
const first = rects.find((r) => r.width || r.height) ?? p.range.startContainer.parentElement?.getBoundingClientRect();
|
|
94
|
+
return pageBox(first ?? { left: 0, top: 0, width: 0, height: 0 });
|
|
95
|
+
}
|
|
96
|
+
const b = p.block.getBoundingClientRect();
|
|
97
|
+
return pageBox({ left: b.left + p.anchor.x * b.width, top: b.top + p.anchor.y * b.height, width: p.anchor.w * b.width, height: p.anchor.h * b.height });
|
|
98
|
+
}
|
|
99
|
+
/** A box dragged over a block, as fractions of that block, clamped inside it. */
|
|
100
|
+
export function regionFrom(block, a, b, id) {
|
|
101
|
+
if (!block.width || !block.height)
|
|
102
|
+
return null;
|
|
103
|
+
const clamp = (v) => Math.min(1, Math.max(0, v));
|
|
104
|
+
const x1 = clamp((Math.min(a.x, b.x) - block.left) / block.width);
|
|
105
|
+
const x2 = clamp((Math.max(a.x, b.x) - block.left) / block.width);
|
|
106
|
+
const y1 = clamp((Math.min(a.y, b.y) - block.top) / block.height);
|
|
107
|
+
const y2 = clamp((Math.max(a.y, b.y) - block.top) / block.height);
|
|
108
|
+
if (x2 - x1 <= 0 || y2 - y1 <= 0)
|
|
109
|
+
return null;
|
|
110
|
+
// Rounded at the edges and measured between them, so x + w never rounds past the block's edge.
|
|
111
|
+
const r4 = (v) => Math.round(v * 10000) / 10000;
|
|
112
|
+
const w = r4(x2) - r4(x1);
|
|
113
|
+
const h = r4(y2) - r4(y1);
|
|
114
|
+
if (w <= 0 || h <= 0)
|
|
115
|
+
return null;
|
|
116
|
+
return { kind: 'region', block: id, x: r4(x1), y: r4(y1), w, h };
|
|
117
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PersonalNote, PersonalNoteValue } from '../artifacts/personal-store.js';
|
|
2
|
+
export declare const deviceNotesKey: (artifactId: string) => string;
|
|
3
|
+
export declare const deviceNotes: (artifactId: string) => PersonalNote[];
|
|
4
|
+
export declare function addDeviceNote(artifactId: string, value: PersonalNoteValue): PersonalNote;
|
|
5
|
+
export declare function replaceDeviceNote(artifactId: string, id: string, value: PersonalNoteValue): void;
|
|
6
|
+
export declare function removeDeviceNote(artifactId: string, id: string): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const deviceNotesKey = (artifactId) => `artifact-notes:${location.host}:${artifactId}`;
|
|
2
|
+
function read(artifactId) {
|
|
3
|
+
try {
|
|
4
|
+
const raw = JSON.parse(window.localStorage.getItem(deviceNotesKey(artifactId)) ?? '[]');
|
|
5
|
+
if (!Array.isArray(raw))
|
|
6
|
+
return [];
|
|
7
|
+
return raw.filter((n) => !!n && typeof n === 'object' && typeof n.id === 'string' && typeof n.at === 'string' && !!n.value && typeof n.value === 'object');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function write(artifactId, notes) {
|
|
14
|
+
try {
|
|
15
|
+
if (notes.length)
|
|
16
|
+
window.localStorage.setItem(deviceNotesKey(artifactId), JSON.stringify(notes));
|
|
17
|
+
else
|
|
18
|
+
window.localStorage.removeItem(deviceNotesKey(artifactId));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Storage full or blocked (a private window): the note is lost with the tab, and nothing breaks.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const deviceNotes = (artifactId) => read(artifactId);
|
|
25
|
+
export function addDeviceNote(artifactId, value) {
|
|
26
|
+
const n = { id: `d-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, value, at: new Date().toISOString() };
|
|
27
|
+
write(artifactId, [...read(artifactId), n]);
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
export function replaceDeviceNote(artifactId, id, value) {
|
|
31
|
+
write(artifactId, read(artifactId).map((n) => (n.id === id ? { ...n, value, updatedAt: new Date().toISOString() } : n)));
|
|
32
|
+
}
|
|
33
|
+
export function removeDeviceNote(artifactId, id) {
|
|
34
|
+
write(artifactId, read(artifactId).filter((n) => n.id !== id));
|
|
35
|
+
}
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -2,3 +2,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming
|
|
|
2
2
|
export { BrowserReader } from './browser-reader.js';
|
|
3
3
|
export { PlayerBar } from './player-bar.js';
|
|
4
4
|
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS, type Sentence } from './speech-plan.js';
|
|
5
|
+
export { CommentLayer } from './comment-layer.js';
|
|
6
|
+
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
|
+
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
8
|
+
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -4,3 +4,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifac
|
|
|
4
4
|
export { BrowserReader } from './browser-reader.js';
|
|
5
5
|
export { PlayerBar } from './player-bar.js';
|
|
6
6
|
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS } from './speech-plan.js';
|
|
7
|
+
export { CommentLayer } from './comment-layer.js';
|
|
8
|
+
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
9
|
+
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
10
|
+
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
|