@bendyline/squisq-editor-react 2.3.4 → 2.4.1

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.
@@ -0,0 +1,313 @@
1
+ import { SyntheticEvent } from 'react';
2
+ import { Doc, MediaProvider } from '@bendyline/squisq/schemas';
3
+ import { ContentContainer } from '@bendyline/squisq/storage';
4
+ import { NarrationScript, VadConfig, NarrationTrace, NarrationAlignment } from '@bendyline/squisq/narration';
5
+
6
+ /**
7
+ * Mic capture + PCM transport for the teleprompter.
8
+ *
9
+ * getUserMedia → AudioContext → AudioWorklet tap → subscriber callbacks
10
+ * with 1024-sample Float32Array hops on the main thread. The DSP itself
11
+ * lives in core (`@bendyline/squisq/narration`) — this hook only moves
12
+ * samples. Falls back to a ScriptProcessorNode when `audioWorklet` is
13
+ * unavailable (same push model, so pacing still isn't tied to rAF).
14
+ */
15
+ type MicAnalysisStatus = 'idle' | 'starting' | 'live' | 'error';
16
+ type PcmHopListener = (pcm: Float32Array, audioTimeSec: number) => void;
17
+ interface MicAnalysisHandle {
18
+ status: MicAnalysisStatus;
19
+ error: Error | null;
20
+ /** The live mic stream (feeds the narration recorder too), or null. */
21
+ stream: MediaStream | null;
22
+ /** AudioContext sample rate once live. */
23
+ sampleRate: number | null;
24
+ /** Known audio inputs (labels appear after the first grant). */
25
+ devices: MediaDeviceInfo[];
26
+ /**
27
+ * Start (or restart) capture. Resolves with the live stream — callers
28
+ * that need it immediately must use the return value, not the `stream`
29
+ * state field, which only updates on the NEXT render (stale-closure
30
+ * hazard right after an await). Null on failure/supersession.
31
+ */
32
+ start: (deviceId: string | null) => Promise<MediaStream | null>;
33
+ stop: () => void;
34
+ /** Subscribe to PCM hops; returns an unsubscribe. */
35
+ subscribeHop: (listener: PcmHopListener) => () => void;
36
+ }
37
+ declare function useMicAnalysis(): MicAnalysisHandle;
38
+
39
+ /**
40
+ * Shared types for the Narrate (teleprompter) display mode.
41
+ */
42
+ interface TeleprompterPrefs {
43
+ /** Prompter type size in px (28–96). */
44
+ fontSizePx: number;
45
+ /** Beam-splitter mirror flip. */
46
+ mirrored: boolean;
47
+ /** Base speaking rate in words per minute (80–260). */
48
+ baseWpm: number;
49
+ /** Voice-adaptive pacing on/off; off = constant-rate manual mode. */
50
+ voiceTracking: boolean;
51
+ /** VAD sensitivity 0–1 (0.5 = engine defaults). */
52
+ vadSensitivity: number;
53
+ /** Countdown before the prompter starts rolling. */
54
+ countdownSec: 0 | 3 | 5 | 10;
55
+ /** Eye-line chevrons + focus band. */
56
+ lineGuide: boolean;
57
+ /** Preferred mic device id (null = system default). */
58
+ micDeviceId: string | null;
59
+ }
60
+ declare const DEFAULT_TELEPROMPTER_PREFS: TeleprompterPrefs;
61
+ declare const TELEPROMPTER_PREF_LIMITS: Readonly<{
62
+ fontSizePx: {
63
+ min: number;
64
+ max: number;
65
+ };
66
+ baseWpm: {
67
+ min: number;
68
+ max: number;
69
+ };
70
+ vadSensitivity: {
71
+ min: number;
72
+ max: number;
73
+ };
74
+ micDeviceIdMaxLength: 1024;
75
+ }>;
76
+ /**
77
+ * Validate persisted or externally supplied preferences and return a complete,
78
+ * bounded value. Unknown keys are ignored; invalid fields retain `fallback`.
79
+ */
80
+ declare function normalizeTeleprompterPrefs(value: unknown, fallback?: TeleprompterPrefs): TeleprompterPrefs;
81
+ type PrompterTransport = 'stopped' | 'countdown' | 'rolling' | 'paused' | 'finished';
82
+ /** Floating-surface tier, best first. */
83
+ type FloatTier = 'document-pip' | 'video-pip' | 'popup' | 'docked';
84
+
85
+ /**
86
+ * The teleprompter controller — single source of truth, always in the
87
+ * MAIN window (floats are render targets only).
88
+ *
89
+ * Loop-ownership rule: **the audio worklet owns time; the main window
90
+ * owns state; the visible surface owns pixels.** Voice-mode position
91
+ * advances on worklet PCM hops (immune to rAF/timer throttling while
92
+ * the browser is occluded by recording software); manual constant-rate
93
+ * mode uses rAF and is documented as best-effort under occlusion. React
94
+ * state publishes at ~15 Hz; per-hop subscribers (`subscribeTick`)
95
+ * exist for the video-PiP canvas pump.
96
+ */
97
+
98
+ interface TeleprompterKeyEvent {
99
+ readonly defaultPrevented: boolean;
100
+ readonly key: string;
101
+ readonly repeat: boolean;
102
+ readonly target: EventTarget | null;
103
+ preventDefault: () => void;
104
+ }
105
+ interface TeleprompterController {
106
+ script: NarrationScript | null;
107
+ transport: PrompterTransport;
108
+ countdownRemaining: number | null;
109
+ /** Fractional word position (published ~15 Hz). */
110
+ wordPos: number;
111
+ /** Smoothed mic level 0–1 for the meter. */
112
+ micLevel: number;
113
+ /** VAD flag for the meter tint. */
114
+ voiceActive: boolean;
115
+ mic: MicAnalysisHandle;
116
+ prefs: TeleprompterPrefs;
117
+ setPrefs: (patch: Partial<TeleprompterPrefs>) => void;
118
+ play: () => void;
119
+ pause: () => void;
120
+ restart: () => void;
121
+ /** Move by spoken words and re-anchor voice tracking. */
122
+ nudge: (deltaWords: number) => void;
123
+ /** Jump to an absolute token index (e.g. click a block marker). */
124
+ seekToToken: (tokenIndex: number) => void;
125
+ /** Per-analysis-tick subscription (video-PiP pump). Not throttled. */
126
+ subscribeTick: (cb: (wordPos: number) => void) => () => void;
127
+ handleKeyDown: (event: TeleprompterKeyEvent) => void;
128
+ }
129
+ /** Map the 0–1 sensitivity pref onto VAD thresholds (0.5 = engine defaults). */
130
+ declare function vadConfigForSensitivity(sensitivity: number): Partial<VadConfig>;
131
+ declare function useTeleprompter(opts: {
132
+ doc: Doc | null;
133
+ }): TeleprompterController;
134
+
135
+ /**
136
+ * Floating-teleprompter window manager — framework-free.
137
+ *
138
+ * Capability ladder, best first:
139
+ * 1. `document-pip` — Document Picture-in-Picture (Chromium 116+,
140
+ * Firefox 151+): a true always-on-top window hosting live DOM; the
141
+ * React surface portals into it.
142
+ * 2. `video-pip` — canvas → `captureStream(0)` → `<video>` →
143
+ * `requestPictureInPicture()` (Safari's only always-on-top path;
144
+ * `webkitSetPresentationMode` fallback). Read-only: the main
145
+ * window draws frames and calls `requestFrame()` on analysis
146
+ * ticks — never rAF, which throttles under occlusion.
147
+ * 3. `popup` — `window.open` (positionable, not always-on-top).
148
+ * 4. `docked` — no float.
149
+ *
150
+ * Every tier is feature-detected at open time and falls through to the
151
+ * next on ANY failure. All interactive state stays in the main window;
152
+ * floats are render targets only.
153
+ */
154
+
155
+ interface FloatOpenOptions {
156
+ width: number;
157
+ height: number;
158
+ /** Try this tier first; the ladder continues below it on failure. */
159
+ preferredTier?: FloatTier;
160
+ title: string;
161
+ }
162
+ interface CanvasSink {
163
+ canvas: HTMLCanvasElement;
164
+ width: number;
165
+ height: number;
166
+ /** Push the freshly drawn canvas frame into the PiP video. */
167
+ requestFrame: () => void;
168
+ }
169
+ type FloatEvent = 'closed' | 'tierchange';
170
+ interface FloatingWindowManager {
171
+ readonly tier: FloatTier;
172
+ readonly isOpen: boolean;
173
+ /** Resolves with the tier that actually opened ('docked' if none could). */
174
+ open(opts: FloatOpenOptions): Promise<FloatTier>;
175
+ /** Idempotent; restores docked and emits 'closed'. */
176
+ close(): void;
177
+ /** Portal container for 'document-pip' | 'popup'; null otherwise. */
178
+ getPortalTarget(): HTMLElement | null;
179
+ /** Canvas sink for 'video-pip'; null otherwise. */
180
+ getCanvasSink(): CanvasSink | null;
181
+ on(event: FloatEvent, cb: (tier: FloatTier) => void): () => void;
182
+ /** Tear everything down (unmount); like close() but silent-safe. */
183
+ dispose(): void;
184
+ }
185
+ /** Feature-detect the available float tiers, best first. */
186
+ declare function detectFloatTiers(): FloatTier[];
187
+ declare function createFloatingWindowManager(deps: {
188
+ styleCss: string;
189
+ }): FloatingWindowManager;
190
+
191
+ /**
192
+ * React binding over {@link createFloatingWindowManager}: exposes the
193
+ * current tier + portal target as state and guarantees the float closes
194
+ * when the owning view unmounts (mode switch, Use-tab exit, shell
195
+ * teardown).
196
+ */
197
+
198
+ interface FloatingWindowHandle {
199
+ tier: FloatTier;
200
+ isOpen: boolean;
201
+ /** Non-docked tiers this browser supports, best first. */
202
+ supportedTiers: FloatTier[];
203
+ portalTarget: HTMLElement | null;
204
+ canvasSink: CanvasSink | null;
205
+ open: (preferredTier?: FloatTier) => Promise<void>;
206
+ close: () => void;
207
+ }
208
+ declare function useFloatingWindow(styleCss: string): FloatingWindowHandle;
209
+
210
+ /**
211
+ * Narration recorder for the teleprompter — an in-place (no modal)
212
+ * capture flow.
213
+ *
214
+ * Audio records THE MIC ANALYSIS STREAM (what paces the prompter is
215
+ * exactly what lands in the take); the optional camera is a separate
216
+ * video-only capture whose start skew vs the audio recorder is measured
217
+ * and persisted (`cameraOffsetSec`) — the audio file is the doc clock,
218
+ * so camera skew never affects narration timing. While recording, a
219
+ * sparse live trace of the prompter position is sampled; after stop,
220
+ * the take is decoded and run through core's offline aligner
221
+ * (`alignNarration`) to produce word/block timestamps for the sidecar.
222
+ * Decode/alignment failure degrades gracefully: saving still works,
223
+ * just without re-timing.
224
+ */
225
+
226
+ type NarrationRecorderState = 'idle' | 'starting' | 'recording' | 'processing' | 'review' | 'saving' | 'error';
227
+ interface NarrationTake {
228
+ audioBlob: Blob;
229
+ audioMime: string;
230
+ audioExt: string;
231
+ cameraBlob: Blob | null;
232
+ cameraMime: string | null;
233
+ cameraExt: string | null;
234
+ durationSec: number;
235
+ cameraOffsetSec: number | undefined;
236
+ trace: NarrationTrace;
237
+ alignment: NarrationAlignment | null;
238
+ script: NarrationScript;
239
+ }
240
+ interface UseNarrationRecorderOptions {
241
+ mic: MicAnalysisHandle;
242
+ getScript: () => NarrationScript | null;
243
+ /** Live prompter position, sampled into the trace while recording. */
244
+ getWordPos: () => number;
245
+ getMicDeviceId: () => string | null;
246
+ /** Fired when capture actually starts (View starts the prompter). */
247
+ onRecordingStart?: () => void;
248
+ onRecordingStop?: () => void;
249
+ }
250
+ interface NarrationRecorderController {
251
+ state: NarrationRecorderState;
252
+ error: Error | null;
253
+ withCamera: boolean;
254
+ setWithCamera: (on: boolean) => void;
255
+ /** Live camera stream for the self-view while recording. */
256
+ cameraStream: MediaStream | null;
257
+ take: NarrationTake | null;
258
+ start: () => Promise<void>;
259
+ stop: () => Promise<void>;
260
+ retake: () => void;
261
+ discard: () => void;
262
+ /** Transition into/out of 'saving'; the View owns the actual I/O. */
263
+ beginSave: () => void;
264
+ finishSave: (ok: boolean, error?: Error) => void;
265
+ }
266
+ declare function useNarrationRecorder(options: UseNarrationRecorderOptions): NarrationRecorderController;
267
+
268
+ /**
269
+ * useNarrationStage — the state/orchestration half of the narration stage,
270
+ * extracted from TeleprompterView so other hosts (the Record media dialog)
271
+ * can mount the same prompter + recorder + save pipeline.
272
+ *
273
+ * Owns the teleprompter controller (single source of truth in the main
274
+ * window), the floating-window handle, the narration recorder, and the
275
+ * retry-idempotent save flow. Pair with <NarrationStage> for the DOM.
276
+ *
277
+ * Recording is prop-gated: pass `recording` deps (media provider + markdown
278
+ * writers) to enable capture; without them this is a pure prompter with zero
279
+ * capture code paths.
280
+ */
281
+
282
+ /** Editor plumbing the recording flow needs; omit for prompter-only use. */
283
+ interface TeleprompterRecordingDeps {
284
+ mediaProvider: MediaProvider;
285
+ container: ContentContainer | null;
286
+ markdownSource: string;
287
+ setMarkdownSource: (next: string) => void;
288
+ bumpMediaRevision: () => void;
289
+ }
290
+ interface UseNarrationStageOptions {
291
+ doc: Doc | null;
292
+ /** Recording deps; null/omitted disables the Record affordance. */
293
+ recording?: TeleprompterRecordingDeps | null;
294
+ /** Optional user-chosen filename base threaded into the save plan. */
295
+ getAudioBasename?: () => string | undefined;
296
+ }
297
+ interface NarrationStageHandle {
298
+ controller: TeleprompterController;
299
+ float: FloatingWindowHandle;
300
+ recorder: NarrationRecorderController;
301
+ /** Pass-through so the stage component can gate its record slot. */
302
+ recording: TeleprompterRecordingDeps | null;
303
+ saveNotice: string | null;
304
+ dismissSaveNotice: () => void;
305
+ handleSave: () => Promise<void>;
306
+ handleRetake: () => void;
307
+ handleDiscard: () => void;
308
+ reviewAudioUrl: string | null;
309
+ handleReviewTimeUpdate: (event: SyntheticEvent<HTMLAudioElement>) => void;
310
+ }
311
+ declare function useNarrationStage(opts: UseNarrationStageOptions): NarrationStageHandle;
312
+
313
+ export { type CanvasSink as C, DEFAULT_TELEPROMPTER_PREFS as D, type FloatOpenOptions as F, type MicAnalysisHandle as M, type NarrationRecorderController as N, type PrompterTransport as P, type TeleprompterController as T, type UseNarrationStageOptions as U, type FloatTier as a, type FloatingWindowHandle as b, type FloatingWindowManager as c, type MicAnalysisStatus as d, type NarrationRecorderState as e, type NarrationStageHandle as f, type NarrationTake as g, type TeleprompterPrefs as h, type TeleprompterRecordingDeps as i, createFloatingWindowManager as j, detectFloatTiers as k, useMicAnalysis as l, useNarrationRecorder as m, useNarrationStage as n, useTeleprompter as o, TELEPROMPTER_PREF_LIMITS as p, normalizeTeleprompterPrefs as q, useFloatingWindow as u, vadConfigForSensitivity as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-editor-react",
3
- "version": "2.3.4",
3
+ "version": "2.4.1",
4
4
  "description": "React editor shell with raw/WYSIWYG/preview modes for Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "type": "module",
25
25
  "engines": {
26
- "node": ">=22.14.0"
26
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
27
27
  },
28
28
  "main": "./dist/index.js",
29
29
  "module": "./dist/index.js",
@@ -84,9 +84,9 @@
84
84
  "react-dom": "^18.0.0 || ^19.0.0"
85
85
  },
86
86
  "dependencies": {
87
- "@bendyline/squisq": "2.3.3",
88
- "@bendyline/squisq-formats": "2.3.3",
89
- "@bendyline/squisq-react": "2.3.3",
87
+ "@bendyline/squisq": "2.4.1",
88
+ "@bendyline/squisq-formats": "2.3.5",
89
+ "@bendyline/squisq-react": "2.4.1",
90
90
  "@fortawesome/fontawesome-free": "7.2.0",
91
91
  "@tiptap/extension-image": "2.27.2",
92
92
  "@tiptap/extension-link": "2.27.2",
@@ -116,6 +116,8 @@
116
116
  "typescript": "5.9.3"
117
117
  },
118
118
  "sideEffects": [
119
- "**/*.css"
119
+ "**/*.css",
120
+ "**/monacoFeatures*",
121
+ "**/monacoSuggestions*"
120
122
  ]
121
123
  }
@@ -1,132 +0,0 @@
1
- // src/recorder/formats.ts
2
- var AUDIO_CANDIDATES = [
3
- "audio/webm;codecs=opus",
4
- "audio/webm",
5
- "audio/mp4;codecs=mp4a.40.2",
6
- "audio/mp4",
7
- "audio/ogg;codecs=opus"
8
- ];
9
- var VIDEO_CANDIDATES = [
10
- "video/webm;codecs=vp9,opus",
11
- "video/webm;codecs=vp8,opus",
12
- "video/webm",
13
- "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
14
- "video/mp4"
15
- ];
16
- function extensionForMime(mimeType) {
17
- const m = mimeType.toLowerCase();
18
- if (m.startsWith("audio/webm")) return ".webm";
19
- if (m.startsWith("audio/ogg")) return ".ogg";
20
- if (m.startsWith("audio/mp4")) return ".m4a";
21
- if (m.startsWith("audio/mpeg")) return ".mp3";
22
- if (m.startsWith("audio/wav")) return ".wav";
23
- if (m.startsWith("video/webm")) return ".webm";
24
- if (m.startsWith("video/mp4")) return ".mp4";
25
- return ".bin";
26
- }
27
- function probeMimeType(candidates) {
28
- if (typeof MediaRecorder === "undefined") return null;
29
- for (const candidate of candidates) {
30
- try {
31
- if (MediaRecorder.isTypeSupported(candidate)) return candidate;
32
- } catch {
33
- }
34
- }
35
- return null;
36
- }
37
- function resolveFormat(kind, preferred) {
38
- const candidates = kind === "audio" ? AUDIO_CANDIDATES : VIDEO_CANDIDATES;
39
- const probed = (preferred && probeMimeType([preferred])) ?? probeMimeType(candidates) ?? "";
40
- const directory = kind === "audio" ? "audio" : "video";
41
- const extension = probed ? extensionForMime(probed) : ".webm";
42
- return { mimeType: probed, extension, directory };
43
- }
44
- function supportsMediaRecorder() {
45
- return typeof MediaRecorder !== "undefined";
46
- }
47
- function supportsUserMedia() {
48
- return typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined" && typeof navigator.mediaDevices.getUserMedia === "function";
49
- }
50
- function supportsDisplayMedia() {
51
- return typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined" && typeof navigator.mediaDevices.getDisplayMedia === "function";
52
- }
53
- function buildFilename(kind, extension, basename) {
54
- const safe = basename ? basename.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-") : "";
55
- if (safe) return `${safe}${extension}`;
56
- const now = /* @__PURE__ */ new Date();
57
- const stamp = now.getFullYear().toString().padStart(4, "0") + (now.getMonth() + 1).toString().padStart(2, "0") + now.getDate().toString().padStart(2, "0") + "-" + now.getHours().toString().padStart(2, "0") + now.getMinutes().toString().padStart(2, "0") + now.getSeconds().toString().padStart(2, "0");
58
- const prefix = kind === "audio" ? "narration" : "recording";
59
- return `${prefix}-${stamp}${extension}`;
60
- }
61
-
62
- // src/recorder/sources/micStream.ts
63
- async function requestMicStream(constraints) {
64
- if (!supportsUserMedia()) {
65
- throw new Error("navigator.mediaDevices.getUserMedia is not available in this environment.");
66
- }
67
- return navigator.mediaDevices.getUserMedia({
68
- audio: constraints ?? true,
69
- video: false
70
- });
71
- }
72
-
73
- // src/recorder/sources/cameraStream.ts
74
- async function requestCameraStream(options) {
75
- if (!supportsUserMedia()) {
76
- throw new Error("navigator.mediaDevices.getUserMedia is not available in this environment.");
77
- }
78
- const video = options?.video ?? true;
79
- const audio = options?.audio ?? true;
80
- return navigator.mediaDevices.getUserMedia({ video, audio });
81
- }
82
-
83
- // src/recorder/hooks/useStreamPreview.ts
84
- import { useEffect } from "react";
85
- function useStreamPreview(ref, stream) {
86
- useEffect(() => {
87
- const el = ref.current;
88
- if (!el) return;
89
- el.muted = true;
90
- el.playsInline = true;
91
- el.srcObject = stream;
92
- if (stream) {
93
- void el.play().catch(() => {
94
- });
95
- }
96
- return () => {
97
- if (el.srcObject === stream) {
98
- el.srcObject = null;
99
- }
100
- };
101
- }, [ref, stream]);
102
- }
103
-
104
- // src/recorder/timingJson.ts
105
- function buildTimingJson(sourceText, durationSec) {
106
- return {
107
- sourceText: sourceText ?? "",
108
- duration: Number.isFinite(durationSec) && durationSec >= 0 ? durationSec : 0,
109
- bookmarks: []
110
- };
111
- }
112
- function encodeTimingJson(timing) {
113
- const text = JSON.stringify(timing, null, 2);
114
- return new TextEncoder().encode(text);
115
- }
116
- function timingPathFor(audioRelativePath) {
117
- return `${audioRelativePath}.timing.json`;
118
- }
119
-
120
- export {
121
- resolveFormat,
122
- supportsMediaRecorder,
123
- supportsUserMedia,
124
- supportsDisplayMedia,
125
- buildFilename,
126
- requestMicStream,
127
- requestCameraStream,
128
- useStreamPreview,
129
- buildTimingJson,
130
- encodeTimingJson,
131
- timingPathFor
132
- };