@mosetta/editor 0.0.0-stage → 0.0.29
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 +190 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// DICTATION into the assistant. Where the platform writes speech out (`/__mosetta__/transcribe`, a
|
|
2
|
+
// speech recognition model that hears the language by itself), the browser records what the person
|
|
3
|
+
// says and sends it whole when they stop — the words come a moment later, any language, no switch.
|
|
4
|
+
// Elsewhere (a dev server) the browser's own speech recognition (the Web Speech API) turns speech
|
|
5
|
+
// into text as it is said — on the device where the browser has the language (Chrome's
|
|
6
|
+
// `processLocally`; a language it can fetch is fetched in the background for the next time),
|
|
7
|
+
// through the browser's own service otherwise; its language is the person's choice among the ones
|
|
8
|
+
// their browser names, remembered.
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
interface ISpeechResult {
|
|
12
|
+
readonly isFinal: boolean;
|
|
13
|
+
readonly 0: { readonly transcript: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ISpeechEvent {
|
|
17
|
+
readonly resultIndex: number;
|
|
18
|
+
readonly results: ArrayLike<ISpeechResult>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ISpeechRecognition {
|
|
22
|
+
lang: string;
|
|
23
|
+
continuous: boolean;
|
|
24
|
+
interimResults: boolean;
|
|
25
|
+
processLocally?: boolean;
|
|
26
|
+
onresult: ((event: ISpeechEvent) => void) | null;
|
|
27
|
+
onerror: ((event: { readonly error: string }) => void) | null;
|
|
28
|
+
onaudiostart: (() => void) | null;
|
|
29
|
+
onend: (() => void) | null;
|
|
30
|
+
start(): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
abort(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TRecognitionClass = (new () => ISpeechRecognition) & {
|
|
36
|
+
/** Chrome: whether the language can be recognized on the device (`available`), or fetched for it (`downloadable`) */
|
|
37
|
+
available?: (options: { langs: string[]; processLocally: boolean }) => Promise<string>;
|
|
38
|
+
install?: (options: { langs: string[]; processLocally: boolean }) => Promise<boolean>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** whether this language is recognized on the device now; one that can be fetched starts fetching for next time */
|
|
42
|
+
async function onDevice(Recognition: TRecognitionClass, lang: string): Promise<boolean> {
|
|
43
|
+
if (!Recognition.available) return false;
|
|
44
|
+
try {
|
|
45
|
+
const state = await Recognition.available({ langs: [lang], processLocally: true });
|
|
46
|
+
if (state === 'available') return true;
|
|
47
|
+
if (state === 'downloadable' && Recognition.install) void Recognition.install({ langs: [lang], processLocally: true }).catch(() => false);
|
|
48
|
+
} catch { /* the browser cannot say — its service then */ }
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const recognitionClass = (): TRecognitionClass | null => {
|
|
53
|
+
if (typeof window === 'undefined') return null;
|
|
54
|
+
const w = window as unknown as { SpeechRecognition?: TRecognitionClass; webkitSpeechRecognition?: TRecognitionClass };
|
|
55
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const LANG_KEY = 'mosetta-assistant-dictation-lang';
|
|
59
|
+
|
|
60
|
+
/** the languages offered: the browser's own, one per primary language, as it orders them */
|
|
61
|
+
export function dictationLanguages(preferred: readonly string[]): string[] {
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
for (const tag of preferred) {
|
|
65
|
+
const primary = tag.split('-')[0].toLowerCase();
|
|
66
|
+
if (!primary || seen.has(primary)) continue;
|
|
67
|
+
seen.add(primary);
|
|
68
|
+
out.push(tag.includes('-') ? tag : primary);
|
|
69
|
+
}
|
|
70
|
+
return out.length ? out : ['en-US'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IDictation {
|
|
74
|
+
readonly supported: boolean;
|
|
75
|
+
readonly listening: boolean;
|
|
76
|
+
/** what was said is being written out — the recording is over */
|
|
77
|
+
readonly busy: boolean;
|
|
78
|
+
/** how long the recording runs, in seconds — 0 where the words come as they are said */
|
|
79
|
+
readonly seconds: number;
|
|
80
|
+
/** the language to dictate in; null — the platform hears it by itself */
|
|
81
|
+
readonly lang: string | null;
|
|
82
|
+
readonly langs: readonly string[];
|
|
83
|
+
/** a failure worth telling (no microphone allowed, …); null when all is well */
|
|
84
|
+
readonly problem: string | null;
|
|
85
|
+
toggle(): void;
|
|
86
|
+
/** dictation ended and its words written in — the text as it then is */
|
|
87
|
+
finish(): Promise<string>;
|
|
88
|
+
nextLang(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Dictation by the browser's own recognition, written into a text as it is said: what was typed
|
|
93
|
+
* before it began, followed by what has been said so far (the words still being heard included).
|
|
94
|
+
*/
|
|
95
|
+
function useSpeechDictation(onText: (text: string) => void, currentText: () => string): IDictation {
|
|
96
|
+
const Recognition = recognitionClass();
|
|
97
|
+
const langs = dictationLanguages(typeof navigator === 'undefined' ? [] : navigator.languages ?? [navigator.language]);
|
|
98
|
+
const [lang, setLang] = useState<string>(() => {
|
|
99
|
+
try {
|
|
100
|
+
const held = window.localStorage.getItem(LANG_KEY);
|
|
101
|
+
if (held && langs.includes(held)) return held;
|
|
102
|
+
} catch { /* storage denied */ }
|
|
103
|
+
return langs[0];
|
|
104
|
+
});
|
|
105
|
+
const [listening, setListening] = useState(false);
|
|
106
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
107
|
+
// the recognition running (or starting): one at a time, whatever the clicks
|
|
108
|
+
const recognition = useRef<ISpeechRecognition | null>(null);
|
|
109
|
+
const starting = useRef(false);
|
|
110
|
+
// a language to go on in once the recognition running has ended (the person switched mid-way)
|
|
111
|
+
const restartIn = useRef<string | null>(null);
|
|
112
|
+
|
|
113
|
+
const start = useCallback(async (inLang: string): Promise<void> => {
|
|
114
|
+
if (!Recognition || recognition.current || starting.current) return;
|
|
115
|
+
starting.current = true;
|
|
116
|
+
const base = currentText().replace(/\s+$/, '');
|
|
117
|
+
setProblem(null);
|
|
118
|
+
const run = (local: boolean): void => {
|
|
119
|
+
const heard = new Recognition();
|
|
120
|
+
heard.lang = inLang;
|
|
121
|
+
heard.continuous = true;
|
|
122
|
+
heard.interimResults = true;
|
|
123
|
+
if (local) heard.processLocally = true;
|
|
124
|
+
let finals = '';
|
|
125
|
+
let retry = false;
|
|
126
|
+
heard.onresult = (event) => {
|
|
127
|
+
let interim = '';
|
|
128
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
129
|
+
const result = event.results[i];
|
|
130
|
+
if (result.isFinal) finals += result[0].transcript;
|
|
131
|
+
else interim += result[0].transcript;
|
|
132
|
+
}
|
|
133
|
+
const said = `${finals}${interim}`.trim();
|
|
134
|
+
onText(base && said ? `${base} ${said}` : base || said);
|
|
135
|
+
};
|
|
136
|
+
heard.onaudiostart = () => setListening(true);
|
|
137
|
+
heard.onerror = (event) => {
|
|
138
|
+
// the device could not after all: the browser's service takes over at once
|
|
139
|
+
if (local && (event.error === 'language-not-supported' || event.error === 'service-not-allowed')) retry = true;
|
|
140
|
+
else if (event.error === 'not-allowed') setProblem('the microphone is not allowed for this site');
|
|
141
|
+
else if (event.error !== 'aborted' && event.error !== 'no-speech') setProblem(`dictation stopped: ${event.error}`);
|
|
142
|
+
};
|
|
143
|
+
heard.onend = () => {
|
|
144
|
+
if (recognition.current !== heard) return;
|
|
145
|
+
recognition.current = null;
|
|
146
|
+
setListening(false);
|
|
147
|
+
if (retry) run(false);
|
|
148
|
+
else if (restartIn.current) {
|
|
149
|
+
const next = restartIn.current;
|
|
150
|
+
restartIn.current = null;
|
|
151
|
+
void start(next);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
recognition.current = heard;
|
|
155
|
+
try {
|
|
156
|
+
heard.start();
|
|
157
|
+
} catch (e) {
|
|
158
|
+
recognition.current = null;
|
|
159
|
+
setListening(false);
|
|
160
|
+
setProblem(`dictation could not start: ${(e as Error).message}`);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
run(await onDevice(Recognition, inLang));
|
|
165
|
+
} finally {
|
|
166
|
+
starting.current = false;
|
|
167
|
+
}
|
|
168
|
+
}, [Recognition, currentText, onText]);
|
|
169
|
+
|
|
170
|
+
/** the recognition running, ended — at once, and for sure: a browser that never says it ended does not keep the button stuck */
|
|
171
|
+
const stop = useCallback((): void => {
|
|
172
|
+
const running = recognition.current;
|
|
173
|
+
if (!running) return;
|
|
174
|
+
running.stop();
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
if (recognition.current !== running) return;
|
|
177
|
+
running.abort();
|
|
178
|
+
recognition.current = null;
|
|
179
|
+
setListening(false);
|
|
180
|
+
}, 1500);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
useEffect(() => () => recognition.current?.abort(), []);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
supported: Recognition !== null,
|
|
187
|
+
listening,
|
|
188
|
+
busy: false,
|
|
189
|
+
seconds: 0,
|
|
190
|
+
lang,
|
|
191
|
+
langs,
|
|
192
|
+
problem,
|
|
193
|
+
toggle: () => {
|
|
194
|
+
if (recognition.current) stop();
|
|
195
|
+
else void start(lang);
|
|
196
|
+
},
|
|
197
|
+
finish: () => {
|
|
198
|
+
stop();
|
|
199
|
+
return Promise.resolve(currentText());
|
|
200
|
+
},
|
|
201
|
+
nextLang: () => {
|
|
202
|
+
const next = langs[(langs.indexOf(lang) + 1) % langs.length];
|
|
203
|
+
setLang(next);
|
|
204
|
+
try {
|
|
205
|
+
window.localStorage.setItem(LANG_KEY, next);
|
|
206
|
+
} catch { /* storage denied */ }
|
|
207
|
+
// switched while dictating: the words from here on are heard in the new language
|
|
208
|
+
if (recognition.current) {
|
|
209
|
+
restartIn.current = next;
|
|
210
|
+
recognition.current.abort();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- recorded, written out by the platform ----
|
|
217
|
+
|
|
218
|
+
export const TRANSCRIBE_PATH = '/__mosetta__/transcribe';
|
|
219
|
+
/** the longest dictation — it stops by itself then, and is written out */
|
|
220
|
+
export const RECORDING_SECONDS_MAX = 300;
|
|
221
|
+
|
|
222
|
+
/** the recording format this browser makes: webm/opus in Chrome and Firefox, mp4/aac in Safari */
|
|
223
|
+
function recordingType(): string | undefined {
|
|
224
|
+
for (const type of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']) {
|
|
225
|
+
if (MediaRecorder.isTypeSupported(type)) return type;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** the text with what was said added after it */
|
|
231
|
+
export const withSaid = (text: string, said: string): string => {
|
|
232
|
+
const base = text.replace(/\s+$/, '');
|
|
233
|
+
return base && said ? `${base} ${said}` : base || said;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** dictation the platform writes out: recorded until the person stops, the words added then */
|
|
237
|
+
function useRecordedDictation(onText: (text: string) => void, currentText: () => string, offered: boolean): IDictation {
|
|
238
|
+
const supported = offered && typeof window !== 'undefined' && typeof window.MediaRecorder !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia);
|
|
239
|
+
const [listening, setListening] = useState(false);
|
|
240
|
+
const [busy, setBusy] = useState(false);
|
|
241
|
+
const [seconds, setSeconds] = useState(0);
|
|
242
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
243
|
+
const recorder = useRef<MediaRecorder | null>(null);
|
|
244
|
+
// from the start of a recording until its words are in: one at a time
|
|
245
|
+
const done = useRef<Promise<string> | null>(null);
|
|
246
|
+
|
|
247
|
+
/** what was recorded, written out and added to the text — the text as it then is */
|
|
248
|
+
const writeOut = useCallback(async (audio: Blob): Promise<string> => {
|
|
249
|
+
if (!audio.size) return currentText();
|
|
250
|
+
setBusy(true);
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(TRANSCRIBE_PATH, { method: 'POST', headers: { 'content-type': audio.type || 'audio/webm' }, body: audio });
|
|
253
|
+
const body = (await res.json().catch(() => null)) as { text?: unknown; error?: { message?: string } } | null;
|
|
254
|
+
if (!res.ok || typeof body?.text !== 'string') {
|
|
255
|
+
setProblem(body?.error?.message ?? `the speech could not be written out (${res.status})`);
|
|
256
|
+
return currentText();
|
|
257
|
+
}
|
|
258
|
+
const text = withSaid(currentText(), body.text);
|
|
259
|
+
onText(text);
|
|
260
|
+
return text;
|
|
261
|
+
} catch (e) {
|
|
262
|
+
setProblem(`the speech could not be written out: ${(e as Error).message}`);
|
|
263
|
+
return currentText();
|
|
264
|
+
} finally {
|
|
265
|
+
setBusy(false);
|
|
266
|
+
}
|
|
267
|
+
}, [currentText, onText]);
|
|
268
|
+
|
|
269
|
+
const start = useCallback((): void => {
|
|
270
|
+
if (done.current) return;
|
|
271
|
+
setProblem(null);
|
|
272
|
+
let stopped: () => void = () => undefined;
|
|
273
|
+
const over = new Promise<void>((resolve) => {
|
|
274
|
+
stopped = resolve;
|
|
275
|
+
});
|
|
276
|
+
done.current = (async () => {
|
|
277
|
+
let stream: MediaStream;
|
|
278
|
+
try {
|
|
279
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
280
|
+
} catch (e) {
|
|
281
|
+
setProblem((e as DOMException).name === 'NotAllowedError' ? 'the microphone is not allowed for this site' : `no microphone: ${(e as Error).message}`);
|
|
282
|
+
return currentText();
|
|
283
|
+
}
|
|
284
|
+
const type = recordingType();
|
|
285
|
+
const heard = new MediaRecorder(stream, type ? { mimeType: type } : undefined);
|
|
286
|
+
const chunks: Blob[] = [];
|
|
287
|
+
heard.ondataavailable = (event) => {
|
|
288
|
+
if (event.data.size) chunks.push(event.data);
|
|
289
|
+
};
|
|
290
|
+
heard.onstop = () => stopped();
|
|
291
|
+
recorder.current = heard;
|
|
292
|
+
heard.start();
|
|
293
|
+
setSeconds(0);
|
|
294
|
+
setListening(true);
|
|
295
|
+
await over;
|
|
296
|
+
for (const track of stream.getTracks()) track.stop();
|
|
297
|
+
recorder.current = null;
|
|
298
|
+
setListening(false);
|
|
299
|
+
return writeOut(new Blob(chunks, { type: heard.mimeType || type || 'audio/webm' }));
|
|
300
|
+
})().finally(() => {
|
|
301
|
+
done.current = null;
|
|
302
|
+
});
|
|
303
|
+
}, [currentText, writeOut]);
|
|
304
|
+
|
|
305
|
+
const finish = useCallback((): Promise<string> => {
|
|
306
|
+
const running = recorder.current;
|
|
307
|
+
if (running && running.state !== 'inactive') running.stop();
|
|
308
|
+
return done.current ?? Promise.resolve(currentText());
|
|
309
|
+
}, [currentText]);
|
|
310
|
+
|
|
311
|
+
// the clock of a recording, and its end when it runs too long
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!listening) return undefined;
|
|
314
|
+
const tick = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
315
|
+
return () => clearInterval(tick);
|
|
316
|
+
}, [listening]);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (listening && seconds >= RECORDING_SECONDS_MAX) void finish();
|
|
319
|
+
}, [finish, listening, seconds]);
|
|
320
|
+
// leaving mid-way: the microphone is let go, nothing is sent
|
|
321
|
+
useEffect(() => () => {
|
|
322
|
+
const running = recorder.current;
|
|
323
|
+
if (!running) return;
|
|
324
|
+
running.ondataavailable = null;
|
|
325
|
+
if (running.state !== 'inactive') running.stop();
|
|
326
|
+
for (const track of running.stream.getTracks()) track.stop();
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
supported,
|
|
331
|
+
listening,
|
|
332
|
+
busy,
|
|
333
|
+
seconds,
|
|
334
|
+
lang: null,
|
|
335
|
+
langs: [],
|
|
336
|
+
problem,
|
|
337
|
+
toggle: () => {
|
|
338
|
+
if (recorder.current) void finish();
|
|
339
|
+
else if (!done.current) start();
|
|
340
|
+
},
|
|
341
|
+
finish,
|
|
342
|
+
nextLang: () => undefined,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Dictation that writes into a text: `onText` gets the text with what was said added, `currentText`
|
|
348
|
+
* reads it as it is. `transcription` — the platform writes speech out; else the browser does.
|
|
349
|
+
*/
|
|
350
|
+
export function useDictation(onText: (text: string) => void, currentText: () => string, transcription: boolean): IDictation {
|
|
351
|
+
const speech = useSpeechDictation(onText, currentText);
|
|
352
|
+
const recorded = useRecordedDictation(onText, currentText, transcription);
|
|
353
|
+
return recorded.supported ? recorded : speech;
|
|
354
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// What the shell knows about the PROJECT and the PERSON: the platform's Worker describes the
|
|
2
|
+
// project at /__mosetta__/project (its host, the origin the public sees it under, its published
|
|
3
|
+
// routes, who is signed in). A dev server has no such door — the shell then knows the host from
|
|
4
|
+
// the address bar alone, and nobody is signed in.
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
export const PROJECT_INFO_PATH = '/__mosetta__/project';
|
|
8
|
+
|
|
9
|
+
export interface IProjectInfo {
|
|
10
|
+
readonly host: string;
|
|
11
|
+
/** the public origin the project is served under (a client's own domain), or null */
|
|
12
|
+
readonly publicOrigin: string | null;
|
|
13
|
+
/** the published routes */
|
|
14
|
+
readonly pages: readonly { readonly pattern: string; readonly name: string; readonly rev: number }[];
|
|
15
|
+
readonly signedIn: { readonly via: string; readonly login: string } | null;
|
|
16
|
+
/** the platform's sign-in service — where signing out happens; null on a dev server */
|
|
17
|
+
readonly authOrigin: string | null;
|
|
18
|
+
/** the platform's own site (the projects list) — the way back from a project; null on a dev server */
|
|
19
|
+
readonly platformOrigin: string | null;
|
|
20
|
+
/** the distributions the project disconnected (by namespace) — the editor offers them no more */
|
|
21
|
+
readonly disabledDistributions: readonly string[];
|
|
22
|
+
/** the platform's assistant is there for this site (`/__mosetta__/assistant`) */
|
|
23
|
+
readonly assistant: boolean;
|
|
24
|
+
/** the platform writes dictation out (`/__mosetta__/transcribe`) */
|
|
25
|
+
readonly transcription: boolean;
|
|
26
|
+
/** the description came from the platform (false: made up from the address bar) */
|
|
27
|
+
readonly described: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fromLocation = (): IProjectInfo => ({
|
|
31
|
+
host: typeof window === 'undefined' ? '' : window.location.host,
|
|
32
|
+
publicOrigin: null,
|
|
33
|
+
pages: [],
|
|
34
|
+
signedIn: null,
|
|
35
|
+
authOrigin: null,
|
|
36
|
+
platformOrigin: null,
|
|
37
|
+
disabledDistributions: [],
|
|
38
|
+
assistant: false,
|
|
39
|
+
transcription: false,
|
|
40
|
+
described: false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** the description, asked once; until it arrives — and where there is none — the address bar's */
|
|
44
|
+
export function useProjectInfo(): IProjectInfo {
|
|
45
|
+
const [info, setInfo] = useState<IProjectInfo>(fromLocation);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let alive = true;
|
|
48
|
+
fetch(PROJECT_INFO_PATH, { headers: { accept: 'application/json' } })
|
|
49
|
+
.then(async (res) => {
|
|
50
|
+
if (!alive || !res.ok) return;
|
|
51
|
+
const body = (await res.json()) as Partial<IProjectInfo>;
|
|
52
|
+
if (!alive || typeof body.host !== 'string') return;
|
|
53
|
+
setInfo({
|
|
54
|
+
host: body.host,
|
|
55
|
+
publicOrigin: typeof body.publicOrigin === 'string' ? body.publicOrigin : null,
|
|
56
|
+
pages: Array.isArray(body.pages) ? body.pages : [],
|
|
57
|
+
signedIn: body.signedIn && typeof body.signedIn.login === 'string' ? body.signedIn : null,
|
|
58
|
+
authOrigin: typeof body.authOrigin === 'string' ? body.authOrigin : null,
|
|
59
|
+
platformOrigin: typeof body.platformOrigin === 'string' ? body.platformOrigin : null,
|
|
60
|
+
disabledDistributions: Array.isArray(body.disabledDistributions) ? body.disabledDistributions.filter((n): n is string => typeof n === 'string') : [],
|
|
61
|
+
assistant: body.assistant === true,
|
|
62
|
+
transcription: body.transcription === true,
|
|
63
|
+
described: true,
|
|
64
|
+
});
|
|
65
|
+
})
|
|
66
|
+
.catch(() => undefined); // no platform behind this host: the address bar's words stand
|
|
67
|
+
return () => {
|
|
68
|
+
alive = false;
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
return info;
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The RECENT list of the sidebar: the last pages and prefabs opened in this browser — a
|
|
2
|
+
// convenience of this browser (localStorage), never content, newest first, a few at most.
|
|
3
|
+
import type { ContentKind } from '@mosetta/content';
|
|
4
|
+
|
|
5
|
+
export interface IRecentEntry {
|
|
6
|
+
readonly kind: ContentKind;
|
|
7
|
+
/** the page's (or prefab's) storage name — what opens it */
|
|
8
|
+
readonly page: string;
|
|
9
|
+
/** the words shown: the route, or the prefab's name */
|
|
10
|
+
readonly title: string;
|
|
11
|
+
readonly at: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const RECENT_LIMIT = 8;
|
|
15
|
+
const RECENT_KEY = 'mosetta-workbench-recent';
|
|
16
|
+
/** the window event a write raises: the sidebar (another block) re-reads on it */
|
|
17
|
+
export const RECENT_EVENT = 'mosetta-workbench-recent';
|
|
18
|
+
|
|
19
|
+
type TRecentHost = Pick<Window, 'localStorage'> & Partial<Pick<Window, 'dispatchEvent' | 'addEventListener' | 'removeEventListener'>>;
|
|
20
|
+
|
|
21
|
+
const isEntry = (value: unknown): value is IRecentEntry => typeof value === 'object' && value !== null
|
|
22
|
+
&& ((value as IRecentEntry).kind === 'pages' || (value as IRecentEntry).kind === 'prefabs')
|
|
23
|
+
&& typeof (value as IRecentEntry).page === 'string' && typeof (value as IRecentEntry).title === 'string'
|
|
24
|
+
&& typeof (value as IRecentEntry).at === 'number';
|
|
25
|
+
|
|
26
|
+
export function readRecent(host: Pick<Window, 'localStorage'>): IRecentEntry[] {
|
|
27
|
+
try {
|
|
28
|
+
const raw = host.localStorage.getItem(RECENT_KEY);
|
|
29
|
+
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
30
|
+
return Array.isArray(parsed) ? parsed.filter(isEntry).slice(0, RECENT_LIMIT) : [];
|
|
31
|
+
} catch {
|
|
32
|
+
return []; // a document that denies storage, or a list someone mangled by hand
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeRecent(host: TRecentHost, entries: readonly IRecentEntry[]): void {
|
|
37
|
+
try {
|
|
38
|
+
host.localStorage.setItem(RECENT_KEY, JSON.stringify(entries.slice(0, RECENT_LIMIT)));
|
|
39
|
+
} catch {
|
|
40
|
+
// nothing to do: the list lives for the page only
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
host.dispatchEvent?.(new Event(RECENT_EVENT));
|
|
44
|
+
} catch {
|
|
45
|
+
// a host without events: no reader to tell
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** a document just opened goes first; the same page known before moves up */
|
|
50
|
+
export function noteRecent(host: TRecentHost, entry: IRecentEntry): IRecentEntry[] {
|
|
51
|
+
const next = [entry, ...readRecent(host).filter((known) => !(known.kind === entry.kind && known.page === entry.page))].slice(0, RECENT_LIMIT);
|
|
52
|
+
writeRecent(host, next);
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** the entry of a page that is gone, removed; what it was called, for a notice */
|
|
57
|
+
export function forgetRecent(host: TRecentHost, doc: { kind: ContentKind; page: string }): IRecentEntry | null {
|
|
58
|
+
const known = readRecent(host).find((entry) => entry.kind === doc.kind && entry.page === doc.page) ?? null;
|
|
59
|
+
if (known) writeRecent(host, readRecent(host).filter((entry) => !(entry.kind === doc.kind && entry.page === doc.page)));
|
|
60
|
+
return known;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the list as a store: `listener` runs on every write in this window; returns the unsubscribe */
|
|
64
|
+
export function subscribeRecent(host: TRecentHost, listener: () => void): () => void {
|
|
65
|
+
host.addEventListener?.(RECENT_EVENT, listener);
|
|
66
|
+
return () => host.removeEventListener?.(RECENT_EVENT, listener);
|
|
67
|
+
}
|