@overpunch/speechtype 1.0.11 → 1.1.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/dist/core.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Emphasise the word containing the span at activeIndex (every span of a word split by markup); dim the
3
+ * others. Pass -1 to reset all words to neutral. The emphasis is a heavier weight, larger optical size and
4
+ * wider tracking around the text's own values (explicit options are absolute).
5
+ *
6
+ * @param el - Element previously prepared by prepareSpeechType
7
+ * @param activeIndex - Index of the word span to emphasise, -1 for none
8
+ * @param options - SpeechTypeOptions (merged with defaults)
9
+ */
10
+ export declare function applySpeechType(el: HTMLElement, activeIndex: number, options?: SpeechTypeOptions): void;
11
+
12
+ /**
13
+ * The element's markup without speechType's word spans. Exact for a prepared element; for any other
14
+ * element, unwraps .st-word spans.
15
+ *
16
+ * @param el - Element to read clean HTML from
17
+ */
18
+ export declare function getCleanHTML(el: HTMLElement): string;
19
+
20
+ /**
21
+ * Wrap each visible word of el in a <span class="st-word">, in place: the element's markup, ids,
22
+ * listeners, form values and line breaks are kept, and hidden text, styles, scripts, form fields and SVG
23
+ * are left alone (and not spoken). The text stays readable to screen readers. Idempotent — calling again
24
+ * re-wraps from the original. Returns the word spans in document order.
25
+ *
26
+ * @param el - Element whose text will be wrapped
27
+ * @param options - SpeechTypeOptions (transitionMs is used)
28
+ */
29
+ export declare function prepareSpeechType(el: HTMLElement, options?: SpeechTypeOptions): HTMLElement[];
30
+
31
+ /**
32
+ * Stop this element's speech (only if it is speaking), put the original text nodes back and delete the
33
+ * saved state. No-op if prepareSpeechType was never called.
34
+ *
35
+ * @param el - The element previously prepared by prepareSpeechType
36
+ */
37
+ export declare function removeSpeechType(el: HTMLElement): void;
38
+
39
+ /**
40
+ * CSS class names injected by speechType.
41
+ * Only `word` ('st-word') is used — there are no st-active or st-inactive classes.
42
+ * Emphasis is applied via inline styles (fontVariationSettings, letterSpacing, opacity),
43
+ * not via class toggles.
44
+ */
45
+ export declare const SPEECH_CLASSES: {
46
+ readonly word: "st-word";
47
+ };
48
+
49
+ /**
50
+ * Options controlling how speechType emphasises spoken words.
51
+ *
52
+ * @remarks
53
+ * Visual options (activeTracking, activeWeight, activeOpsz, inactiveOpacity, transitionMs)
54
+ * are used by prepareSpeechType and applySpeechType.
55
+ * Speech options (rate, pitch, volume) are only used by startSpeechType and are ignored
56
+ * by applySpeechType, useSpeechType, and SpeechTypeText.
57
+ *
58
+ * activeWeight and activeOpsz are written directly into font-variation-settings. Ensure
59
+ * the values are within the wght/opsz axis ranges supported by your font — out-of-range
60
+ * values may cause some engines to silently ignore the entire declaration.
61
+ *
62
+ * inactiveOpacity: ensure the resulting contrast ratio remains at least 4.5:1 against
63
+ * your background colour to meet WCAG AA. The default of 0.45 may fail this threshold
64
+ * depending on your foreground/background pairing.
65
+ */
66
+ export declare interface SpeechTypeOptions {
67
+ /** Letter-spacing on the active (currently spoken) word in em. Default: 0.06 */
68
+ activeTracking?: number;
69
+ /**
70
+ * wght axis value on the active word. Default: 700.
71
+ * Must be within the font's supported wght axis range (e.g. 100–900 for most variable fonts).
72
+ */
73
+ activeWeight?: number;
74
+ /**
75
+ * opsz axis value on the active word. Default: 24.
76
+ * Must be within the font's supported opsz axis range (e.g. 6–72 for many variable fonts).
77
+ */
78
+ activeOpsz?: number;
79
+ /**
80
+ * Opacity of inactive (not currently spoken) words. Default: 0.45.
81
+ * Values below ~0.5 may reduce contrast below WCAG AA (4.5:1) depending on your colours.
82
+ * Minimum recommended value: 0.3.
83
+ */
84
+ inactiveOpacity?: number;
85
+ /** CSS transition duration in ms for style changes. Default: 80 */
86
+ transitionMs?: number;
87
+ /** Speech rate (0.1–10). Used only by startSpeechType. Default: 0.9 */
88
+ rate?: number;
89
+ /** Speech pitch (0–2). Used only by startSpeechType. Default: 1 */
90
+ pitch?: number;
91
+ /** Speech volume (0–1). Used only by startSpeechType. Default: 1 */
92
+ volume?: number;
93
+ /**
94
+ * Called when speech synthesis is unavailable in the current browser.
95
+ * Used only by startSpeechType.
96
+ */
97
+ /**
98
+ * Language of the speech (BCP 47, e.g. 'ja'). Default: the element's own `lang` (nearest ancestor),
99
+ * else the document's. A voice for that language is chosen when the browser has one.
100
+ */
101
+ lang?: string;
102
+ /** Voice to use: a SpeechSynthesisVoice, or a voice name / voiceURI. Default: the language's voice. */
103
+ voice?: SpeechSynthesisVoice | string;
104
+ /** Called when a run ends: speech finished, stopped, cancelled by a newer run, or failed (after onError). */
105
+ onEnd?: () => void;
106
+ onUnsupported?: () => void;
107
+ /**
108
+ * Called when a real speech error occurs (any error code other than "interrupted",
109
+ * which is a normal cancellation). Receives the SpeechSynthesisErrorEvent.
110
+ * Used only by startSpeechType.
111
+ */
112
+ onError?: (event: SpeechSynthesisErrorEvent) => void;
113
+ }
114
+
115
+ /**
116
+ * Speak el's text, emphasising each word as the Web Speech API reaches it. Calls prepareSpeechType
117
+ * first. Earlier speech started by speechType is stopped (other speech on the page is left alone unless
118
+ * this run has to take over the speech engine). Returns a stop() function that stops this run and resets
119
+ * the emphasis; the words stay wrapped until removeSpeechType.
120
+ *
121
+ * @param el - Element to speak and highlight
122
+ * @param options - SpeechTypeOptions (merged with defaults)
123
+ */
124
+ export declare function startSpeechType(el: HTMLElement, options?: SpeechTypeOptions): () => void;
125
+
126
+ export { }
package/dist/core.js ADDED
@@ -0,0 +1,196 @@
1
+ const x = {
2
+ word: "st-word"
3
+ }, b = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "TEXTAREA", "NOSCRIPT", "TEMPLATE", "SVG", "MATH", "SELECT", "OPTION", "CANVAS", "IFRAME", "OBJECT", "VIDEO", "AUDIO", "INPUT", "BUTTON"]), R = /* @__PURE__ */ new Set(["BR", "HR", "P", "DIV", "LI", "UL", "OL", "DL", "DT", "DD", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", "SECTION", "ARTICLE", "ASIDE", "HEADER", "FOOTER", "NAV", "FIGURE", "FIGCAPTION", "TABLE", "TR", "TD", "TH", "CAPTION", "IMG", "ADDRESS", "MAIN", "DETAILS", "SUMMARY"]), k = /[฀-໿က-႟ក-៿぀-ヿ㐀-䶿一-鿿豈-﫿]/, U = 80, F = 1e4, V = 1e3, D = typeof Intl < "u" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null, H = /* @__PURE__ */ new Set();
4
+ function P(t) {
5
+ H.has(t) || (H.add(t), console.warn(t));
6
+ }
7
+ function y(t, e, r, n, s) {
8
+ return t === void 0 ? e : typeof t == "number" && Number.isFinite(t) ? Math.min(n, Math.max(r, t)) : (P(`[speechType] ${s} must be a finite number; got ${String(t)}, using ${e}`), e);
9
+ }
10
+ function _() {
11
+ var t, e;
12
+ return typeof window < "u" && !!((e = (t = window.matchMedia) == null ? void 0 : t.call(window, "(prefers-reduced-motion: reduce)")) != null && e.matches);
13
+ }
14
+ const A = /* @__PURE__ */ new WeakMap();
15
+ function z(t) {
16
+ if (t.getAttribute("aria-hidden") === "true" || t.hasAttribute("hidden")) return !0;
17
+ if (!t.isConnected) return !1;
18
+ const e = getComputedStyle(t);
19
+ return e.display === "none" || e.visibility === "hidden";
20
+ }
21
+ function $(t) {
22
+ for (const e of t.wrapped) {
23
+ const r = e.produced.find((n) => n.parentNode);
24
+ r != null && r.parentNode && r.parentNode.insertBefore(e.original, r), e.produced.forEach((n) => {
25
+ var s;
26
+ return (s = n.parentNode) == null ? void 0 : s.removeChild(n);
27
+ });
28
+ }
29
+ }
30
+ function B(t, e = {}) {
31
+ var T;
32
+ if (typeof window > "u" || !t) return [];
33
+ const r = A.get(t);
34
+ r && ((T = r.run) == null || T.stop(), $(r), A.delete(t));
35
+ const n = t.innerHTML, s = [], o = [], u = [], i = [];
36
+ let a = "", p = -1, g = !0;
37
+ const h = (c) => {
38
+ Array.from(c.childNodes).forEach((f) => {
39
+ if (f.nodeType === Node.TEXT_NODE) {
40
+ I(f);
41
+ return;
42
+ }
43
+ if (f.nodeType !== Node.ELEMENT_NODE) return;
44
+ const d = f, l = d.nodeName.toUpperCase();
45
+ R.has(l) && (g = !0), !(b.has(l) || d.isContentEditable || z(d)) && (h(d), R.has(l) && (g = !0));
46
+ });
47
+ }, I = (c) => {
48
+ const f = c.data;
49
+ if (!f || !c.parentNode) return;
50
+ if (!/\S/.test(f)) {
51
+ g = !0;
52
+ return;
53
+ }
54
+ const d = [];
55
+ for (const w of f.split(/(\s+)/)) {
56
+ if (!w) continue;
57
+ if (/^\s+$/.test(w)) {
58
+ d.push(document.createTextNode(w)), g = !0;
59
+ continue;
60
+ }
61
+ (D && k.test(w) ? Array.from(D.segment(w), (C) => C.segment) : [w]).forEach((C, m) => {
62
+ (g || m > 0) && (a && (a += " "), p++, i[p] = a.length), g = !1;
63
+ const E = document.createElement("span");
64
+ E.className = x.word, E.textContent = C, d.push(E), o.push(E), u.push(p), a += C;
65
+ });
66
+ }
67
+ const l = document.createDocumentFragment();
68
+ d.forEach((w) => l.appendChild(w)), c.parentNode.replaceChild(l, c), s.push({ original: c, produced: d });
69
+ };
70
+ h(t);
71
+ const S = y(e.transitionMs, 80, 0, 1e4, "transitionMs");
72
+ return S > 0 && !_() && o.forEach((c) => {
73
+ c.style.transition = `font-variation-settings ${S}ms ease, letter-spacing ${S}ms ease, opacity ${S}ms ease`;
74
+ }), A.set(t, { originalHTML: n, wrapped: s, wordSpans: o, wordOf: u, text: a, wordStart: i, run: null, activeIndex: -1 }), o;
75
+ }
76
+ function G(t) {
77
+ if (!t || t === "normal") return [];
78
+ const e = [];
79
+ for (const r of t.matchAll(/["']([^"']{4})["']\s+(-?[\d.]+(?:e[+-]?\d+)?)/gi)) e.push([r[1], parseFloat(r[2])]);
80
+ return e;
81
+ }
82
+ function W(t, e) {
83
+ var T;
84
+ const r = t.parentElement ?? t, n = getComputedStyle(r), s = G(((T = n.getPropertyValue) == null ? void 0 : T.call(n, "font-variation-settings")) || n.fontVariationSettings || ""), o = (c) => {
85
+ var f;
86
+ return (f = s.find(([d]) => d === c)) == null ? void 0 : f[1];
87
+ }, u = parseFloat(n.fontSize) || 16, i = o("wght") ?? (parseFloat(n.fontWeight) || 400), a = o("opsz") ?? u, p = y(e.activeWeight, Math.min(1e3, i + 300), 1, 1e3, "activeWeight"), g = y(e.activeOpsz, a * 1.5, 1, 1e3, "activeOpsz"), h = y(e.activeTracking, 0.06, -1, 1, "activeTracking"), I = s.filter(([c]) => c !== "wght" && c !== "opsz").map(([c, f]) => `"${c}" ${f}`), S = parseFloat(n.letterSpacing) || 0;
88
+ return {
89
+ fvs: [...I, `"wght" ${+p.toFixed(1)}`, `"opsz" ${+g.toFixed(1)}`].join(", "),
90
+ // Added to the text's own letter-spacing.
91
+ ls: S ? `calc(${S}px + ${h}em)` : `${h}em`
92
+ };
93
+ }
94
+ function v(t, e, r = {}) {
95
+ if (typeof window > "u") return;
96
+ const n = A.get(t);
97
+ if (!n) return;
98
+ const s = Number.isInteger(e) && e >= 0 && e < n.wordSpans.length ? e : -1, o = y(r.inactiveOpacity, 0.45, 0, 1, "inactiveOpacity"), u = s >= 0 ? n.wordOf[s] : -1;
99
+ n.activeIndex = s, n.wordSpans.forEach((i, a) => {
100
+ if (u >= 0 && n.wordOf[a] === u) {
101
+ const p = W(i, r);
102
+ i.setAttribute("aria-current", "true"), i.style.fontVariationSettings = p.fvs, i.style.letterSpacing = p.ls, i.style.opacity = "1";
103
+ } else
104
+ i.removeAttribute("aria-current"), i.style.fontVariationSettings = "", i.style.letterSpacing = "", i.style.opacity = u === -1 ? "1" : String(o);
105
+ });
106
+ }
107
+ let N = null;
108
+ function K(t, e) {
109
+ var s, o;
110
+ const r = ((o = (s = window.speechSynthesis).getVoices) == null ? void 0 : o.call(s)) ?? [];
111
+ if (e && typeof e == "object") return e;
112
+ if (typeof e == "string") {
113
+ const u = r.find((i) => i.name === e || i.voiceURI === e);
114
+ if (u) return u;
115
+ P(`[speechType] no voice named ${JSON.stringify(e)}; using the language's default`);
116
+ }
117
+ if (!t) return null;
118
+ const n = t.toLowerCase();
119
+ return r.find((u) => u.lang.toLowerCase() === n) ?? r.find((u) => u.lang.toLowerCase().split("-")[0] === n.split("-")[0]) ?? null;
120
+ }
121
+ function Y(t, e = {}) {
122
+ var M, C;
123
+ if (typeof window > "u" || !t || !("speechSynthesis" in window) || typeof SpeechSynthesisUtterance > "u")
124
+ return (M = e.onUnsupported) == null || M.call(e), () => {
125
+ };
126
+ const r = y(e.rate, 0.9, 0.1, 10, "rate"), n = y(e.pitch, 1, 0, 2, "pitch"), s = y(e.volume, 1, 0, 1, "volume"), o = window.speechSynthesis, u = o.speaking || o.pending;
127
+ B(t, e);
128
+ const i = A.get(t);
129
+ if (!i || !i.text) return () => {
130
+ };
131
+ const a = new SpeechSynthesisUtterance(i.text);
132
+ a.rate = r, a.pitch = n, a.volume = s;
133
+ const p = e.lang ?? ((C = t.closest("[lang]")) == null ? void 0 : C.lang) ?? document.documentElement.lang ?? "";
134
+ p && (a.lang = p);
135
+ const g = K(p, e.voice);
136
+ g && (a.voice = g);
137
+ let h = !1, I = !1, S = null, T = null, c = null, f = !1;
138
+ const d = () => {
139
+ var m;
140
+ h || (h = !0, S && clearInterval(S), T && clearInterval(T), c && clearTimeout(c), N === l && (N = null), i.run === l && (i.run = null), v(t, -1, e), (m = e.onEnd) == null || m.call(e));
141
+ };
142
+ a.onstart = () => {
143
+ f = !0;
144
+ }, a.onboundary = (m) => {
145
+ if (h || m.name !== "word") return;
146
+ f = !0;
147
+ let E = -1;
148
+ for (let O = 0; O < i.wordStart.length && i.wordStart[O] <= m.charIndex; O++)
149
+ E = O;
150
+ const L = i.wordOf.indexOf(E);
151
+ L !== -1 && v(t, L, e);
152
+ }, a.onend = () => d(), a.onerror = (m) => {
153
+ var E;
154
+ m.error !== "interrupted" && !(I && m.error === "canceled") && ((E = e.onError) == null || E.call(e, m)), d();
155
+ };
156
+ const l = {
157
+ utterance: a,
158
+ stop: () => {
159
+ h || (N === l && (I = !0, o.cancel()), d());
160
+ }
161
+ };
162
+ N ? N.stop() : u && o.cancel(), N = l, i.run = l;
163
+ const w = () => {
164
+ c = null, !h && (o.speak(a), /Chrome\//.test(navigator.userAgent) && (S = setInterval(() => {
165
+ o.speaking && !o.paused && (o.pause(), o.resume());
166
+ }, F)), T = setInterval(() => {
167
+ f && !o.speaking && !o.pending && d();
168
+ }, V));
169
+ };
170
+ return u ? c = setTimeout(w, U) : w(), l.stop;
171
+ }
172
+ function j(t) {
173
+ var r;
174
+ const e = A.get(t);
175
+ e && ((r = e.run) == null || r.stop(), v(t, -1), $(e), A.delete(t));
176
+ }
177
+ function q(t) {
178
+ const e = A.get(t);
179
+ if (e) return e.originalHTML;
180
+ const r = t.cloneNode(!0);
181
+ return r.querySelectorAll(`.${x.word}`).forEach((n) => {
182
+ const s = n.parentNode;
183
+ if (s) {
184
+ for (; n.firstChild; ) s.insertBefore(n.firstChild, n);
185
+ s.removeChild(n);
186
+ }
187
+ }), r.querySelectorAll("[data-st-live]").forEach((n) => n.remove()), r.normalize(), r.innerHTML;
188
+ }
189
+ export {
190
+ x as SPEECH_CLASSES,
191
+ v as applySpeechType,
192
+ q as getCleanHTML,
193
+ B as prepareSpeechType,
194
+ j as removeSpeechType,
195
+ Y as startSpeechType
196
+ };
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=require("react"),b=require("react/jsx-runtime"),v={word:"st-word"},w=new WeakMap;function C(t,e={}){if(typeof window>"u")return[];const r=window.scrollY,n=w.get(t),l=(n==null?void 0:n.originalHTML)??t.innerHTML;t.innerHTML=l;const d=(t.textContent??"").split(/(\s+)/);t.innerHTML=d.map(i=>!i||/^\s+$/.test(i)?i:`<span class="${v.word}" aria-hidden="true">${i}</span>`).join("");const u=Array.from(t.querySelectorAll(`.${v.word}`)),o=e.transitionMs??80;u.forEach(i=>{i.style.display="inline",i.style.transition=[`font-variation-settings ${o}ms ease`,`letter-spacing ${o}ms ease`,`opacity ${o}ms ease`].join(", ")});let c=t.querySelector("[data-st-live]");return c||(c=document.createElement("span"),c.setAttribute("data-st-live",""),c.setAttribute("aria-live","polite"),c.setAttribute("aria-atomic","true"),c.style.cssText="position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0",t.appendChild(c)),w.set(t,{originalHTML:l,wordSpans:u,utterance:null,activeIndex:-1}),requestAnimationFrame(()=>{Math.abs(window.scrollY-r)>2&&window.scrollTo({top:r,behavior:"instant"})}),u}function y(t,e,r={}){var c;if(typeof window>"u")return;const n=w.get(t);if(!n)return;const l=r.activeTracking??.06,s=r.activeWeight??700,d=r.activeOpsz??24,u=r.inactiveOpacity??.45;n.activeIndex=e;const o=t.querySelector("[data-st-live]");o&&(o.textContent=e>=0?((c=n.wordSpans[e])==null?void 0:c.textContent)??"":""),n.wordSpans.forEach((i,S)=>{S===e?(i.setAttribute("aria-current","true"),i.style.fontVariationSettings=`"wght" ${s}, "opsz" ${d}`,i.style.letterSpacing=`${l}em`,i.style.opacity="1"):(i.removeAttribute("aria-current"),i.style.fontVariationSettings="",i.style.letterSpacing="",i.style.opacity=e===-1?"1":String(u))})}function L(t,e={}){var m;if(typeof window>"u"||!("speechSynthesis"in window))return(m=e.onUnsupported)==null||m.call(e),()=>{};const r=C(t,e),n=w.get(t);if(!n)return()=>{};window.speechSynthesis.cancel();const l=r.map(a=>a.textContent??"").join(" "),s=new SpeechSynthesisUtterance(l);s.rate=e.rate??.9,s.pitch=e.pitch??1,s.volume=e.volume??1;const d=e.activeTracking??.06,u=e.activeWeight??700,o=e.activeOpsz??24,c={...e,activeTracking:d,activeWeight:u,activeOpsz:o};let i=0;const S=r.map(a=>{var h;const p=i;return i+=(((h=a.textContent)==null?void 0:h.length)??0)+1,p});let f=!1;return s.onboundary=a=>{if(f||a.name!=="word")return;const p=S.findIndex((h,x)=>{const T=S[x+1]??1/0;return a.charIndex>=h&&a.charIndex<T});p!==-1&&y(t,p,c)},s.onend=()=>{f||(y(t,-1,c),n.utterance=null)},s.onerror=a=>{var p;a.error!=="interrupted"&&((p=e.onError)==null||p.call(e,a)),f||(y(t,-1,c),n.utterance=null)},n.utterance=s,window.speechSynthesis.speak(s),()=>{f||(f=!0,window.speechSynthesis.cancel(),y(t,-1,c),n.utterance=null)}}function M(t){const e=w.get(t);e&&(typeof window<"u"&&"speechSynthesis"in window&&window.speechSynthesis.cancel(),t.innerHTML=e.originalHTML,w.delete(t))}function A(t){const e=t.cloneNode(!0);return e.querySelectorAll(`.${v.word}`).forEach(r=>{const n=r.parentNode;if(n){for(;r.firstChild;)n.insertBefore(r.firstChild,r);n.removeChild(r)}}),e.innerHTML}function E(t,e,r){g.useEffect(()=>{const n=t.current;if(n)return C(n,r),()=>M(n)},[r==null?void 0:r.transitionMs]),g.useEffect(()=>{const n=t.current;n&&y(n,e,r)},[e])}const $=g.forwardRef(function({activeWordIndex:e,as:r="p",children:n,style:l,className:s,activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i,rate:S,pitch:f,volume:m,onUnsupported:a,onError:p,...h},x){const T=g.useRef(null);g.useImperativeHandle(x,()=>T.current),E(T,e,{activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i});const H=r;return b.jsx(H,{ref:T,style:l,className:s,...h,children:n})});exports.SPEECH_CLASSES=v;exports.SpeechTypeText=$;exports.applySpeechType=y;exports.getCleanHTML=A;exports.prepareSpeechType=C;exports.removeSpeechType=M;exports.startSpeechType=L;exports.useSpeechType=E;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./core.cjs"),y=require("react"),H=require("react/jsx-runtime");function T(s,u,e){const c=y.useRef(null),r=y.useRef(e);r.current=e;const f=JSON.stringify([e==null?void 0:e.activeTracking,e==null?void 0:e.activeWeight,e==null?void 0:e.activeOpsz,e==null?void 0:e.inactiveOpacity]);y.useEffect(()=>{const t=s.current,p=c.current;if(!p||p.el!==t||p.transitionMs!==(e==null?void 0:e.transitionMs)){if(p&&p.el!==t&&n.removeSpeechType(p.el),c.current=null,!t)return;n.prepareSpeechType(t,r.current),c.current={el:t,transitionMs:e==null?void 0:e.transitionMs},n.applySpeechType(t,u,r.current)}}),y.useEffect(()=>{const t=s.current;t&&n.applySpeechType(t,u,r.current)},[u,f]),y.useEffect(()=>()=>{c.current&&n.removeSpeechType(c.current.el),c.current=null},[])}function O(s){const u=[],e=c=>{y.Children.forEach(c,r=>{if(!(r==null||typeof r=="boolean")){if(typeof r=="string"||typeof r=="number"){u.push(String(r));return}if(y.isValidElement(r)){const f=typeof r.type=="string"?r.type:r.type.displayName??r.type.name??"C",t=r.props,p=Object.keys(t).filter(a=>a!=="children"&&["string","number","boolean"].includes(typeof t[a])).sort().map(a=>`${a}=${String(t[a])}`);u.push(`<${f}${r.key!=null?"#"+r.key:""} ${p.join(" ")}>`),e(t.children),u.push(`</${f}>`)}}})};return e(s),u.join("\0")}const L=y.forwardRef(function({activeWordIndex:u,as:e="p",children:c,style:r,className:f,activeTracking:t,activeWeight:p,activeOpsz:a,inactiveOpacity:i,transitionMs:h,rate:m,pitch:E,volume:g,onUnsupported:v,onError:C,onEnd:$,lang:l,voice:b,...M},R){const S=y.useRef(null);y.useImperativeHandle(R,()=>S.current),T(S,u,{activeTracking:t,activeWeight:p,activeOpsz:a,inactiveOpacity:i,transitionMs:h,rate:m,pitch:E,volume:g,onUnsupported:v,onError:C,onEnd:$,lang:l,voice:b});const j=e;return H.jsx(j,{ref:S,style:r,className:f,lang:l,...M,children:c},`${typeof e=="string"?e:"C"}|${O(c)}`)});exports.SPEECH_CLASSES=n.SPEECH_CLASSES;exports.applySpeechType=n.applySpeechType;exports.getCleanHTML=n.getCleanHTML;exports.prepareSpeechType=n.prepareSpeechType;exports.removeSpeechType=n.removeSpeechType;exports.startSpeechType=n.startSpeechType;exports.SpeechTypeText=L;exports.useSpeechType=T;
package/dist/index.d.ts CHANGED
@@ -1,41 +1,42 @@
1
+ import { default as default_2 } from 'react';
1
2
  import { ElementType } from 'react';
2
- import { ForwardRefExoticComponent } from 'react';
3
- import { RefAttributes } from 'react';
4
3
  import { RefObject } from 'react';
5
4
 
6
5
  /**
7
- * Apply typographic emphasis to the word at activeIndex.
8
- * All other words receive the inactive style. Pass -1 to reset all words to neutral.
6
+ * Emphasise the word containing the span at activeIndex (every span of a word split by markup); dim the
7
+ * others. Pass -1 to reset all words to neutral. The emphasis is a heavier weight, larger optical size and
8
+ * wider tracking around the text's own values (explicit options are absolute).
9
9
  *
10
10
  * @param el - Element previously prepared by prepareSpeechType
11
- * @param activeIndex - Index of the word to emphasise, -1 for none
11
+ * @param activeIndex - Index of the word span to emphasise, -1 for none
12
12
  * @param options - SpeechTypeOptions (merged with defaults)
13
13
  */
14
14
  export declare function applySpeechType(el: HTMLElement, activeIndex: number, options?: SpeechTypeOptions): void;
15
15
 
16
16
  /**
17
- * Return a clean copy of el's innerHTML with all speechType spans unwrapped.
18
- * Does not modify el itself.
17
+ * The element's markup without speechType's word spans. Exact for a prepared element; for any other
18
+ * element, unwraps .st-word spans.
19
19
  *
20
20
  * @param el - Element to read clean HTML from
21
21
  */
22
22
  export declare function getCleanHTML(el: HTMLElement): string;
23
23
 
24
- declare type HTMLForwardProps = Omit<React.HTMLAttributes<HTMLElement>, keyof SpeechTypeOptions | 'children' | 'style' | 'className'>;
24
+ declare type HTMLForwardProps = Omit<default_2.HTMLAttributes<HTMLElement>, keyof SpeechTypeOptions | 'children' | 'style' | 'className'>;
25
25
 
26
26
  /**
27
- * Wrap the text content of el in per-word <span> elements with the SPEECH_CLASSES.word
28
- * class. Saves the original innerHTML for cleanup. Idempotent — calling again re-wraps
29
- * from the saved original. Returns the array of word span elements.
27
+ * Wrap each visible word of el in a <span class="st-word">, in place: the element's markup, ids,
28
+ * listeners, form values and line breaks are kept, and hidden text, styles, scripts, form fields and SVG
29
+ * are left alone (and not spoken). The text stays readable to screen readers. Idempotent — calling again
30
+ * re-wraps from the original. Returns the word spans in document order.
30
31
  *
31
32
  * @param el - Element whose text will be wrapped
32
- * @param options - SpeechTypeOptions (merged with defaults)
33
+ * @param options - SpeechTypeOptions (transitionMs is used)
33
34
  */
34
35
  export declare function prepareSpeechType(el: HTMLElement, options?: SpeechTypeOptions): HTMLElement[];
35
36
 
36
37
  /**
37
- * Remove speechType from el — cancel any active speech, restore original innerHTML,
38
- * and delete all saved state. No-op if prepareSpeechType was never called.
38
+ * Stop this element's speech (only if it is speaking), put the original text nodes back and delete the
39
+ * saved state. No-op if prepareSpeechType was never called.
39
40
  *
40
41
  * @param el - The element previously prepared by prepareSpeechType
41
42
  */
@@ -99,6 +100,15 @@ export declare interface SpeechTypeOptions {
99
100
  * Called when speech synthesis is unavailable in the current browser.
100
101
  * Used only by startSpeechType.
101
102
  */
103
+ /**
104
+ * Language of the speech (BCP 47, e.g. 'ja'). Default: the element's own `lang` (nearest ancestor),
105
+ * else the document's. A voice for that language is chosen when the browser has one.
106
+ */
107
+ lang?: string;
108
+ /** Voice to use: a SpeechSynthesisVoice, or a voice name / voiceURI. Default: the language's voice. */
109
+ voice?: SpeechSynthesisVoice | string;
110
+ /** Called when a run ends: speech finished, stopped, cancelled by a newer run, or failed (after onError). */
111
+ onEnd?: () => void;
102
112
  onUnsupported?: () => void;
103
113
  /**
104
114
  * Called when a real speech error occurs (any error code other than "interrupted",
@@ -114,7 +124,7 @@ export declare interface SpeechTypeOptions {
114
124
  * Forwards a ref to the underlying DOM element for imperative startSpeechType access.
115
125
  * All aria-*, data-*, role, lang, and other HTML attributes are forwarded to the DOM element.
116
126
  */
117
- export declare const SpeechTypeText: ForwardRefExoticComponent<SpeechTypeTextProps & RefAttributes<HTMLElement>>;
127
+ export declare const SpeechTypeText: default_2.ForwardRefExoticComponent<SpeechTypeTextProps & default_2.RefAttributes<HTMLElement>>;
118
128
 
119
129
  /** Props accepted by SpeechTypeText */
120
130
  declare interface SpeechTypeTextProps extends SpeechTypeOptions, HTMLForwardProps {
@@ -123,17 +133,18 @@ declare interface SpeechTypeTextProps extends SpeechTypeOptions, HTMLForwardProp
123
133
  /** HTML element tag to render. Default: 'p' */
124
134
  as?: ElementType;
125
135
  /** React children (text content to highlight) */
126
- children: React.ReactNode;
136
+ children: default_2.ReactNode;
127
137
  /** Inline styles forwarded to the rendered element */
128
- style?: React.CSSProperties;
138
+ style?: default_2.CSSProperties;
129
139
  /** Class name forwarded to the rendered element */
130
140
  className?: string;
131
141
  }
132
142
 
133
143
  /**
134
- * Start speech synthesis on el's text content, syncing word emphasis to Web Speech API
135
- * boundary events. Calls prepareSpeechType first. Cancels any existing speech.
136
- * Returns a stop() function that cancels speech and resets all styles.
144
+ * Speak el's text, emphasising each word as the Web Speech API reaches it. Calls prepareSpeechType
145
+ * first. Earlier speech started by speechType is stopped (other speech on the page is left alone unless
146
+ * this run has to take over the speech engine). Returns a stop() function that stops this run and resets
147
+ * the emphasis; the words stay wrapped until removeSpeechType.
137
148
  *
138
149
  * @param el - Element to speak and highlight
139
150
  * @param options - SpeechTypeOptions (merged with defaults)
@@ -141,11 +152,11 @@ declare interface SpeechTypeTextProps extends SpeechTypeOptions, HTMLForwardProp
141
152
  export declare function startSpeechType(el: HTMLElement, options?: SpeechTypeOptions): () => void;
142
153
 
143
154
  /**
144
- * Prepare word spans on mount and apply emphasis when activeWordIndex changes.
145
- * Cleans up by restoring original innerHTML on unmount.
155
+ * Prepare word spans on mount (and again when the element or transitionMs changes) and apply emphasis for
156
+ * activeWordIndex. Restores the element on unmount; speech is only cancelled if this element is speaking.
146
157
  *
147
158
  * @param ref - Ref to the element containing text to highlight
148
- * @param activeWordIndex - Index of the currently active word (-1 = none)
159
+ * @param activeWordIndex - Index of the currently active word span (-1 = none)
149
160
  * @param options - SpeechTypeOptions (merged with defaults)
150
161
  */
151
162
  export declare function useSpeechType(ref: RefObject<HTMLElement | null>, activeWordIndex: number, options?: SpeechTypeOptions): void;