@bendyline/docblocks-react 2.6.2 → 2.7.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.
- package/README.md +3 -10
- package/THIRD_PARTY_NOTICES.txt +4284 -3651
- package/dist/AiDiagrams-LFV4YU6W.js +13 -0
- package/dist/AiEditorAssistant-GN2KXKZD.js +786 -0
- package/dist/AudioExportDialog-F23QXNPB.js +253 -0
- package/dist/DocumentConflictDialog-5LRVIGXS.js +111 -0
- package/dist/ExportDialog-IMBVGRYG.js +9 -0
- package/dist/{ExportToolbarControls-QYLBRWM7.js → ExportToolbarControls-MUM6HM3K.js} +117 -49
- package/dist/{GitToolbarControl-O5AXBC6T.js → GitToolbarControl-B7KG4MFP.js} +6 -6
- package/dist/{GitUI-L5T3XHZG.js → GitUI-RCWUZVS6.js} +6 -6
- package/dist/PresentationMaker-W2JIBHRN.js +9 -0
- package/dist/QuickOpenDialog-2KK4SEBU.js +356 -0
- package/dist/Settings-DuXk6baH.d.ts +144 -0
- package/dist/ShareDialog-XUA2OVGI.js +292 -0
- package/dist/SpeechToolbarControl-OSQS7NVY.js +434 -0
- package/dist/TextFromNarration-XYRAB6O2.js +598 -0
- package/dist/WorkspaceSearchPanel-BKZJRPMY.js +334 -0
- package/dist/chunk-2IYBBFQX.js +201 -0
- package/dist/{chunk-LG6HAWCK.js → chunk-56KO2LYN.js} +35 -4
- package/dist/chunk-6B7G33D5.js +88 -0
- package/dist/chunk-ARATARVJ.js +40 -0
- package/dist/{chunk-ODYNOPC3.js → chunk-BCQZTWE5.js} +57 -17
- package/dist/{chunk-FMDLZ4ZG.js → chunk-BD54Z5XP.js} +12 -3
- package/dist/chunk-EA4BO6E4.js +73 -0
- package/dist/chunk-EPOLZNFD.js +518 -0
- package/dist/chunk-G4MFB3IO.js +208 -0
- package/dist/{chunk-MRUK56JS.js → chunk-GMA4KGGO.js} +7 -3
- package/dist/chunk-I44HKDQX.js +314 -0
- package/dist/chunk-K4I3NJFL.js +49 -0
- package/dist/chunk-KBDZDFPU.js +61 -0
- package/dist/{chunk-FNSRCCBC.js → chunk-KWG6UP5O.js} +0 -10
- package/dist/chunk-NAH52736.js +1884 -0
- package/dist/chunk-RP3DVNTI.js +62 -0
- package/dist/chunk-SPUPWCSI.js +111 -0
- package/dist/chunk-TBLZBNQF.js +25 -0
- package/dist/{chunk-M5Y5WO7Z.js → chunk-UG4G7PJ4.js} +1 -1
- package/dist/chunk-UONFWKJ7.js +22 -0
- package/dist/chunk-VO2JYYQZ.js +40 -0
- package/dist/chunk-W55E6TE2.js +1714 -0
- package/dist/chunk-X3DDTJKR.js +574 -0
- package/dist/{chunk-EBWYGTN7.js → chunk-X437UAF7.js} +6 -0
- package/dist/editor.d.ts +36 -1
- package/dist/editor.js +16 -1
- package/dist/export/index.d.ts +27 -11
- package/dist/export/index.js +11 -7
- package/dist/index.d.ts +100 -13
- package/dist/index.js +3927 -1638
- package/dist/proofing/index.js +11 -1
- package/dist/{run-export-I2XOVGG4.js → run-export-47X5FTYZ.js} +3 -3
- package/dist/settings/index.d.ts +32 -101
- package/dist/settings/index.js +14 -2
- package/package.json +9 -9
- package/src/styles/docblocks.css +2344 -657
- package/dist/ExportDialog-Q4VWI43H.js +0 -9
- package/dist/ShareDialog-4OX3UPJ4.js +0 -359
- package/dist/chunk-JDJVRDOP.js +0 -92
- package/dist/chunk-Q6XNIKDG.js +0 -669
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// src/editor.ts
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/components/FindButton.tsx
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
function FindButton({ active, onActiveChange, shortcutLabel: shortcutLabel2 }) {
|
|
7
|
+
const tooltip = shortcutLabel2 ? `Find in document (${shortcutLabel2})` : "Find in document";
|
|
8
|
+
return /* @__PURE__ */ jsx(
|
|
9
|
+
"button",
|
|
10
|
+
{
|
|
11
|
+
type: "button",
|
|
12
|
+
className: `squisq-toolbar-button${active ? " squisq-toolbar-button--active" : ""}`,
|
|
13
|
+
onClick: () => onActiveChange(!active),
|
|
14
|
+
"aria-label": "Find in document",
|
|
15
|
+
"aria-pressed": active,
|
|
16
|
+
"data-tooltip": tooltip,
|
|
17
|
+
children: /* @__PURE__ */ jsx(FindGlyph, {})
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
function FindGlyph() {
|
|
22
|
+
return /* @__PURE__ */ jsxs(
|
|
23
|
+
"svg",
|
|
24
|
+
{
|
|
25
|
+
width: "16",
|
|
26
|
+
height: "16",
|
|
27
|
+
viewBox: "0 0 16 16",
|
|
28
|
+
fill: "none",
|
|
29
|
+
stroke: "currentColor",
|
|
30
|
+
strokeWidth: "1.5",
|
|
31
|
+
strokeLinecap: "round",
|
|
32
|
+
"aria-hidden": "true",
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ jsx("circle", { cx: "6.75", cy: "6.75", r: "4.25" }),
|
|
35
|
+
/* @__PURE__ */ jsx("path", { d: "m10 10 3.5 3.5" })
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/components/find-input.ts
|
|
42
|
+
function focusFindInput(scope = document) {
|
|
43
|
+
const input = scope.querySelector(".squisq-find-input");
|
|
44
|
+
input?.focus();
|
|
45
|
+
input?.select();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/DocBlocksShell/keyboard-shortcuts.ts
|
|
49
|
+
var SHELL_SHORTCUTS = Object.freeze([
|
|
50
|
+
// Must work mid-sentence; that is the entire point of a save shortcut.
|
|
51
|
+
{ id: "save", key: "s", accel: true, label: "Ctrl+S", whileTyping: true },
|
|
52
|
+
// Find is mostly invoked from inside the document being searched.
|
|
53
|
+
{ id: "find", key: "f", accel: true, label: "Ctrl+F", whileTyping: true },
|
|
54
|
+
// Jumping elsewhere is as likely mid-sentence as from the tree.
|
|
55
|
+
{ id: "quick-open", key: "p", accel: true, label: "Ctrl+P", whileTyping: true },
|
|
56
|
+
{
|
|
57
|
+
id: "workspace-search",
|
|
58
|
+
key: "f",
|
|
59
|
+
accel: true,
|
|
60
|
+
shift: true,
|
|
61
|
+
label: "Ctrl+Shift+F",
|
|
62
|
+
whileTyping: true
|
|
63
|
+
},
|
|
64
|
+
// Deliberately NOT Cmd+B — Squisq owns that for Bold. Cmd+\ is the VS Code
|
|
65
|
+
// convention for toggling the side bar.
|
|
66
|
+
{ id: "toggle-sidebar", key: "\\", accel: true, label: "Ctrl+\\", whileTyping: true },
|
|
67
|
+
{ id: "focus-files", key: "e", accel: true, shift: true, label: "Ctrl+Shift+E" },
|
|
68
|
+
{ id: "new-document", key: "n", accel: true, label: "Ctrl+N" },
|
|
69
|
+
{ id: "new-folder", key: "n", accel: true, shift: true, label: "Ctrl+Shift+N" },
|
|
70
|
+
// Ctrl+, (settings) is deliberately absent: AppMenu owns that dialog's state
|
|
71
|
+
// internally and exposes no way to open it. Binding the key without first
|
|
72
|
+
// giving AppMenu a controlled `settingsOpen` prop would ship a dead shortcut.
|
|
73
|
+
{ id: "close-overlay", key: "Escape", accel: false, label: "Esc", whileTyping: true }
|
|
74
|
+
]);
|
|
75
|
+
function isTypingTarget(target) {
|
|
76
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
77
|
+
if (target.isContentEditable) return true;
|
|
78
|
+
const tag = target.tagName;
|
|
79
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
80
|
+
return target.closest(".monaco-editor") !== null;
|
|
81
|
+
}
|
|
82
|
+
function matchShortcut(event, _layoutMode) {
|
|
83
|
+
return SHELL_SHORTCUTS.find((binding) => bindingMatches(binding, event))?.id ?? null;
|
|
84
|
+
}
|
|
85
|
+
function bindingMatches(binding, event) {
|
|
86
|
+
if (event.altKey) return false;
|
|
87
|
+
const accel = event.ctrlKey || event.metaKey;
|
|
88
|
+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
89
|
+
if (binding.accel !== accel) return false;
|
|
90
|
+
if ((binding.shift ?? false) !== event.shiftKey) return false;
|
|
91
|
+
if (binding.key !== key) return false;
|
|
92
|
+
return binding.whileTyping === true || !isTypingTarget(event.target);
|
|
93
|
+
}
|
|
94
|
+
function isFindShortcut(event) {
|
|
95
|
+
const binding = SHELL_SHORTCUTS.find((candidate) => candidate.id === "find");
|
|
96
|
+
return binding !== void 0 && bindingMatches(binding, event);
|
|
97
|
+
}
|
|
98
|
+
function findShortcutLabel() {
|
|
99
|
+
return shortcutLabel("find", isApplePlatform());
|
|
100
|
+
}
|
|
101
|
+
function isApplePlatform() {
|
|
102
|
+
if (typeof navigator === "undefined") return false;
|
|
103
|
+
return /Mac|iPhone|iPad|iPod/u.test(navigator.platform || navigator.userAgent);
|
|
104
|
+
}
|
|
105
|
+
function shortcutLabel(id, isApple) {
|
|
106
|
+
const binding = SHELL_SHORTCUTS.find((candidate) => candidate.id === id);
|
|
107
|
+
if (!binding) return "";
|
|
108
|
+
return isApple ? binding.label.replace("Ctrl+", "\u2318") : binding.label;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/editor.ts
|
|
112
|
+
var PORTRAIT_FORM_FACTOR_QUERY = "(orientation: portrait)";
|
|
113
|
+
function previewViewportPresetForOrientation(isPortrait) {
|
|
114
|
+
return isPortrait ? "portrait" : "landscape";
|
|
115
|
+
}
|
|
116
|
+
function useResponsivePreviewViewportPreset() {
|
|
117
|
+
const [isPortrait, setIsPortrait] = useState(() => {
|
|
118
|
+
if (typeof globalThis.matchMedia !== "function") return false;
|
|
119
|
+
return globalThis.matchMedia(PORTRAIT_FORM_FACTOR_QUERY).matches;
|
|
120
|
+
});
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (typeof globalThis.matchMedia !== "function") return;
|
|
123
|
+
const query = globalThis.matchMedia(PORTRAIT_FORM_FACTOR_QUERY);
|
|
124
|
+
const update = () => setIsPortrait(query.matches);
|
|
125
|
+
update();
|
|
126
|
+
query.addEventListener("change", update);
|
|
127
|
+
return () => query.removeEventListener("change", update);
|
|
128
|
+
}, []);
|
|
129
|
+
return previewViewportPresetForOrientation(isPortrait);
|
|
130
|
+
}
|
|
131
|
+
var EMPTY_DOCUMENT_PROMPTS = [
|
|
132
|
+
"Start typing your content, or drop images on top of me...",
|
|
133
|
+
"Write anything -- paste markdown, drag in images, or just start typing...",
|
|
134
|
+
"Type away. Markdown syntax works too...",
|
|
135
|
+
"Chapter 1 begins here...",
|
|
136
|
+
"Once upon a time...",
|
|
137
|
+
"A blank page. Exciting, isn't it?",
|
|
138
|
+
"The first word is always the hardest...",
|
|
139
|
+
"Plot twist: this is where it all starts...",
|
|
140
|
+
"Write something the future you will thank you for...",
|
|
141
|
+
"Begin at the beginning...",
|
|
142
|
+
"Let's begin.",
|
|
143
|
+
"We can be heroes...",
|
|
144
|
+
"Here comes the sun...",
|
|
145
|
+
"And so it begins...",
|
|
146
|
+
"Let it be, let it be...",
|
|
147
|
+
"Don't stop believing...",
|
|
148
|
+
"Started from the bottom...",
|
|
149
|
+
"This is the start of something new...",
|
|
150
|
+
"Ground control to Major Tom...",
|
|
151
|
+
"Come as you are...",
|
|
152
|
+
"Every new beginning...",
|
|
153
|
+
"Hello, is it me you're looking for?",
|
|
154
|
+
"Imagine all the people...",
|
|
155
|
+
"Anything you want, you got it...",
|
|
156
|
+
"Rise above...",
|
|
157
|
+
"Do you have the time...",
|
|
158
|
+
"Here we are now, entertain us...",
|
|
159
|
+
"All the small things...",
|
|
160
|
+
"Should I stay or should I go?",
|
|
161
|
+
"I am a patient boy -- I wait, I wait, I wait...",
|
|
162
|
+
"Destination unknown...",
|
|
163
|
+
"Easy like Sunday morning...",
|
|
164
|
+
"Dance, dance, dance to the radio...",
|
|
165
|
+
"New day rising...",
|
|
166
|
+
"Visit the world...",
|
|
167
|
+
"Positive mental attitude...",
|
|
168
|
+
"Better I sing than I cry...",
|
|
169
|
+
"Disconnect for a minute...",
|
|
170
|
+
"We need new noise...",
|
|
171
|
+
"Shoot the moon...",
|
|
172
|
+
"I'll surprise you sometime -- I'll come around...",
|
|
173
|
+
"Coming out of my cage, and doing just fine...",
|
|
174
|
+
"Throw me to the wolves -- I'll come back, leading the pack...",
|
|
175
|
+
"There comes a time -- no excuses...",
|
|
176
|
+
"It's up to me now, turn on the bright lights...",
|
|
177
|
+
"Do what you want...",
|
|
178
|
+
"Fingers crossed...",
|
|
179
|
+
"This is the start of how it all ends...",
|
|
180
|
+
"It has to start somewhere, it has to start sometime -- what better place than here? what better time than now?",
|
|
181
|
+
"I know myself, and I'll begin again...",
|
|
182
|
+
"Dig me out, dig me in...",
|
|
183
|
+
"Everybody's full of sugar, honey, ice and tea...",
|
|
184
|
+
"Take time with a wounded hand, 'cause it likes to heal...",
|
|
185
|
+
"Grab a brush and put on a little makeup...",
|
|
186
|
+
"Amber is the color of your energy...",
|
|
187
|
+
"Wait -- they don't love it like I love it...",
|
|
188
|
+
"A world's been buried, where love is the law; a you-topia..."
|
|
189
|
+
];
|
|
190
|
+
function pickEmptyDocumentPrompt(random = Math.random) {
|
|
191
|
+
const index = Math.floor(random() * EMPTY_DOCUMENT_PROMPTS.length);
|
|
192
|
+
return EMPTY_DOCUMENT_PROMPTS[index] ?? EMPTY_DOCUMENT_PROMPTS[0];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
FindButton,
|
|
197
|
+
focusFindInput,
|
|
198
|
+
matchShortcut,
|
|
199
|
+
isFindShortcut,
|
|
200
|
+
findShortcutLabel,
|
|
201
|
+
isApplePlatform,
|
|
202
|
+
shortcutLabel,
|
|
203
|
+
PORTRAIT_FORM_FACTOR_QUERY,
|
|
204
|
+
previewViewportPresetForOrientation,
|
|
205
|
+
useResponsivePreviewViewportPreset,
|
|
206
|
+
EMPTY_DOCUMENT_PROMPTS,
|
|
207
|
+
pickEmptyDocumentPrompt
|
|
208
|
+
};
|
|
@@ -51,15 +51,19 @@ function useMenuKeyboard(open, setOpen) {
|
|
|
51
51
|
(event) => {
|
|
52
52
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
53
53
|
event.preventDefault();
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (open) {
|
|
55
|
+
focusItem(event.key === "ArrowUp" ? items().length - 1 : 0);
|
|
56
|
+
} else {
|
|
57
|
+
pendingFocusRef.current = event.key === "ArrowUp" ? "last" : "first";
|
|
58
|
+
setOpen(true);
|
|
59
|
+
}
|
|
56
60
|
return;
|
|
57
61
|
}
|
|
58
62
|
if (event.key === "Enter" || event.key === " ") {
|
|
59
63
|
if (!open) pendingFocusRef.current = "first";
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
|
-
[open, setOpen]
|
|
66
|
+
[open, setOpen, focusItem, items]
|
|
63
67
|
);
|
|
64
68
|
const handleMenuKeyDown = useCallback(
|
|
65
69
|
(event) => {
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// src/Speech/useGenerateNarration.ts
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEditorContext } from "@bendyline/squisq-editor-react";
|
|
4
|
+
import {
|
|
5
|
+
buildNarrationSavePlan,
|
|
6
|
+
executeNarrationSave
|
|
7
|
+
} from "@bendyline/squisq-editor-react/teleprompter";
|
|
8
|
+
|
|
9
|
+
// src/Speech/speakable-text.ts
|
|
10
|
+
var MAX_SEGMENT_CHARACTERS = 4e3;
|
|
11
|
+
var FENCE = /^(?<indent> {0,3})(?<marker>`{3,}|~{3,})[^\n]*\n[\s\S]*?^\k<indent>\k<marker>[ \t]*$/gmu;
|
|
12
|
+
function stripFencedCode(markdown) {
|
|
13
|
+
return markdown.replace(FENCE, "");
|
|
14
|
+
}
|
|
15
|
+
function ensureSentenceEnd(text) {
|
|
16
|
+
const trimmed = text.trimEnd();
|
|
17
|
+
if (!trimmed) return trimmed;
|
|
18
|
+
return /[.!?…:;]["'”’)\]]*$/u.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
19
|
+
}
|
|
20
|
+
function splitLongText(text, start, max = MAX_SEGMENT_CHARACTERS) {
|
|
21
|
+
const pieces = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < text.length) {
|
|
24
|
+
let end = Math.min(text.length, offset + max);
|
|
25
|
+
if (end < text.length) {
|
|
26
|
+
const window = text.slice(offset, end);
|
|
27
|
+
const sentence = Math.max(
|
|
28
|
+
window.lastIndexOf(". "),
|
|
29
|
+
window.lastIndexOf("! "),
|
|
30
|
+
window.lastIndexOf("? "),
|
|
31
|
+
window.lastIndexOf("\n")
|
|
32
|
+
);
|
|
33
|
+
const space = window.lastIndexOf(" ");
|
|
34
|
+
const cut = sentence > max / 4 ? sentence + 1 : space > max / 4 ? space : window.length;
|
|
35
|
+
end = offset + cut;
|
|
36
|
+
}
|
|
37
|
+
const piece = text.slice(offset, end);
|
|
38
|
+
const leading = piece.length - piece.trimStart().length;
|
|
39
|
+
const trimmed = piece.trim();
|
|
40
|
+
if (trimmed) {
|
|
41
|
+
pieces.push({
|
|
42
|
+
text: trimmed,
|
|
43
|
+
start: start + offset + leading,
|
|
44
|
+
end: start + offset + leading + trimmed.length
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
offset = end;
|
|
48
|
+
}
|
|
49
|
+
return pieces;
|
|
50
|
+
}
|
|
51
|
+
async function planSpeech(markdown) {
|
|
52
|
+
const [{ parseMarkdown }, { markdownToDoc }, { buildNarrationScript }] = await Promise.all([
|
|
53
|
+
import("@bendyline/squisq/markdown"),
|
|
54
|
+
import("@bendyline/squisq/doc"),
|
|
55
|
+
import("@bendyline/squisq/narration")
|
|
56
|
+
]);
|
|
57
|
+
const doc = markdownToDoc(parseMarkdown(stripFencedCode(markdown)));
|
|
58
|
+
const script = buildNarrationScript(doc);
|
|
59
|
+
const segments = [];
|
|
60
|
+
for (const block of script.blocks) {
|
|
61
|
+
const text = script.sourceText.slice(block.charStart, block.charEnd);
|
|
62
|
+
let bodyStart = block.charStart;
|
|
63
|
+
const heading = block.heading?.trim();
|
|
64
|
+
if (heading && text.startsWith(heading)) {
|
|
65
|
+
segments.push({
|
|
66
|
+
text: ensureSentenceEnd(heading),
|
|
67
|
+
blockId: block.blockId,
|
|
68
|
+
kind: "heading",
|
|
69
|
+
sourceStart: block.charStart,
|
|
70
|
+
sourceEnd: block.charStart + heading.length
|
|
71
|
+
});
|
|
72
|
+
bodyStart = block.charStart + heading.length;
|
|
73
|
+
}
|
|
74
|
+
const lines = /[^\n]+/gu;
|
|
75
|
+
const body = script.sourceText.slice(bodyStart, block.charEnd);
|
|
76
|
+
for (const line of body.matchAll(lines)) {
|
|
77
|
+
for (const piece of splitLongText(line[0], bodyStart + line.index)) {
|
|
78
|
+
segments.push({
|
|
79
|
+
text: ensureSentenceEnd(piece.text),
|
|
80
|
+
blockId: block.blockId,
|
|
81
|
+
kind: "body",
|
|
82
|
+
sourceStart: piece.start,
|
|
83
|
+
sourceEnd: piece.end
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { sourceText: script.sourceText, segments, doc, script };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/Speech/tts-narration.ts
|
|
92
|
+
function spokenSpanForChunk(chunk, segment, elapsed) {
|
|
93
|
+
const length = segment.sourceEnd - segment.sourceStart;
|
|
94
|
+
const sourceOffset = (offset) => segment.sourceStart + Math.min(offset, length);
|
|
95
|
+
return {
|
|
96
|
+
sourceStart: sourceOffset(chunk.textStart),
|
|
97
|
+
sourceEnd: sourceOffset(chunk.textEnd),
|
|
98
|
+
startSec: elapsed,
|
|
99
|
+
endSec: elapsed + chunk.durationSec,
|
|
100
|
+
...chunk.wordTimings !== void 0 ? {
|
|
101
|
+
wordTimings: chunk.wordTimings.map((word) => ({
|
|
102
|
+
textStart: sourceOffset(word.textStart),
|
|
103
|
+
textEnd: sourceOffset(word.textEnd),
|
|
104
|
+
startSec: elapsed + word.startSec,
|
|
105
|
+
endSec: elapsed + word.endSec
|
|
106
|
+
})).filter((word) => word.textEnd > word.textStart)
|
|
107
|
+
} : {}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function buildTtsAlignment(script, spans, durationSec) {
|
|
111
|
+
const ordered = [...spans].sort((a, b) => a.sourceStart - b.sourceStart);
|
|
112
|
+
const spanOf = new Array(script.tokens.length).fill(-1);
|
|
113
|
+
let cursor = 0;
|
|
114
|
+
for (const [tokenIndex, token] of script.tokens.entries()) {
|
|
115
|
+
while (cursor < ordered.length && ordered[cursor].sourceEnd <= token.charOffset) cursor += 1;
|
|
116
|
+
const span = ordered[cursor];
|
|
117
|
+
if (span && token.charOffset >= span.sourceStart) spanOf[tokenIndex] = cursor;
|
|
118
|
+
}
|
|
119
|
+
const totals = new Array(ordered.length).fill(0);
|
|
120
|
+
for (const [tokenIndex, token] of script.tokens.entries()) {
|
|
121
|
+
const span = spanOf[tokenIndex];
|
|
122
|
+
if (span >= 0) totals[span] += Math.max(1, token.syllables);
|
|
123
|
+
}
|
|
124
|
+
const consumed = new Array(ordered.length).fill(0);
|
|
125
|
+
const modelWords = ordered.flatMap((span) => span.wordTimings ?? []);
|
|
126
|
+
const modelTimes = [];
|
|
127
|
+
let modelCursor = 0;
|
|
128
|
+
for (const token of script.tokens) {
|
|
129
|
+
while (modelCursor < modelWords.length && modelWords[modelCursor].textEnd <= token.charOffset)
|
|
130
|
+
modelCursor++;
|
|
131
|
+
const word = modelWords[modelCursor];
|
|
132
|
+
modelTimes.push(word && word.textStart < token.charEnd ? word.startSec : void 0);
|
|
133
|
+
}
|
|
134
|
+
const nextModelTimes = [];
|
|
135
|
+
let nextTime = durationSec;
|
|
136
|
+
for (let i = modelTimes.length - 1; i >= 0; i--) {
|
|
137
|
+
nextTime = modelTimes[i] ?? nextTime;
|
|
138
|
+
nextModelTimes[i] = nextTime;
|
|
139
|
+
}
|
|
140
|
+
const words = [];
|
|
141
|
+
let lastTime = 0;
|
|
142
|
+
for (const [tokenIndex, token] of script.tokens.entries()) {
|
|
143
|
+
const index = spanOf[tokenIndex];
|
|
144
|
+
const modelTime = modelTimes[tokenIndex];
|
|
145
|
+
const before = index >= 0 ? consumed[index] : 0;
|
|
146
|
+
if (index >= 0) consumed[index] = before + Math.max(1, token.syllables);
|
|
147
|
+
if (modelTime !== void 0) {
|
|
148
|
+
lastTime = Math.max(lastTime, modelTime);
|
|
149
|
+
words.push({ tokenIndex, tSec: lastTime, interpolated: false });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (index < 0) {
|
|
153
|
+
const next = ordered.find((candidate) => candidate.sourceStart > token.charOffset);
|
|
154
|
+
lastTime = Math.max(
|
|
155
|
+
lastTime,
|
|
156
|
+
Math.min(next?.startSec ?? durationSec, nextModelTimes[tokenIndex])
|
|
157
|
+
);
|
|
158
|
+
words.push({ tokenIndex, tSec: lastTime, interpolated: true });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const span = ordered[index];
|
|
162
|
+
const t = span.startSec + (span.endSec - span.startSec) * (before / Math.max(1, totals[index]));
|
|
163
|
+
lastTime = Math.max(lastTime, Math.min(t, nextModelTimes[tokenIndex]));
|
|
164
|
+
words.push({ tokenIndex, tSec: lastTime, interpolated: true });
|
|
165
|
+
}
|
|
166
|
+
const blocks = [];
|
|
167
|
+
for (const [blockIndex, block] of script.blocks.entries()) {
|
|
168
|
+
const first = words[block.tokenStart];
|
|
169
|
+
blocks.push({
|
|
170
|
+
blockId: block.blockId,
|
|
171
|
+
...block.heading !== void 0 ? { heading: block.heading } : {},
|
|
172
|
+
blockIndex,
|
|
173
|
+
charStart: block.charStart,
|
|
174
|
+
charEnd: block.charEnd,
|
|
175
|
+
startSec: blockIndex === 0 ? 0 : first?.tSec ?? 0,
|
|
176
|
+
endSec: durationSec
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < blocks.length - 1; i += 1) {
|
|
180
|
+
const current = blocks[i];
|
|
181
|
+
const next = blocks[i + 1];
|
|
182
|
+
blocks[i] = { ...current, endSec: Math.max(current.startSec, next.startSec) };
|
|
183
|
+
}
|
|
184
|
+
return { words, blocks, detectedSyllables: 0, cost: 0 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/Speech/useGenerateNarration.ts
|
|
188
|
+
var TTS_NARRATION_GENERATOR = { name: "docblocks-kokoro", method: "tts" };
|
|
189
|
+
function hasDocumentNarration(markdown) {
|
|
190
|
+
return /\{\[audio\b[^\]]*\banchor=document\b[^\]]*\]\}/u.test(markdown);
|
|
191
|
+
}
|
|
192
|
+
function useGenerateNarration(speech) {
|
|
193
|
+
const {
|
|
194
|
+
markdownSource,
|
|
195
|
+
setMarkdownSource,
|
|
196
|
+
mediaProvider,
|
|
197
|
+
workspaceContainer,
|
|
198
|
+
bumpMediaRevision
|
|
199
|
+
} = useEditorContext();
|
|
200
|
+
const [state, setState] = useState({ phase: "idle" });
|
|
201
|
+
const sourceRef = useRef(markdownSource);
|
|
202
|
+
sourceRef.current = markdownSource;
|
|
203
|
+
const run = useRef(null);
|
|
204
|
+
const cancel = useCallback(() => {
|
|
205
|
+
if (run.current) {
|
|
206
|
+
run.current.cancelled = true;
|
|
207
|
+
run.current.handle?.cancel();
|
|
208
|
+
run.current = null;
|
|
209
|
+
}
|
|
210
|
+
setState({ phase: "idle" });
|
|
211
|
+
}, []);
|
|
212
|
+
useEffect(
|
|
213
|
+
() => () => {
|
|
214
|
+
if (run.current) {
|
|
215
|
+
run.current.cancelled = true;
|
|
216
|
+
run.current.handle?.cancel();
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
[]
|
|
220
|
+
);
|
|
221
|
+
const generate = useCallback(() => {
|
|
222
|
+
const synthesize = speech.synthesize;
|
|
223
|
+
if (!synthesize || !mediaProvider || run.current) return;
|
|
224
|
+
const current = { cancelled: false, handle: null };
|
|
225
|
+
run.current = current;
|
|
226
|
+
setState({ phase: "generating", done: 0, total: 1 });
|
|
227
|
+
void (async () => {
|
|
228
|
+
const [plan, { createAudioFileEncoder }] = await Promise.all([
|
|
229
|
+
planSpeech(sourceRef.current),
|
|
230
|
+
import("@bendyline/squisq-video-react/encoder")
|
|
231
|
+
]);
|
|
232
|
+
if (current.cancelled) return;
|
|
233
|
+
if (plan.segments.length === 0) throw new Error("There is nothing to narrate yet.");
|
|
234
|
+
const encoder = await createAudioFileEncoder({
|
|
235
|
+
format: "opus-webm",
|
|
236
|
+
sampleRate: 24e3,
|
|
237
|
+
channels: 1
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
const spans = [];
|
|
241
|
+
let elapsed = 0;
|
|
242
|
+
let appending = Promise.resolve();
|
|
243
|
+
for (const [index, segment] of plan.segments.entries()) {
|
|
244
|
+
if (current.cancelled) return;
|
|
245
|
+
setState({ phase: "generating", done: index, total: plan.segments.length });
|
|
246
|
+
const handle = synthesize({ text: segment.text }, (event) => {
|
|
247
|
+
if (event.kind !== "chunk" || current.cancelled) return;
|
|
248
|
+
const { chunk } = event;
|
|
249
|
+
spans.push(spokenSpanForChunk(chunk, segment, elapsed));
|
|
250
|
+
elapsed += chunk.durationSec;
|
|
251
|
+
const samples = new Float32Array(chunk.pcm);
|
|
252
|
+
appending = appending.then(() => encoder.append([samples]));
|
|
253
|
+
});
|
|
254
|
+
current.handle = handle;
|
|
255
|
+
const result = await handle.done;
|
|
256
|
+
if (current.cancelled) return;
|
|
257
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
258
|
+
}
|
|
259
|
+
await appending;
|
|
260
|
+
if (current.cancelled) return;
|
|
261
|
+
setState({ phase: "saving" });
|
|
262
|
+
const audio = await encoder.finish();
|
|
263
|
+
const savePlan = buildNarrationSavePlan({
|
|
264
|
+
script: plan.script,
|
|
265
|
+
alignment: buildTtsAlignment(plan.script, spans, elapsed),
|
|
266
|
+
durationSec: elapsed,
|
|
267
|
+
// Squisq's recorder extensions carry the dot (".webm").
|
|
268
|
+
audioExt: `.${encoder.extension}`,
|
|
269
|
+
cameraExt: null,
|
|
270
|
+
generator: TTS_NARRATION_GENERATOR
|
|
271
|
+
});
|
|
272
|
+
await executeNarrationSave(
|
|
273
|
+
savePlan,
|
|
274
|
+
{ audioBlob: audio, audioMime: encoder.mimeType, cameraBlob: null, cameraMime: null },
|
|
275
|
+
{
|
|
276
|
+
mediaProvider,
|
|
277
|
+
container: workspaceContainer,
|
|
278
|
+
getMarkdownSource: () => sourceRef.current,
|
|
279
|
+
setMarkdownSource,
|
|
280
|
+
bumpMediaRevision
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
if (current.cancelled) return;
|
|
284
|
+
run.current = null;
|
|
285
|
+
setState({ phase: "done", durationSec: elapsed });
|
|
286
|
+
} finally {
|
|
287
|
+
if (current.cancelled) await encoder.cancel().catch(() => void 0);
|
|
288
|
+
}
|
|
289
|
+
})().catch((error) => {
|
|
290
|
+
if (current.cancelled) return;
|
|
291
|
+
run.current = null;
|
|
292
|
+
setState({
|
|
293
|
+
phase: "error",
|
|
294
|
+
message: error instanceof Error ? error.message : "Generating narration failed."
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}, [bumpMediaRevision, mediaProvider, setMarkdownSource, speech, workspaceContainer]);
|
|
298
|
+
const reset = useCallback(() => {
|
|
299
|
+
if (!run.current) setState({ phase: "idle" });
|
|
300
|
+
}, []);
|
|
301
|
+
return {
|
|
302
|
+
state,
|
|
303
|
+
available: Boolean(speech.synthesize && mediaProvider),
|
|
304
|
+
generate,
|
|
305
|
+
cancel,
|
|
306
|
+
reset
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export {
|
|
311
|
+
planSpeech,
|
|
312
|
+
hasDocumentNarration,
|
|
313
|
+
useGenerateNarration
|
|
314
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/Ai/ai-status.ts
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
function useAiStatus(ai) {
|
|
4
|
+
const [status, setStatus] = useState(null);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
let active = true;
|
|
7
|
+
const unsubscribe = ai.onStatus((next) => {
|
|
8
|
+
if (active) setStatus(next);
|
|
9
|
+
});
|
|
10
|
+
void ai.status().then(
|
|
11
|
+
(initial) => {
|
|
12
|
+
if (active) setStatus((current) => current ?? initial);
|
|
13
|
+
},
|
|
14
|
+
() => {
|
|
15
|
+
if (active) {
|
|
16
|
+
setStatus({
|
|
17
|
+
kind: "error",
|
|
18
|
+
error: { code: "unknown", message: "AI status is unavailable." },
|
|
19
|
+
retryable: true
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
return () => {
|
|
25
|
+
active = false;
|
|
26
|
+
unsubscribe();
|
|
27
|
+
};
|
|
28
|
+
}, [ai]);
|
|
29
|
+
return status;
|
|
30
|
+
}
|
|
31
|
+
function unavailableSentence(status) {
|
|
32
|
+
if (!status) return "Checking AI connection\u2026";
|
|
33
|
+
if (status.kind === "connecting") return "AI is getting ready\u2026";
|
|
34
|
+
if (status.kind === "error") return status.error.message;
|
|
35
|
+
if (status.kind === "ready" && !status.model) return "Add a model in Settings to use AI.";
|
|
36
|
+
if (status.kind === "unavailable") {
|
|
37
|
+
if (status.reason === "opt-out") return "Turn on AI features in Settings.";
|
|
38
|
+
if (status.reason === "disconnected") return "Built-in AI is starting\u2026";
|
|
39
|
+
if (status.reason === "not-installed") return "Built-in AI is not available in this build.";
|
|
40
|
+
if (status.reason === "not-running") return "Built-in AI is restarting\u2026";
|
|
41
|
+
return "AI is not available in this build.";
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
useAiStatus,
|
|
48
|
+
unavailableSentence
|
|
49
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/Speech/speech-input-provider.ts
|
|
2
|
+
function toReadiness(readiness) {
|
|
3
|
+
return {
|
|
4
|
+
state: readiness.state,
|
|
5
|
+
...readiness.reason ? { reason: readiness.reason } : {}
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function abortable(promise, signal) {
|
|
9
|
+
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const onAbort = () => reject(new DOMException("Aborted", "AbortError"));
|
|
12
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
13
|
+
promise.then(
|
|
14
|
+
(value) => {
|
|
15
|
+
signal.removeEventListener("abort", onAbort);
|
|
16
|
+
resolve(value);
|
|
17
|
+
},
|
|
18
|
+
(error) => {
|
|
19
|
+
signal.removeEventListener("abort", onAbort);
|
|
20
|
+
reject(error);
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function createSpeechInputProvider(speech, options) {
|
|
26
|
+
const transcribe = speech.transcribe;
|
|
27
|
+
return {
|
|
28
|
+
id: "docblocks-dictation",
|
|
29
|
+
label: "Dictation",
|
|
30
|
+
async status() {
|
|
31
|
+
if (!transcribe) return { state: "unavailable" };
|
|
32
|
+
return toReadiness((await speech.status()).stt);
|
|
33
|
+
},
|
|
34
|
+
onStatus(listener) {
|
|
35
|
+
return speech.onStatus((status) => listener(toReadiness(status.stt)));
|
|
36
|
+
},
|
|
37
|
+
async prepare(signal) {
|
|
38
|
+
const result = await abortable(speech.prepare("stt"), signal);
|
|
39
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
40
|
+
},
|
|
41
|
+
requestSetup: options.onRequestSetup,
|
|
42
|
+
async transcribe(wav, { prompt, language, signal }) {
|
|
43
|
+
if (!transcribe) throw new Error("Dictation is not available.");
|
|
44
|
+
const result = await abortable(
|
|
45
|
+
transcribe({
|
|
46
|
+
audio: wav,
|
|
47
|
+
mimeType: "audio/wav",
|
|
48
|
+
...prompt ? { prompt } : {},
|
|
49
|
+
...language ? { language } : {}
|
|
50
|
+
}),
|
|
51
|
+
signal
|
|
52
|
+
);
|
|
53
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
54
|
+
return { text: result.value.text };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
createSpeechInputProvider
|
|
61
|
+
};
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// src/Export/image-save.ts
|
|
2
|
-
function createImageSaveOutput(adapter) {
|
|
3
|
-
return async (blob, filename) => {
|
|
4
|
-
const target = await adapter.pickTarget(filename, null);
|
|
5
|
-
if (!target) return false;
|
|
6
|
-
return await adapter.saveBlob(blob, filename, target) !== null;
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
|
|
10
1
|
// src/Export/export-destination.ts
|
|
11
2
|
function updateExportTargetExtension(targetPath, suggestedFilename) {
|
|
12
3
|
const nextExtension = extensionOf(suggestedFilename);
|
|
@@ -36,6 +27,5 @@ function firstSuffixIndex(value) {
|
|
|
36
27
|
}
|
|
37
28
|
|
|
38
29
|
export {
|
|
39
|
-
createImageSaveOutput,
|
|
40
30
|
updateExportTargetExtension
|
|
41
31
|
};
|