@ssml-builder-js/ssml-editor-react 2.4.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/CHANGELOG.md +91 -0
- package/dist/index.d.mts +288 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +5423 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5385 -0
- package/dist/index.mjs.map +1 -0
- package/e2e/monaco-editor.spec.ts +126 -0
- package/package.json +57 -0
- package/src/SsmlEditor.tsx +1302 -0
- package/src/buttonVisibility.ts +36 -0
- package/src/clearSsmlDocument.ts +57 -0
- package/src/components/popovers/InsertionPopover.tsx +136 -0
- package/src/components/popovers/InsertionPopovers.tsx +47 -0
- package/src/components/popovers/ProsodyPopovers.tsx +78 -0
- package/src/components/popovers/TextPopovers.tsx +8 -0
- package/src/components/popovers/TimingPopovers.tsx +8 -0
- package/src/constants/ssmlPresets.ts +660 -0
- package/src/constants/ui.ts +11 -0
- package/src/editableSsml.ts +251 -0
- package/src/formatXml.ts +600 -0
- package/src/hooks/useSsmlEditorState.ts +704 -0
- package/src/hooks/useSsmlMonaco.ts +393 -0
- package/src/index.tsx +50 -0
- package/src/locales.ts +435 -0
- package/src/ssmlCodeAction.ts +144 -0
- package/src/ssmlCodeLens.ts +226 -0
- package/src/ssmlCompletion.ts +129 -0
- package/src/ssmlContext.ts +196 -0
- package/src/ssmlDiagnostics.ts +191 -0
- package/src/ssmlHover.ts +703 -0
- package/src/ssmlInsertion.ts +75 -0
- package/src/ssmlInsertions.ts +471 -0
- package/src/styles/editorStyles.ts +282 -0
- package/test/format-xml-edge-cases.test.ts +107 -0
- package/test/index.test.ts +597 -0
- package/test/randomized-editor-invariants.test.ts +520 -0
- package/test/ui-components.test.tsx +854 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.mjs +10 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { buildPartialSsml, buildSsml } from "@ssml-builder-js/ssml-core";
|
|
3
|
+
import type {
|
|
4
|
+
ProsodyElement,
|
|
5
|
+
SsmlDocument,
|
|
6
|
+
SsmlElement,
|
|
7
|
+
SsmlNode,
|
|
8
|
+
SsmlPartialContext,
|
|
9
|
+
VoiceElement,
|
|
10
|
+
} from "@ssml-builder-js/ssml-core";
|
|
11
|
+
import type {
|
|
12
|
+
SsmlEditorInsertionDefinition,
|
|
13
|
+
SsmlEditorInsertionOption,
|
|
14
|
+
SsmlEditorInsertionTemplate,
|
|
15
|
+
SsmlEditorTheme,
|
|
16
|
+
SelectionInfo,
|
|
17
|
+
} from "../SsmlEditor";
|
|
18
|
+
import { clearSsmlDocument } from "../clearSsmlDocument";
|
|
19
|
+
import { formatXmlFragment } from "../formatXml";
|
|
20
|
+
import { getEditableRegion, getEditableText, updateEditableText } from "../editableSsml";
|
|
21
|
+
import { createSsmlInsertionEdit } from "../ssmlInsertion";
|
|
22
|
+
import { findSsmlVoiceContext, updateTagAttribute } from "../ssmlContext";
|
|
23
|
+
import type { MonacoEditor, SsmlSyntaxError } from "../ssmlDiagnostics";
|
|
24
|
+
import type { SsmlCodeLensAction } from "../ssmlCodeLens";
|
|
25
|
+
import { SELECTION_OVERLAY_ABOVE_THRESHOLD_LINES } from "../constants/ui";
|
|
26
|
+
|
|
27
|
+
const TIMING_INSERTION_TAGS = new Set(["break", "mstts:silence"]);
|
|
28
|
+
const PROSODY_INSERTION_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
|
|
29
|
+
const TEXT_INSERTION_TAGS = new Set(["sub", "say-as", "phoneme", "w", "lang"]);
|
|
30
|
+
|
|
31
|
+
export interface SelectionOverlayState extends SelectionInfo {
|
|
32
|
+
position: {
|
|
33
|
+
top: number;
|
|
34
|
+
left: number;
|
|
35
|
+
height: number;
|
|
36
|
+
} | null;
|
|
37
|
+
placement: "above" | "below";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MonacoEditorRef {
|
|
41
|
+
current: MonacoEditor | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UseSsmlEditorStateOptions {
|
|
45
|
+
document: SsmlDocument;
|
|
46
|
+
resolvedTheme: SsmlEditorTheme;
|
|
47
|
+
showDecorations: boolean;
|
|
48
|
+
onChange?: (document: SsmlDocument) => void;
|
|
49
|
+
onSsmlChange?: (xml: string) => void;
|
|
50
|
+
onSelectionChange?: (info: SelectionInfo) => void;
|
|
51
|
+
onPreviewSelection?: (ssml: string) => void;
|
|
52
|
+
injectTheme?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PendingCodeLensAttribute {
|
|
56
|
+
insertionId: "rate" | "pitch" | "break";
|
|
57
|
+
attributeName: "rate" | "pitch" | "time";
|
|
58
|
+
tagRange: { start: number; end: number };
|
|
59
|
+
modelVersionId: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EMPTY_SELECTION_OVERLAY: SelectionOverlayState = {
|
|
63
|
+
selectedText: "",
|
|
64
|
+
characterCount: 0,
|
|
65
|
+
hasSelection: false,
|
|
66
|
+
position: null,
|
|
67
|
+
placement: "above",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function isSsmlElement(node: SsmlNode): node is SsmlElement {
|
|
71
|
+
return typeof node !== "string" && node.type !== "text";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isVoice(element: SsmlElement): element is VoiceElement {
|
|
75
|
+
return element.type === "voice";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isProsody(element: SsmlElement): element is ProsodyElement {
|
|
79
|
+
return element.type === "prosody";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getDocumentChildren(document: SsmlDocument): SsmlNode[] {
|
|
83
|
+
return document.children ?? (document.content === undefined ? [] : [document.content]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getPartialContext(document: SsmlDocument): SsmlPartialContext {
|
|
87
|
+
const children = getDocumentChildren(document);
|
|
88
|
+
const voice = findFirstElement(children, isVoice);
|
|
89
|
+
const prosody = findFirstElement(children, isProsody);
|
|
90
|
+
const context: SsmlPartialContext = {
|
|
91
|
+
version: document.version,
|
|
92
|
+
lang: document.lang,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (voice) {
|
|
96
|
+
context.voice = {
|
|
97
|
+
name: voice.name,
|
|
98
|
+
effect: voice.effect,
|
|
99
|
+
attributes: voice.attributes,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (prosody) {
|
|
104
|
+
context.prosody = {
|
|
105
|
+
rate: prosody.rate,
|
|
106
|
+
pitch: prosody.pitch,
|
|
107
|
+
volume: prosody.volume,
|
|
108
|
+
contour: prosody.contour,
|
|
109
|
+
range: prosody.range,
|
|
110
|
+
attributes: prosody.attributes,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return context;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findFirstElement<T extends SsmlElement>(
|
|
118
|
+
nodes: SsmlNode[],
|
|
119
|
+
predicate: (element: SsmlElement) => element is T,
|
|
120
|
+
): T | undefined {
|
|
121
|
+
for (const node of nodes) {
|
|
122
|
+
if (!isSsmlElement(node)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (predicate(node)) {
|
|
127
|
+
return node;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const child = findFirstElement(node.children ?? [], predicate);
|
|
131
|
+
if (child) {
|
|
132
|
+
return child;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getSelectionInfo(editor: MonacoEditor): SelectionInfo {
|
|
140
|
+
const model = editor.getModel();
|
|
141
|
+
const selection = editor.getSelection();
|
|
142
|
+
if (!model || !selection) {
|
|
143
|
+
return {
|
|
144
|
+
selectedText: "",
|
|
145
|
+
characterCount: 0,
|
|
146
|
+
hasSelection: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const selectedText = model.getValueInRange(selection);
|
|
151
|
+
return {
|
|
152
|
+
selectedText,
|
|
153
|
+
characterCount: selectedText.length,
|
|
154
|
+
hasSelection: selectedText.length > 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getSelectionOverlayState(editor: MonacoEditor): SelectionOverlayState {
|
|
159
|
+
const info = getSelectionInfo(editor);
|
|
160
|
+
if (!info.hasSelection) {
|
|
161
|
+
return { ...info, position: null, placement: "above" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const selection = editor.getSelection();
|
|
165
|
+
if (!selection) {
|
|
166
|
+
return { ...info, position: null, placement: "above" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const position = editor.getScrolledVisiblePosition(selection.getStartPosition());
|
|
170
|
+
if (!position) {
|
|
171
|
+
return { ...info, position: null, placement: "above" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const editorHeight = editor.getLayoutInfo().height;
|
|
175
|
+
if (position.top + position.height < 0 || position.top > editorHeight) {
|
|
176
|
+
return { ...info, position: null, placement: "above" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...info,
|
|
181
|
+
position,
|
|
182
|
+
placement: position.top >= SELECTION_OVERLAY_ABOVE_THRESHOLD_LINES * position.height ? "above" : "below",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getCurrentLineText(editor: MonacoEditor): string | null {
|
|
187
|
+
const model = editor.getModel();
|
|
188
|
+
const selection = editor.getSelection();
|
|
189
|
+
if (!model || !selection) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const text = model.getLineContent(selection.positionLineNumber);
|
|
194
|
+
return text.trim().length > 0 ? text : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getSelectedText(editor: MonacoEditor): string | null {
|
|
198
|
+
const model = editor.getModel();
|
|
199
|
+
const selection = editor.getSelection();
|
|
200
|
+
if (!model || !selection) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const selectedText = model.getValueInRange(selection);
|
|
205
|
+
return selectedText.length > 0 ? selectedText : getCurrentLineText(editor);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getEffectiveVoiceName(editor: MonacoEditor, document: SsmlDocument): string | undefined {
|
|
209
|
+
const model = editor.getModel();
|
|
210
|
+
const selection = editor.getSelection();
|
|
211
|
+
const outerVoiceName = getEditableRegion(document).voiceName;
|
|
212
|
+
if (!model || !selection) {
|
|
213
|
+
return outerVoiceName;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const offset = model.getOffsetAt(selection.getStartPosition());
|
|
217
|
+
const voiceContext = findSsmlVoiceContext(model.getValue(), offset);
|
|
218
|
+
return voiceContext === undefined ? outerVoiceName : voiceContext.voiceName;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getSelectedSsml(editor: MonacoEditor, document: SsmlDocument): string | null {
|
|
222
|
+
const selectedText = getSelectedText(editor);
|
|
223
|
+
return selectedText === null ? null : buildPartialSsml(selectedText, getPartialContext(document));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getCurrentLineSsml(editor: MonacoEditor, document: SsmlDocument): string | null {
|
|
227
|
+
const currentLineText = getCurrentLineText(editor);
|
|
228
|
+
return currentLineText === null ? null : buildPartialSsml(currentLineText, getPartialContext(document));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function applySsmlTemplate(editor: MonacoEditor, template: SsmlEditorInsertionTemplate): void {
|
|
232
|
+
const model = editor.getModel();
|
|
233
|
+
const selection = editor.getSelection();
|
|
234
|
+
if (!model || !selection) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const startOffset = model.getOffsetAt(selection.getStartPosition());
|
|
239
|
+
const endOffset = model.getOffsetAt(selection.getEndPosition());
|
|
240
|
+
const selectedText = selection.isEmpty() ? "" : model.getValueInRange(selection);
|
|
241
|
+
const { replacement, selectionOffset } = createSsmlInsertionEdit(
|
|
242
|
+
model.getValue(),
|
|
243
|
+
startOffset,
|
|
244
|
+
endOffset,
|
|
245
|
+
template,
|
|
246
|
+
model.getEOL(),
|
|
247
|
+
selectedText,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
editor.pushUndoStop();
|
|
251
|
+
const applied = editor.executeEdits("ssml-toolbar", [
|
|
252
|
+
{
|
|
253
|
+
range: selection,
|
|
254
|
+
text: replacement,
|
|
255
|
+
},
|
|
256
|
+
]);
|
|
257
|
+
editor.pushUndoStop();
|
|
258
|
+
if (!applied) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const nextSelectionStart = model.getPositionAt(startOffset + selectionOffset);
|
|
263
|
+
const nextSelectionEnd = model.getPositionAt(endOffset + selectionOffset);
|
|
264
|
+
editor.setSelection({
|
|
265
|
+
selectionStartLineNumber: nextSelectionStart.lineNumber,
|
|
266
|
+
selectionStartColumn: nextSelectionStart.column,
|
|
267
|
+
positionLineNumber: nextSelectionEnd.lineNumber,
|
|
268
|
+
positionColumn: nextSelectionEnd.column,
|
|
269
|
+
});
|
|
270
|
+
editor.focus();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function applySsmlInsertion(
|
|
274
|
+
editor: MonacoEditor,
|
|
275
|
+
insertion: SsmlEditorInsertionDefinition,
|
|
276
|
+
option: SsmlEditorInsertionOption,
|
|
277
|
+
): void {
|
|
278
|
+
applySsmlTemplate(editor, insertion.createTemplate(option.value));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function useSsmlEditorState({
|
|
282
|
+
document,
|
|
283
|
+
resolvedTheme,
|
|
284
|
+
showDecorations,
|
|
285
|
+
onChange,
|
|
286
|
+
onSsmlChange,
|
|
287
|
+
onSelectionChange,
|
|
288
|
+
onPreviewSelection,
|
|
289
|
+
injectTheme,
|
|
290
|
+
}: UseSsmlEditorStateOptions) {
|
|
291
|
+
const editorRef = useRef<MonacoEditor | null>(null);
|
|
292
|
+
const [draftDocument, setDraftDocument] = useState(document);
|
|
293
|
+
const [selectionOverlay, setSelectionOverlay] = useState<SelectionOverlayState>(EMPTY_SELECTION_OVERLAY);
|
|
294
|
+
const [isDark, setIsDark] = useState(false);
|
|
295
|
+
const [decorationsVisible, setDecorationsVisible] = useState(showDecorations);
|
|
296
|
+
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
|
297
|
+
const [syntaxError, setSyntaxError] = useState<SsmlSyntaxError | null>(null);
|
|
298
|
+
const [activeTags, setActiveTags] = useState<Set<string>>(() => new Set());
|
|
299
|
+
const [openPopoverId, setOpenPopoverId] = useState<string | null>(null);
|
|
300
|
+
const [popoverVoiceName, setPopoverVoiceName] = useState<string | undefined>(undefined);
|
|
301
|
+
const [popoverPosition, setPopoverPosition] = useState<{ top: number; left: number } | null>(null);
|
|
302
|
+
const pendingCodeLensAttributeRef = useRef<PendingCodeLensAttribute | null>(null);
|
|
303
|
+
const helpPanelId = useId();
|
|
304
|
+
const draftDocumentRef = useRef(document);
|
|
305
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
306
|
+
const onPreviewSelectionRef = useRef(onPreviewSelection);
|
|
307
|
+
const activePopoverTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
308
|
+
const activePopoverMenuRef = useRef<HTMLDivElement | null>(null);
|
|
309
|
+
|
|
310
|
+
draftDocumentRef.current = draftDocument;
|
|
311
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
312
|
+
onPreviewSelectionRef.current = onPreviewSelection;
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
injectTheme?.();
|
|
316
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
317
|
+
setIsDark(mq.matches);
|
|
318
|
+
const handler = (event: MediaQueryListEvent): void => setIsDark(event.matches);
|
|
319
|
+
mq.addEventListener("change", handler);
|
|
320
|
+
return () => mq.removeEventListener("change", handler);
|
|
321
|
+
}, [injectTheme]);
|
|
322
|
+
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
draftDocumentRef.current = document;
|
|
325
|
+
setDraftDocument(document);
|
|
326
|
+
}, [document]);
|
|
327
|
+
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
setDecorationsVisible(showDecorations);
|
|
330
|
+
}, [showDecorations]);
|
|
331
|
+
|
|
332
|
+
const closePopover = useCallback((restoreFocus = false): void => {
|
|
333
|
+
pendingCodeLensAttributeRef.current = null;
|
|
334
|
+
setOpenPopoverId(null);
|
|
335
|
+
setPopoverVoiceName(undefined);
|
|
336
|
+
setPopoverPosition(null);
|
|
337
|
+
if (restoreFocus) {
|
|
338
|
+
activePopoverTriggerRef.current?.focus();
|
|
339
|
+
}
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
const togglePopover = useCallback((id: string, trigger: HTMLButtonElement): void => {
|
|
343
|
+
pendingCodeLensAttributeRef.current = null;
|
|
344
|
+
setOpenPopoverId((currentId) => {
|
|
345
|
+
if (currentId === id) {
|
|
346
|
+
setPopoverVoiceName(undefined);
|
|
347
|
+
setPopoverPosition(null);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
activePopoverTriggerRef.current = trigger;
|
|
351
|
+
setPopoverVoiceName(
|
|
352
|
+
editorRef.current ? getEffectiveVoiceName(editorRef.current, draftDocumentRef.current) : undefined,
|
|
353
|
+
);
|
|
354
|
+
return id;
|
|
355
|
+
});
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
const setPopoverMenuRef = useCallback((menu: HTMLDivElement | null): void => {
|
|
359
|
+
activePopoverMenuRef.current = menu;
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
if (!openPopoverId) {
|
|
364
|
+
setPopoverPosition(null);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const updatePopoverPosition = (): void => {
|
|
369
|
+
const trigger = activePopoverTriggerRef.current;
|
|
370
|
+
if (trigger) {
|
|
371
|
+
const triggerBounds = trigger.getBoundingClientRect();
|
|
372
|
+
setPopoverPosition({
|
|
373
|
+
top: triggerBounds.bottom + 4,
|
|
374
|
+
left: triggerBounds.left,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const pending = pendingCodeLensAttributeRef.current;
|
|
380
|
+
const editor = editorRef.current;
|
|
381
|
+
const model = editor?.getModel();
|
|
382
|
+
if (!pending || !editor || !model) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const position = model.getPositionAt(pending.tagRange.start);
|
|
387
|
+
const visiblePosition = editor.getScrolledVisiblePosition(position);
|
|
388
|
+
const editorElement = editor.getDomNode();
|
|
389
|
+
if (!visiblePosition || !editorElement) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const editorBounds = editorElement.getBoundingClientRect();
|
|
394
|
+
const menu = activePopoverMenuRef.current;
|
|
395
|
+
const margin = 8;
|
|
396
|
+
setPopoverPosition({
|
|
397
|
+
top: Math.max(
|
|
398
|
+
margin,
|
|
399
|
+
Math.min(
|
|
400
|
+
editorBounds.top + visiblePosition.top + visiblePosition.height + 4,
|
|
401
|
+
window.innerHeight - (menu?.offsetHeight ?? 0) - margin,
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
left: Math.max(
|
|
405
|
+
margin,
|
|
406
|
+
Math.min(editorBounds.left + visiblePosition.left, window.innerWidth - (menu?.offsetWidth ?? 0) - margin),
|
|
407
|
+
),
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
const handlePointerDown = (event: PointerEvent): void => {
|
|
411
|
+
const target = event.target;
|
|
412
|
+
if (
|
|
413
|
+
target instanceof Node &&
|
|
414
|
+
!activePopoverTriggerRef.current?.contains(target) &&
|
|
415
|
+
!activePopoverMenuRef.current?.contains(target)
|
|
416
|
+
) {
|
|
417
|
+
closePopover();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
421
|
+
if (event.key === "Escape") {
|
|
422
|
+
closePopover(true);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
updatePopoverPosition();
|
|
427
|
+
window.addEventListener("resize", updatePopoverPosition);
|
|
428
|
+
window.addEventListener("scroll", updatePopoverPosition, true);
|
|
429
|
+
globalThis.document.addEventListener("pointerdown", handlePointerDown);
|
|
430
|
+
globalThis.document.addEventListener("keydown", handleKeyDown);
|
|
431
|
+
|
|
432
|
+
return () => {
|
|
433
|
+
window.removeEventListener("resize", updatePopoverPosition);
|
|
434
|
+
window.removeEventListener("scroll", updatePopoverPosition, true);
|
|
435
|
+
globalThis.document.removeEventListener("pointerdown", handlePointerDown);
|
|
436
|
+
globalThis.document.removeEventListener("keydown", handleKeyDown);
|
|
437
|
+
};
|
|
438
|
+
}, [closePopover, openPopoverId]);
|
|
439
|
+
|
|
440
|
+
const refreshSelectionOverlay = useCallback((editor: MonacoEditor, notify: boolean): void => {
|
|
441
|
+
const nextSelection = getSelectionOverlayState(editor);
|
|
442
|
+
setSelectionOverlay(nextSelection);
|
|
443
|
+
if (notify) {
|
|
444
|
+
onSelectionChangeRef.current?.({
|
|
445
|
+
selectedText: nextSelection.selectedText,
|
|
446
|
+
characterCount: nextSelection.characterCount,
|
|
447
|
+
hasSelection: nextSelection.hasSelection,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}, []);
|
|
451
|
+
|
|
452
|
+
const updateActiveTags = useCallback((nextTags: ReadonlySet<string>): void => {
|
|
453
|
+
setActiveTags((currentTags) => {
|
|
454
|
+
if (currentTags.size === nextTags.size && [...currentTags].every((tag) => nextTags.has(tag))) {
|
|
455
|
+
return currentTags;
|
|
456
|
+
}
|
|
457
|
+
return new Set(nextTags);
|
|
458
|
+
});
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
461
|
+
const commit = useCallback(
|
|
462
|
+
(nextDocument: SsmlDocument): void => {
|
|
463
|
+
draftDocumentRef.current = nextDocument;
|
|
464
|
+
setDraftDocument(nextDocument);
|
|
465
|
+
onChange?.(nextDocument);
|
|
466
|
+
onSsmlChange?.(buildSsml(nextDocument));
|
|
467
|
+
},
|
|
468
|
+
[onChange, onSsmlChange],
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const handleTextChange = useCallback(
|
|
472
|
+
(value: string): void => {
|
|
473
|
+
commit(updateEditableText(draftDocumentRef.current, value));
|
|
474
|
+
if (editorRef.current) {
|
|
475
|
+
refreshSelectionOverlay(editorRef.current, false);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
[commit, refreshSelectionOverlay],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const handleInsert = useCallback(
|
|
482
|
+
(insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption): void => {
|
|
483
|
+
const editor = editorRef.current;
|
|
484
|
+
const pending = pendingCodeLensAttributeRef.current;
|
|
485
|
+
if (!editor) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (pending?.insertionId === insertion.id) {
|
|
490
|
+
const model = editor.getModel();
|
|
491
|
+
if (model && model.getVersionId() === pending.modelVersionId) {
|
|
492
|
+
const source = model.getValue();
|
|
493
|
+
const tagText = source.slice(pending.tagRange.start, pending.tagRange.end);
|
|
494
|
+
const updatedTag = updateTagAttribute(
|
|
495
|
+
tagText,
|
|
496
|
+
{ start: 0, end: tagText.length },
|
|
497
|
+
pending.attributeName,
|
|
498
|
+
option.value,
|
|
499
|
+
);
|
|
500
|
+
const start = model.getPositionAt(pending.tagRange.start);
|
|
501
|
+
const end = model.getPositionAt(pending.tagRange.end);
|
|
502
|
+
editor.pushUndoStop();
|
|
503
|
+
editor.executeEdits("ssml-code-lens", [
|
|
504
|
+
{
|
|
505
|
+
range: {
|
|
506
|
+
startLineNumber: start.lineNumber,
|
|
507
|
+
startColumn: start.column,
|
|
508
|
+
endLineNumber: end.lineNumber,
|
|
509
|
+
endColumn: end.column,
|
|
510
|
+
},
|
|
511
|
+
text: updatedTag,
|
|
512
|
+
},
|
|
513
|
+
]);
|
|
514
|
+
editor.pushUndoStop();
|
|
515
|
+
editor.focus();
|
|
516
|
+
}
|
|
517
|
+
pendingCodeLensAttributeRef.current = null;
|
|
518
|
+
if (!model || model.getVersionId() !== pending.modelVersionId) {
|
|
519
|
+
closePopover();
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
applySsmlInsertion(editor, insertion, option);
|
|
525
|
+
},
|
|
526
|
+
[closePopover],
|
|
527
|
+
);
|
|
528
|
+
const handleInsertBreak = useCallback(
|
|
529
|
+
(insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption): void => {
|
|
530
|
+
if (insertion.tagName && TIMING_INSERTION_TAGS.has(insertion.tagName)) {
|
|
531
|
+
handleInsert(insertion, option);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
[handleInsert],
|
|
535
|
+
);
|
|
536
|
+
const handleInsertProsody = useCallback(
|
|
537
|
+
(insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption): void => {
|
|
538
|
+
if (insertion.tagName && PROSODY_INSERTION_TAGS.has(insertion.tagName)) {
|
|
539
|
+
handleInsert(insertion, option);
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
[handleInsert],
|
|
543
|
+
);
|
|
544
|
+
const handleInsertText = useCallback(
|
|
545
|
+
(insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption): void => {
|
|
546
|
+
if (insertion.tagName && TEXT_INSERTION_TAGS.has(insertion.tagName)) {
|
|
547
|
+
handleInsert(insertion, option);
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
[handleInsert],
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const handleClear = useCallback((): void => {
|
|
554
|
+
commit(clearSsmlDocument(draftDocumentRef.current));
|
|
555
|
+
}, [commit]);
|
|
556
|
+
|
|
557
|
+
const handleFormat = useCallback((): void => {
|
|
558
|
+
const value = editorRef.current?.getValue() ?? getEditableText(draftDocumentRef.current);
|
|
559
|
+
commit(updateEditableText(draftDocumentRef.current, formatXmlFragment(value)));
|
|
560
|
+
}, [commit]);
|
|
561
|
+
|
|
562
|
+
const handleUndo = useCallback((): void => {
|
|
563
|
+
editorRef.current?.trigger("toolbar", "undo", null);
|
|
564
|
+
editorRef.current?.focus();
|
|
565
|
+
}, []);
|
|
566
|
+
|
|
567
|
+
const handleRedo = useCallback((): void => {
|
|
568
|
+
editorRef.current?.trigger("toolbar", "redo", null);
|
|
569
|
+
editorRef.current?.focus();
|
|
570
|
+
}, []);
|
|
571
|
+
|
|
572
|
+
const previewSelection = useCallback((): void => {
|
|
573
|
+
const editor = editorRef.current;
|
|
574
|
+
if (!editor) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const selectedSsml = getSelectedSsml(editor, draftDocumentRef.current);
|
|
579
|
+
if (selectedSsml !== null) {
|
|
580
|
+
onPreviewSelectionRef.current?.(selectedSsml);
|
|
581
|
+
}
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
const getSelectedSsmlValue = useCallback(
|
|
585
|
+
() => (editorRef.current ? getSelectedSsml(editorRef.current, draftDocumentRef.current) : null),
|
|
586
|
+
[],
|
|
587
|
+
);
|
|
588
|
+
const getCurrentLineSsmlValue = useCallback(
|
|
589
|
+
() => (editorRef.current ? getCurrentLineSsml(editorRef.current, draftDocumentRef.current) : null),
|
|
590
|
+
[],
|
|
591
|
+
);
|
|
592
|
+
const getFullSsml = useCallback(
|
|
593
|
+
() =>
|
|
594
|
+
buildSsml(
|
|
595
|
+
editorRef.current
|
|
596
|
+
? updateEditableText(draftDocumentRef.current, editorRef.current.getValue())
|
|
597
|
+
: draftDocumentRef.current,
|
|
598
|
+
),
|
|
599
|
+
[],
|
|
600
|
+
);
|
|
601
|
+
const getOuterVoiceName = useCallback(() => getEditableRegion(draftDocumentRef.current).voiceName, []);
|
|
602
|
+
|
|
603
|
+
const handleCodeLensAction = useCallback((action: SsmlCodeLensAction): void => {
|
|
604
|
+
const editor = editorRef.current;
|
|
605
|
+
const model = editor?.getModel();
|
|
606
|
+
if (!editor || !model) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (action.type === "attribute") {
|
|
611
|
+
pendingCodeLensAttributeRef.current = {
|
|
612
|
+
insertionId: action.insertionId,
|
|
613
|
+
attributeName: action.attributeName,
|
|
614
|
+
tagRange: action.tagRange,
|
|
615
|
+
modelVersionId: model.getVersionId(),
|
|
616
|
+
};
|
|
617
|
+
activePopoverTriggerRef.current = null;
|
|
618
|
+
setPopoverVoiceName(getEffectiveVoiceName(editor, draftDocumentRef.current));
|
|
619
|
+
setOpenPopoverId(action.insertionId);
|
|
620
|
+
setPopoverPosition(null);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const source = model.getValue();
|
|
625
|
+
const toEditorRange = (range: { start: number; end: number }) => {
|
|
626
|
+
const start = model.getPositionAt(range.start);
|
|
627
|
+
const end = model.getPositionAt(range.end);
|
|
628
|
+
return {
|
|
629
|
+
startLineNumber: start.lineNumber,
|
|
630
|
+
startColumn: start.column,
|
|
631
|
+
endLineNumber: end.lineNumber,
|
|
632
|
+
endColumn: end.column,
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
const edits =
|
|
636
|
+
action.type === "delete"
|
|
637
|
+
? [{ range: toEditorRange(action.tagRange), text: "" }]
|
|
638
|
+
: (() => {
|
|
639
|
+
const elementText = source.slice(action.elementRange.start, action.elementRange.end);
|
|
640
|
+
const closingOffset = elementText.search(/<\/\s*prosody\s*>/i);
|
|
641
|
+
const closingStart =
|
|
642
|
+
closingOffset === -1 ? action.elementRange.end : action.elementRange.start + closingOffset;
|
|
643
|
+
const closingEnd =
|
|
644
|
+
closingOffset === -1
|
|
645
|
+
? action.elementRange.end
|
|
646
|
+
: source.indexOf(">", closingStart) === -1
|
|
647
|
+
? action.elementRange.end
|
|
648
|
+
: source.indexOf(">", closingStart) + 1;
|
|
649
|
+
return [
|
|
650
|
+
{ range: toEditorRange(action.tagRange), text: "" },
|
|
651
|
+
{ range: toEditorRange({ start: closingStart, end: closingEnd }), text: "" },
|
|
652
|
+
];
|
|
653
|
+
})();
|
|
654
|
+
editor.pushUndoStop();
|
|
655
|
+
editor.executeEdits("ssml-code-lens", edits);
|
|
656
|
+
editor.pushUndoStop();
|
|
657
|
+
pendingCodeLensAttributeRef.current = null;
|
|
658
|
+
setOpenPopoverId(null);
|
|
659
|
+
setPopoverPosition(null);
|
|
660
|
+
editor.focus();
|
|
661
|
+
}, []);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
editorRef,
|
|
665
|
+
draftDocument,
|
|
666
|
+
text: getEditableText(draftDocument),
|
|
667
|
+
selectionOverlay,
|
|
668
|
+
activeTags,
|
|
669
|
+
isDarkTheme: resolvedTheme === "dark" || (resolvedTheme === "system" && isDark),
|
|
670
|
+
decorationsVisible,
|
|
671
|
+
setDecorationsVisible,
|
|
672
|
+
isHelpOpen,
|
|
673
|
+
setIsHelpOpen,
|
|
674
|
+
syntaxError,
|
|
675
|
+
setSyntaxError,
|
|
676
|
+
helpPanelId,
|
|
677
|
+
commit,
|
|
678
|
+
handleTextChange,
|
|
679
|
+
handleInsert,
|
|
680
|
+
handleInsertBreak,
|
|
681
|
+
handleInsertProsody,
|
|
682
|
+
handleInsertText,
|
|
683
|
+
handleClear,
|
|
684
|
+
handleFormat,
|
|
685
|
+
handleUndo,
|
|
686
|
+
handleRedo,
|
|
687
|
+
previewSelection,
|
|
688
|
+
canPreviewSelection: onPreviewSelection !== undefined,
|
|
689
|
+
refreshSelectionOverlay,
|
|
690
|
+
updateActiveTags,
|
|
691
|
+
getSelectedSsml: getSelectedSsmlValue,
|
|
692
|
+
getCurrentLineSsml: getCurrentLineSsmlValue,
|
|
693
|
+
getFullSsml,
|
|
694
|
+
getOuterVoiceName,
|
|
695
|
+
handleCodeLensAction,
|
|
696
|
+
openPopoverId,
|
|
697
|
+
popoverVoiceName,
|
|
698
|
+
popoverPosition,
|
|
699
|
+
isPopoverOpen: (id: string) => openPopoverId === id,
|
|
700
|
+
togglePopover,
|
|
701
|
+
closePopover,
|
|
702
|
+
setPopoverMenuRef,
|
|
703
|
+
};
|
|
704
|
+
}
|