@octolearn/training-ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -5
- package/dist/chunks/{core-DR1lxtEg.js → core-rJUDTpGS.js} +49 -19
- package/dist/chunks/{define-elements-BKBBQkbv.js → define-elements-CeSpZJXj.js} +661 -65
- package/dist/core.js +1 -1
- package/dist/define.js +13 -10
- package/dist/index.js +12 -9
- package/dist/octolearn-training-ui.bundle.js +1592 -1044
- package/dist/types/base-element.d.ts +7 -0
- package/dist/types/d-icons.d.ts +1 -1
- package/dist/types/elements/card.d.ts +2 -1
- package/dist/types/elements/dictation-question.d.ts +11 -2
- package/dist/types/elements/picture-question.d.ts +14 -2
- package/dist/types/elements/question-answer.d.ts +0 -2
- package/dist/types/elements/question-parts.d.ts +11 -3
- package/dist/types/elements/text-answer.d.ts +7 -0
- package/dist/types/i18n/strings.d.ts +5 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speech.d.ts +19 -2
- package/dist/types/styles/d-parts.d.ts +2 -1
- package/dist/types/viewport.d.ts +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,9 +255,9 @@ shown as text, never as HTML.
|
|
|
255
255
|
| --------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
256
256
|
| `<octolearn-question>` | any question: picks the element by answer format, then learning mode | `content`, `settings`, `no-autospeak`, `no-autofocus`, `no-keys`, `revealFlashcard` |
|
|
257
257
|
| `<octolearn-question-answer>` | Vokabel / question, also Stimmt das? and Karteikarte | `content`, `no-autofocus`, `no-keys`, `revealFlashcard` |
|
|
258
|
-
| `<octolearn-dictation-question>` | Diktat: listen, "Langsam anhören", optional "Einstellungen" | `content`, `settings`, `no-autospeak`, `no-keys`
|
|
259
|
-
| `<octolearn-arithmetic-question>` | Rechnen ("7 · 8 =") | `content`, `no-keys`
|
|
260
|
-
| `<octolearn-picture-question>` | Bilderrätsel | `content`, `no-keys`
|
|
258
|
+
| `<octolearn-dictation-question>` | Diktat: listen, "Langsam anhören", optional "Einstellungen" | `content`, `settings`, `no-autospeak`, `no-autofocus`, `no-keys` |
|
|
259
|
+
| `<octolearn-arithmetic-question>` | Rechnen ("7 · 8 =") | `content`, `no-autofocus`, `no-keys` |
|
|
260
|
+
| `<octolearn-picture-question>` | Bilderrätsel | `content`, `no-autofocus`, `no-keys` |
|
|
261
261
|
| `<octolearn-multiple-choice>` | answer tiles, keys 1–9 | `options`, `label`, `no-keys` |
|
|
262
262
|
| `<octolearn-text-answer>` | the big field, "Weiß ich nicht", Wortfragment hint | `label`, `visible-label`, `type`, `input-lang`, `hint`, `no-dont-know`, `value` |
|
|
263
263
|
| `<octolearn-tile-answer>` | Buchstabensalat / Satzpuzzle | `tiles`, `separator` (`""` letters, `" "` words) |
|
|
@@ -302,8 +302,13 @@ The texts are German (`de`) or English (`en`), following the `lang` attribute of
|
|
|
302
302
|
exported as `de` and `en`).
|
|
303
303
|
|
|
304
304
|
Dictations are read aloud with the browser's speech synthesis; the voice and speed the learner chose are remembered
|
|
305
|
-
(`getSpeechController().choose(voice, rate)`).
|
|
306
|
-
|
|
305
|
+
(`getSpeechController().choose(voice, rate)`). A word is only read by a voice of its language (`detail.lang`: the
|
|
306
|
+
content's `sourceLanguage`, else `de`): the learner's voice if it speaks that language, else Google Deutsch or the first
|
|
307
|
+
voice of it. Without one the browser gets the language alone, and the dictation shows a note that the device has no
|
|
308
|
+
voice for it (`getSpeechController().hasVoiceFor(lang)`). If the browser blocks speech before the first tap, the
|
|
309
|
+
question shows an "Abspielen" button. To use your own audio, cancel `octolearn-speak` and play `detail.text` in
|
|
310
|
+
`detail.lang` yourself, or pass both to `getSpeechController().speak(text, { lang, rate })`, which waits for the
|
|
311
|
+
browser's voice list (Chrome's first `getVoices()` is often empty).
|
|
307
312
|
|
|
308
313
|
## Theming
|
|
309
314
|
|
|
@@ -373,6 +378,17 @@ without dragging, the progress stars say "Frage 3 von 5, richtig", and the hints
|
|
|
373
378
|
on touch screens. Right answers move on after 3 s; the timer pauses while the pointer rests on the card, and `no-auto` turns it
|
|
374
379
|
off. Reduced motion is respected.
|
|
375
380
|
|
|
381
|
+
## Phones and the on-screen keyboard
|
|
382
|
+
|
|
383
|
+
When a phone's keyboard opens it covers about half of the screen. The elements notice this through
|
|
384
|
+
`window.visualViewport` (CSS can't: the layout viewport keeps its size) and set the reflected attribute `short` on
|
|
385
|
+
themselves while less than 520 px (`SHORT_VIEWPORT_PX`) are visible. In that state the card hides its header and Inkie
|
|
386
|
+
(the label and stars stay for screen readers), texts, pictures and buttons get smaller, the answer field carries a
|
|
387
|
+
round send button instead of "Fertig", and "Weiß ich nicht" becomes a small text button. A question whose field has the
|
|
388
|
+
focus then scrolls its card to the top of the visible part, so the word or picture stays above the field. When the
|
|
389
|
+
keyboard closes, everything is back as before. The page can follow the same state, e.g. with
|
|
390
|
+
`.layout:has(octolearn-trainer[short])` or `getViewportWatcher().subscribe(short => …)`.
|
|
391
|
+
|
|
376
392
|
## Browser support, CSP and size
|
|
377
393
|
|
|
378
394
|
Current Chrome, Edge, Firefox and Safari (custom elements, shadow DOM, container queries: Chrome 105+, Firefox 110+,
|
|
@@ -2,6 +2,7 @@ const VOICE_STORAGE_KEY = "octolearn.training.voice";
|
|
|
2
2
|
const RATE_STORAGE_KEY = "octolearn.training.rate";
|
|
3
3
|
const MIN_RATE = 0.5;
|
|
4
4
|
const MAX_RATE = 1.5;
|
|
5
|
+
const VOICE_WAIT_MS = 1e3;
|
|
5
6
|
class SpeechController {
|
|
6
7
|
constructor(options = {}) {
|
|
7
8
|
this.voices = [];
|
|
@@ -10,8 +11,8 @@ class SpeechController {
|
|
|
10
11
|
this.requestId = 0;
|
|
11
12
|
this.connections = 0;
|
|
12
13
|
this.handleVoicesChanged = () => {
|
|
13
|
-
if (this.
|
|
14
|
-
this.
|
|
14
|
+
if (this.pending) {
|
|
15
|
+
this.flushWhenVoicesKnown();
|
|
15
16
|
}
|
|
16
17
|
};
|
|
17
18
|
this.preferredVoices = options.preferredVoices ?? ["Google Deutsch"];
|
|
@@ -48,20 +49,27 @@ class SpeechController {
|
|
|
48
49
|
if ("userActivation" in navigator && !navigator.userActivation.hasBeenActive) {
|
|
49
50
|
return "blocked";
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
-
this.flushPending();
|
|
53
|
-
}
|
|
52
|
+
this.flushWhenVoicesKnown();
|
|
54
53
|
return "spoken";
|
|
55
54
|
}
|
|
56
55
|
/** Speaks the text that was blocked; call it from a click. */
|
|
57
56
|
playPending() {
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
this.flushWhenVoicesKnown();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether the browser has a voice for the language (default: `preferredLanguage`); `undefined` while its voice list
|
|
61
|
+
* is still loading or without speech synthesis.
|
|
62
|
+
*/
|
|
63
|
+
hasVoiceFor(lang = this.preferredLanguage) {
|
|
64
|
+
if (!SpeechController.supported || !this.refreshVoices()) {
|
|
65
|
+
return void 0;
|
|
60
66
|
}
|
|
67
|
+
return this.voiceFor(lang) !== void 0;
|
|
61
68
|
}
|
|
62
69
|
stop() {
|
|
63
70
|
this.pending = null;
|
|
64
71
|
this.requestId++;
|
|
72
|
+
this.stopWaitingForVoices();
|
|
65
73
|
if (SpeechController.supported) {
|
|
66
74
|
speechSynthesis.cancel();
|
|
67
75
|
}
|
|
@@ -76,21 +84,41 @@ class SpeechController {
|
|
|
76
84
|
} catch {
|
|
77
85
|
}
|
|
78
86
|
}
|
|
87
|
+
/** Speaks the pending text now if the voice list is known, else on voiceschanged or after VOICE_WAIT_MS. */
|
|
88
|
+
flushWhenVoicesKnown() {
|
|
89
|
+
this.stopWaitingForVoices();
|
|
90
|
+
if (this.refreshVoices()) {
|
|
91
|
+
this.flushPending();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.voiceWait = setTimeout(() => {
|
|
95
|
+
this.voiceWait = void 0;
|
|
96
|
+
this.flushPending();
|
|
97
|
+
}, VOICE_WAIT_MS);
|
|
98
|
+
}
|
|
99
|
+
stopWaitingForVoices() {
|
|
100
|
+
if (this.voiceWait !== void 0) {
|
|
101
|
+
clearTimeout(this.voiceWait);
|
|
102
|
+
this.voiceWait = void 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Reads the browser's voice list; false while it is still empty. */
|
|
79
106
|
refreshVoices() {
|
|
80
107
|
const available = speechSynthesis.getVoices();
|
|
81
|
-
if (available.length
|
|
82
|
-
|
|
108
|
+
if (available.length > 0) {
|
|
109
|
+
this.voices = available;
|
|
110
|
+
this.selectedVoice = this.preferredVoice(available);
|
|
83
111
|
}
|
|
84
|
-
this.voices
|
|
85
|
-
this.selectedVoice = this.preferredVoice(available);
|
|
86
|
-
return this.selectedVoice !== null;
|
|
112
|
+
return this.voices.length > 0;
|
|
87
113
|
}
|
|
114
|
+
/** A voice that speaks the language: the learner's, else a preferred one, else the first; never one of another language. */
|
|
88
115
|
voiceFor(lang) {
|
|
89
|
-
const wanted = lang
|
|
90
|
-
|
|
116
|
+
const wanted = primaryLanguage(lang);
|
|
117
|
+
const speaks = (voice) => !!voice && primaryLanguage(voice.lang) === wanted;
|
|
118
|
+
if (speaks(this.selectedVoice)) {
|
|
91
119
|
return this.selectedVoice;
|
|
92
120
|
}
|
|
93
|
-
return this.voices.find((voice) => voice.
|
|
121
|
+
return this.preferredVoices.map((name) => this.voices.find((voice) => voice.name === name)).find(speaks) ?? this.voices.find(speaks);
|
|
94
122
|
}
|
|
95
123
|
preferredVoice(available) {
|
|
96
124
|
const chosen = this.selectedVoice?.name ?? readStoredVoiceName();
|
|
@@ -104,7 +132,8 @@ class SpeechController {
|
|
|
104
132
|
const requestId = ++this.requestId;
|
|
105
133
|
const { text, options } = this.pending;
|
|
106
134
|
this.pending = null;
|
|
107
|
-
const
|
|
135
|
+
const lang = options.lang ?? this.preferredLanguage;
|
|
136
|
+
const voice = this.voiceFor(lang);
|
|
108
137
|
const speakLatest = () => {
|
|
109
138
|
if (requestId !== this.requestId) {
|
|
110
139
|
return;
|
|
@@ -113,9 +142,7 @@ class SpeechController {
|
|
|
113
142
|
if (voice) {
|
|
114
143
|
utterance.voice = voice;
|
|
115
144
|
}
|
|
116
|
-
|
|
117
|
-
utterance.lang = options.lang;
|
|
118
|
-
}
|
|
145
|
+
utterance.lang = voice?.lang ?? lang;
|
|
119
146
|
utterance.rate = options.rate ?? this.rate;
|
|
120
147
|
speechSynthesis.speak(utterance);
|
|
121
148
|
};
|
|
@@ -127,6 +154,9 @@ class SpeechController {
|
|
|
127
154
|
setTimeout(speakLatest, 0);
|
|
128
155
|
}
|
|
129
156
|
}
|
|
157
|
+
function primaryLanguage(lang) {
|
|
158
|
+
return lang.trim().toLowerCase().split(/[-_]/)[0];
|
|
159
|
+
}
|
|
130
160
|
function clampRate(rate) {
|
|
131
161
|
return Number.isFinite(rate) ? Math.min(MAX_RATE, Math.max(MIN_RATE, rate)) : 1;
|
|
132
162
|
}
|