@supersuit/artifacts 0.25.0 → 0.26.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 +21 -0
- package/lib/reader/artifact-reader.d.ts +3 -0
- package/lib/reader/artifact-reader.js +70 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.26.0
|
|
7
|
+
|
|
8
|
+
**Read-along that leaves the reader in charge.** A tap on any word started the audio, and while it
|
|
9
|
+
played the page scrolled back to the lit word every time it left the screen, pulling a reader who
|
|
10
|
+
had scrolled ahead back to the voice.
|
|
11
|
+
|
|
12
|
+
- A tap on a word while the audio is paused shows a small **Play from here** bar beside the word
|
|
13
|
+
(it moves with the page, closes with × or Escape); pressing it plays from that word. A tap while
|
|
14
|
+
it plays jumps straight there.
|
|
15
|
+
- The page follows the voice until the reader scrolls on their own (wheel, touch, or a scrolling
|
|
16
|
+
key: arrows, Page Up/Down, Home, End, space outside a text field). It then stays where they are,
|
|
17
|
+
and a floating **Back to the voice** button scrolls back to the lit word and resumes following.
|
|
18
|
+
Pressing play, or tapping a word while it plays, also resumes following.
|
|
19
|
+
- New export `isUserScrollKey`.
|
|
20
|
+
- DETECTOR: on a narrated page, pause and tap a word. Audio starting at once means 0.25.0 or
|
|
21
|
+
earlier.
|
|
22
|
+
- REMEDY: update the dependency. No config change.
|
|
23
|
+
- Tests: `artifact-reader-controls.test.tsx` (offer when paused and play from the word; jump when
|
|
24
|
+
playing; scrolling while playing releases the page and the button brings it back; scrolling while
|
|
25
|
+
paused changes nothing; the scroll keys). `test:packed` green.
|
|
26
|
+
|
|
6
27
|
## 0.25.0
|
|
7
28
|
|
|
8
29
|
**A page can play a Supersuit Session demo inline: the ```simulator block.** A book or guide that
|
|
@@ -15,6 +15,9 @@ export declare function seekableWord(target: Element | null): HTMLSpanElement |
|
|
|
15
15
|
/** The word to light at `now`, or -1. Nothing lights until narration has started: the first
|
|
16
16
|
* word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
|
|
17
17
|
export declare function litWordIndex(words: WordTiming[], now: number, started: boolean): number;
|
|
18
|
+
/** Whether a key press is the reader scrolling the page themselves. Space scrolls only outside a
|
|
19
|
+
* field that takes text. */
|
|
20
|
+
export declare function isUserScrollKey(key: string, target: EventTarget | null): boolean;
|
|
18
21
|
export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
|
|
19
22
|
src: string;
|
|
20
23
|
words: WordTiming[];
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// Speechify-style read-along for a published artifact. The narration was generated
|
|
4
4
|
// from the same rendered text this page shows (see lib/artifacts/narration.ts), so
|
|
5
5
|
// the highlighter walks the DOM in reading order, wraps each word, and aligns that
|
|
6
6
|
// sequence to the word timings by normalized token, resyncing within a short window
|
|
7
|
-
// when the two disagree.
|
|
7
|
+
// when the two disagree. Tap a word while it plays to jump there; tap one while it is paused and a
|
|
8
|
+
// small bar offers to play from it, so reading and tapping never starts the audio by surprise. While
|
|
9
|
+
// it plays the page follows the voice, until the reader scrolls on their own: then it leaves them
|
|
10
|
+
// where they are and shows a button that brings them back to the voice.
|
|
8
11
|
import { useEffect, useRef, useState } from 'react';
|
|
9
12
|
import { nextRate, PlayerBar } from './player-bar.js';
|
|
10
13
|
const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img, style, script';
|
|
@@ -130,6 +133,17 @@ function fmt(t) {
|
|
|
130
133
|
const s = Math.floor(t % 60);
|
|
131
134
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
132
135
|
}
|
|
136
|
+
const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End', ' ']);
|
|
137
|
+
/** Whether a key press is the reader scrolling the page themselves. Space scrolls only outside a
|
|
138
|
+
* field that takes text. */
|
|
139
|
+
export function isUserScrollKey(key, target) {
|
|
140
|
+
if (!SCROLL_KEYS.has(key))
|
|
141
|
+
return false;
|
|
142
|
+
const el = target;
|
|
143
|
+
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName ?? '')))
|
|
144
|
+
return false;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
133
147
|
export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
134
148
|
const narrator = label;
|
|
135
149
|
const audioRef = useRef(null);
|
|
@@ -141,6 +155,15 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
141
155
|
const [t, setT] = useState(0);
|
|
142
156
|
const [dur, setDur] = useState(0);
|
|
143
157
|
const [rate, setRate] = useState(1);
|
|
158
|
+
// Whether the page follows the voice. A reader scrolling on their own turns it off; the button
|
|
159
|
+
// that appears, a word tapped while playing, or pressing play turns it back on.
|
|
160
|
+
const [following, setFollowing] = useState(true);
|
|
161
|
+
const followingRef = useRef(true);
|
|
162
|
+
const playingRef = useRef(false);
|
|
163
|
+
const follow = (on) => { followingRef.current = on; setFollowing(on); };
|
|
164
|
+
// The Play from here bar, beside the word a paused reader tapped, in page coordinates so it
|
|
165
|
+
// stays with the word when they scroll.
|
|
166
|
+
const [offer, setOffer] = useState(null);
|
|
144
167
|
useEffect(() => {
|
|
145
168
|
const root = document.getElementById(rootId);
|
|
146
169
|
if (!root)
|
|
@@ -154,12 +177,50 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
154
177
|
const idx = mapRef.current.indexOf(el);
|
|
155
178
|
if (idx === -1 || !audioRef.current)
|
|
156
179
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
180
|
+
if (playingRef.current) {
|
|
181
|
+
audioRef.current.currentTime = words[idx].s;
|
|
182
|
+
follow(true);
|
|
183
|
+
setOffer(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const r = el.getBoundingClientRect();
|
|
187
|
+
setOffer({ idx, top: r.bottom + window.scrollY + 6, left: Math.max(8, r.left + window.scrollX) });
|
|
159
188
|
};
|
|
160
189
|
root.addEventListener('click', onClick);
|
|
161
190
|
return () => root.removeEventListener('click', onClick);
|
|
162
191
|
}, [rootId, words]);
|
|
192
|
+
// The reader scrolling on their own, while it plays, stops the page following the voice.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const mine = () => { if (playingRef.current && followingRef.current)
|
|
195
|
+
follow(false); };
|
|
196
|
+
const onKey = (e) => { if (isUserScrollKey(e.key, e.target))
|
|
197
|
+
mine(); };
|
|
198
|
+
const onEsc = (e) => { if (e.key === 'Escape')
|
|
199
|
+
setOffer(null); };
|
|
200
|
+
window.addEventListener('wheel', mine, { passive: true });
|
|
201
|
+
window.addEventListener('touchmove', mine, { passive: true });
|
|
202
|
+
window.addEventListener('keydown', onKey);
|
|
203
|
+
window.addEventListener('keydown', onEsc);
|
|
204
|
+
return () => {
|
|
205
|
+
window.removeEventListener('wheel', mine);
|
|
206
|
+
window.removeEventListener('touchmove', mine);
|
|
207
|
+
window.removeEventListener('keydown', onKey);
|
|
208
|
+
window.removeEventListener('keydown', onEsc);
|
|
209
|
+
};
|
|
210
|
+
}, []);
|
|
211
|
+
const playFrom = (idx) => {
|
|
212
|
+
const a = audioRef.current;
|
|
213
|
+
if (!a)
|
|
214
|
+
return;
|
|
215
|
+
a.currentTime = words[idx].s;
|
|
216
|
+
follow(true);
|
|
217
|
+
setOffer(null);
|
|
218
|
+
void a.play();
|
|
219
|
+
};
|
|
220
|
+
const backToVoice = () => {
|
|
221
|
+
follow(true);
|
|
222
|
+
litRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
223
|
+
};
|
|
163
224
|
useEffect(() => {
|
|
164
225
|
const a = audioRef.current;
|
|
165
226
|
if (!a)
|
|
@@ -174,7 +235,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
174
235
|
litRef.current?.classList.remove('artifact-word-lit');
|
|
175
236
|
el?.classList.add('artifact-word-lit');
|
|
176
237
|
litRef.current = el;
|
|
177
|
-
if (el && playing) {
|
|
238
|
+
if (el && playing && followingRef.current) {
|
|
178
239
|
const r = el.getBoundingClientRect();
|
|
179
240
|
if (r.top < 80 || r.bottom > window.innerHeight - 140) {
|
|
180
241
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
@@ -197,10 +258,13 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
197
258
|
};
|
|
198
259
|
return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
|
|
199
260
|
startedRef.current = true;
|
|
261
|
+
playingRef.current = true;
|
|
200
262
|
setPlaying(true);
|
|
263
|
+
setOffer(null);
|
|
264
|
+
follow(true);
|
|
201
265
|
}, onSeeking: () => {
|
|
202
266
|
startedRef.current = true;
|
|
203
|
-
}, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
|
|
267
|
+
}, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { "data-nospeak": true, className: "absolute z-50 flex items-center gap-1 rounded-full border px-1 py-1 text-sm shadow-lg", style: { top: offer.top, left: offer.left, background: ground, borderColor: accent }, children: [_jsx("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "rounded-full px-3 py-1 font-medium", style: { background: accent, color: ground }, children: "\u25B6 Play from here" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "px-2 opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
|
|
204
268
|
if (audioRef.current)
|
|
205
269
|
audioRef.current.currentTime = v;
|
|
206
270
|
}, progress: `${fmt(t)} / ${fmt(dur)}`, rate: rate, onRate: () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|