@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/index.d.mts +288 -0
  3. package/dist/index.d.ts +288 -0
  4. package/dist/index.js +5423 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +5385 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/e2e/monaco-editor.spec.ts +126 -0
  9. package/package.json +57 -0
  10. package/src/SsmlEditor.tsx +1302 -0
  11. package/src/buttonVisibility.ts +36 -0
  12. package/src/clearSsmlDocument.ts +57 -0
  13. package/src/components/popovers/InsertionPopover.tsx +136 -0
  14. package/src/components/popovers/InsertionPopovers.tsx +47 -0
  15. package/src/components/popovers/ProsodyPopovers.tsx +78 -0
  16. package/src/components/popovers/TextPopovers.tsx +8 -0
  17. package/src/components/popovers/TimingPopovers.tsx +8 -0
  18. package/src/constants/ssmlPresets.ts +660 -0
  19. package/src/constants/ui.ts +11 -0
  20. package/src/editableSsml.ts +251 -0
  21. package/src/formatXml.ts +600 -0
  22. package/src/hooks/useSsmlEditorState.ts +704 -0
  23. package/src/hooks/useSsmlMonaco.ts +393 -0
  24. package/src/index.tsx +50 -0
  25. package/src/locales.ts +435 -0
  26. package/src/ssmlCodeAction.ts +144 -0
  27. package/src/ssmlCodeLens.ts +226 -0
  28. package/src/ssmlCompletion.ts +129 -0
  29. package/src/ssmlContext.ts +196 -0
  30. package/src/ssmlDiagnostics.ts +191 -0
  31. package/src/ssmlHover.ts +703 -0
  32. package/src/ssmlInsertion.ts +75 -0
  33. package/src/ssmlInsertions.ts +471 -0
  34. package/src/styles/editorStyles.ts +282 -0
  35. package/test/format-xml-edge-cases.test.ts +107 -0
  36. package/test/index.test.ts +597 -0
  37. package/test/randomized-editor-invariants.test.ts +520 -0
  38. package/test/ui-components.test.tsx +854 -0
  39. package/tsconfig.json +10 -0
  40. package/tsup.config.ts +17 -0
  41. package/vitest.config.mjs +10 -0
@@ -0,0 +1,520 @@
1
+ import { buildPartialSsml, buildSsml, parseSsml, validateSsml } from "../../ssml-core/src/index";
2
+ import type { SsmlDocument, SsmlElement, SsmlNode } from "../../ssml-core/src/types";
3
+ import fc, { type Command } from "fast-check";
4
+ import { expect, test } from "vitest";
5
+ import { clearSsmlDocument } from "../src/clearSsmlDocument";
6
+ import { formatXml, formatXmlFragment } from "../src/formatXml";
7
+ import { createSsmlInsertionEdit, type SsmlInsertionTemplate } from "../src/ssmlInsertion";
8
+
9
+ const EDITABLE_PREFIX = '<speak version="1.0" xml:lang="en-US">';
10
+ const EDITABLE_SUFFIX = "</speak>";
11
+ const OPERATION_COUNT = 50;
12
+ const NUM_RUNS = 100;
13
+ const SEED = 0x5eed;
14
+ const MAX_GENERATED_OFFSET = 64;
15
+
16
+ type OperationName = "insert-text" | "delete-text" | "select-text" | "insert-tag" | "format-xml" | "clear-document";
17
+
18
+ const TAG_TEMPLATES: readonly SsmlInsertionTemplate[] = [
19
+ { prefix: '<break time="500ms"/>', suffix: "", mode: "insert" },
20
+ { prefix: '<prosody rate="slow">', suffix: "</prosody>", mode: "wrap" },
21
+ { prefix: '<mstts:express-as style="cheerful">', suffix: "</mstts:express-as>", mode: "wrap" },
22
+ ];
23
+
24
+ const asciiTextArbitrary = fc
25
+ .array(fc.constantFrom(...Array.from("abcXYZ0123 .,!?")), { minLength: 1, maxLength: 16 })
26
+ .map((characters) => characters.join(""));
27
+ const japaneseTextArbitrary = fc
28
+ .array(fc.constantFrom(...Array.from("日本語こんにちは世界音声")), { minLength: 1, maxLength: 10 })
29
+ .map((characters) => characters.join(""));
30
+ const reservedXmlTextArbitrary = fc
31
+ .array(fc.constantFrom("&", "<", ">", "&amp;", "&lt;", "&gt;"), { minLength: 1, maxLength: 8 })
32
+ .map((parts) => parts.join(""));
33
+ const incompleteXmlTagArbitrary = fc.constantFrom(
34
+ "<",
35
+ "</",
36
+ "<voice",
37
+ '<prosody rate="slow"',
38
+ '<mstts:express-as style="cheerful"',
39
+ "<break",
40
+ "<voice><prosody>",
41
+ "<speak><voice>日本語",
42
+ );
43
+ const insertionTextArbitrary = fc.oneof(
44
+ asciiTextArbitrary,
45
+ japaneseTextArbitrary,
46
+ reservedXmlTextArbitrary,
47
+ incompleteXmlTagArbitrary,
48
+ );
49
+
50
+ function getChildren(document: SsmlDocument): SsmlNode[] {
51
+ return document.children ?? (document.content === undefined ? [] : [document.content]);
52
+ }
53
+
54
+ function isElement(node: SsmlNode): node is SsmlElement {
55
+ return typeof node !== "string";
56
+ }
57
+
58
+ function findFirstElement(nodes: SsmlNode[], type: SsmlElement["type"]): SsmlElement | undefined {
59
+ for (const node of nodes) {
60
+ if (isElement(node) && node.type === type) {
61
+ return node;
62
+ }
63
+ if (isElement(node)) {
64
+ const result = findFirstElement(node.children ?? [], type);
65
+ if (result) {
66
+ return result;
67
+ }
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ function serializeChildren(children: SsmlNode[], lang: string): string {
74
+ if (children.length === 1 && typeof children[0] === "string") {
75
+ return children[0];
76
+ }
77
+
78
+ const xml = buildSsml({
79
+ type: "speak",
80
+ version: "1.0",
81
+ lang,
82
+ children,
83
+ });
84
+ return xml.slice(xml.indexOf(">") + 1, -EDITABLE_SUFFIX.length);
85
+ }
86
+
87
+ function getEditableText(document: SsmlDocument): string {
88
+ const children = getChildren(document);
89
+ const editableElement = findFirstElement(children, "prosody") ?? findFirstElement(children, "voice");
90
+ return serializeChildren(editableElement?.children ?? children, document.lang);
91
+ }
92
+
93
+ function parseEditableText(value: string): SsmlNode[] {
94
+ try {
95
+ const children = parseSsml(`${EDITABLE_PREFIX}${value}${EDITABLE_SUFFIX}`).children ?? [];
96
+ return children.some(isElement) ? children : [value];
97
+ } catch {
98
+ return [value];
99
+ }
100
+ }
101
+
102
+ function replaceFirstElement(
103
+ nodes: SsmlNode[],
104
+ type: SsmlElement["type"],
105
+ children: SsmlNode[],
106
+ ): { nodes: SsmlNode[]; replaced: boolean } {
107
+ const nextNodes = nodes.map((node) => {
108
+ if (!isElement(node)) {
109
+ return node;
110
+ }
111
+ if (node.type === type) {
112
+ return { ...node, children };
113
+ }
114
+ if (!node.children) {
115
+ return node;
116
+ }
117
+ const result = replaceFirstElement(node.children, type, children);
118
+ if (!result.replaced) {
119
+ return node;
120
+ }
121
+ return { ...node, children: result.nodes };
122
+ });
123
+
124
+ return {
125
+ nodes: nextNodes,
126
+ replaced: nextNodes.some((node, index) => node !== nodes[index]),
127
+ };
128
+ }
129
+
130
+ function updateEditableText(document: SsmlDocument, value: string): SsmlDocument {
131
+ const children = parseEditableText(value);
132
+ const editableChildren = children.length > 0 ? children : [value];
133
+ const currentChildren = getChildren(document);
134
+ const prosodyResult = replaceFirstElement(currentChildren, "prosody", editableChildren);
135
+ if (prosodyResult.replaced) {
136
+ return { ...document, children: prosodyResult.nodes };
137
+ }
138
+
139
+ const voiceResult = replaceFirstElement(currentChildren, "voice", editableChildren);
140
+ if (voiceResult.replaced) {
141
+ return { ...document, children: voiceResult.nodes };
142
+ }
143
+
144
+ return { ...document, children: editableChildren };
145
+ }
146
+
147
+ function createInitialDocument(): SsmlDocument {
148
+ return parseSsml('<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">Hello world</voice></speak>');
149
+ }
150
+
151
+ interface EditorModel {
152
+ document: SsmlDocument;
153
+ value: string;
154
+ selectionStart: number;
155
+ selectionEnd: number;
156
+ operationLog: string[];
157
+ }
158
+
159
+ function createModel(): EditorModel {
160
+ const document = createInitialDocument();
161
+ return {
162
+ document,
163
+ value: getEditableText(document),
164
+ selectionStart: 0,
165
+ selectionEnd: 0,
166
+ operationLog: [],
167
+ };
168
+ }
169
+
170
+ function setModelValue(model: EditorModel, value: string): void {
171
+ model.document = updateEditableText(model.document, value);
172
+ model.value = getEditableText(model.document);
173
+ }
174
+
175
+ function createInsertionResult(
176
+ source: string,
177
+ start: number,
178
+ end: number,
179
+ template: SsmlInsertionTemplate,
180
+ ): { value: string; selectionStart: number; selectionEnd: number } {
181
+ const edit = createSsmlInsertionEdit(source, start, end, template);
182
+ return {
183
+ value: `${source.slice(0, start)}${edit.replacement}${source.slice(end)}`,
184
+ selectionStart: Math.min(source.length + edit.replacement.length - (end - start), start + edit.selectionOffset),
185
+ selectionEnd: Math.min(source.length + edit.replacement.length - (end - start), end + edit.selectionOffset),
186
+ };
187
+ }
188
+
189
+ class RandomizedEditor {
190
+ private document = createInitialDocument();
191
+ private value = getEditableText(this.document);
192
+ private selectionStart = 0;
193
+ private selectionEnd = 0;
194
+
195
+ getFullSsml(): string {
196
+ try {
197
+ return buildSsml(updateEditableText(this.document, this.value));
198
+ } catch {
199
+ return this.getFallbackSsml();
200
+ }
201
+ }
202
+
203
+ getFallbackSsml(): string {
204
+ return buildSsml({
205
+ type: "speak",
206
+ version: "1.0",
207
+ lang: this.document.lang,
208
+ children: [{ type: "text", value: this.value }],
209
+ });
210
+ }
211
+
212
+ getValue(): string {
213
+ return this.value;
214
+ }
215
+
216
+ getSelection(): { start: number; end: number } {
217
+ return { start: this.selectionStart, end: this.selectionEnd };
218
+ }
219
+
220
+ getSelectedSsml(): string | null {
221
+ if (this.selectionStart === this.selectionEnd) {
222
+ return null;
223
+ }
224
+
225
+ return buildPartialSsml(this.value.slice(this.selectionStart, this.selectionEnd), {
226
+ lang: this.document.lang,
227
+ });
228
+ }
229
+
230
+ insertText(position: number, text: string): void {
231
+ this.setValue(`${this.value.slice(0, position)}${text}${this.value.slice(position)}`);
232
+ this.setSelection(position + text.length, position + text.length);
233
+ }
234
+
235
+ deleteText(start: number, end: number): void {
236
+ this.setValue(`${this.value.slice(0, start)}${this.value.slice(end)}`);
237
+ this.setSelection(start, start);
238
+ }
239
+
240
+ selectText(start: number, end: number): void {
241
+ this.setSelection(start, end);
242
+ }
243
+
244
+ insertTag(start: number, end: number, template: SsmlInsertionTemplate): void {
245
+ const result = createInsertionResult(this.value, start, end, template);
246
+ this.setValue(result.value);
247
+ this.setSelection(result.selectionStart, result.selectionEnd);
248
+ }
249
+
250
+ formatXml(): void {
251
+ this.setValue(formatXmlFragment(this.value));
252
+ }
253
+
254
+ clearDocument(): void {
255
+ this.document = clearSsmlDocument(this.document);
256
+ this.value = getEditableText(this.document);
257
+ this.setSelection(0, 0);
258
+ }
259
+
260
+ private setValue(value: string): void {
261
+ this.document = updateEditableText(this.document, value);
262
+ this.value = getEditableText(this.document);
263
+ }
264
+
265
+ private setSelection(start: number, end: number): void {
266
+ this.selectionStart = Math.max(0, Math.min(start, this.value.length));
267
+ this.selectionEnd = Math.max(this.selectionStart, Math.min(end, this.value.length));
268
+ }
269
+ }
270
+
271
+ function checkInvariants(model: EditorModel, editor: RandomizedEditor): void {
272
+ expect(editor.getValue()).toBe(model.value);
273
+ expect(editor.getSelection()).toEqual({
274
+ start: model.selectionStart,
275
+ end: model.selectionEnd,
276
+ });
277
+
278
+ const ssml = editor.getFullSsml();
279
+ let parseableSsml = ssml;
280
+ try {
281
+ parseSsml(ssml);
282
+ } catch {
283
+ parseableSsml = editor.getFallbackSsml();
284
+ expect(() => parseSsml(parseableSsml)).not.toThrow();
285
+ }
286
+ expect(validateSsml(parseableSsml)).toBeNull();
287
+
288
+ const formatted = formatXml(parseableSsml);
289
+ expect(formatXml(formatted)).toBe(formatted);
290
+
291
+ const selectedSsml = editor.getSelectedSsml();
292
+ expect(selectedSsml === null || typeof selectedSsml === "string").toBe(true);
293
+ if (selectedSsml !== null) {
294
+ expect(() => parseSsml(selectedSsml)).not.toThrow();
295
+ }
296
+ }
297
+
298
+ abstract class EditorCommand implements Command<EditorModel, RandomizedEditor> {
299
+ abstract readonly name: OperationName;
300
+
301
+ abstract check(model: Readonly<EditorModel>): boolean;
302
+
303
+ abstract applyModel(model: EditorModel): void;
304
+
305
+ abstract applyReal(editor: RandomizedEditor): void;
306
+
307
+ run(model: EditorModel, editor: RandomizedEditor): void {
308
+ model.operationLog.push(this.toString());
309
+ this.applyModel(model);
310
+ this.applyReal(editor);
311
+ checkInvariants(model, editor);
312
+ }
313
+
314
+ abstract toString(): string;
315
+ }
316
+
317
+ class InsertTextCommand extends EditorCommand {
318
+ readonly name = "insert-text" as const;
319
+
320
+ constructor(
321
+ private readonly position: number,
322
+ private readonly text: string,
323
+ ) {
324
+ super();
325
+ }
326
+
327
+ check(model: Readonly<EditorModel>): boolean {
328
+ return this.position <= model.value.length;
329
+ }
330
+
331
+ applyModel(model: EditorModel): void {
332
+ setModelValue(model, `${model.value.slice(0, this.position)}${this.text}${model.value.slice(this.position)}`);
333
+ model.selectionStart = Math.min(model.value.length, this.position + this.text.length);
334
+ model.selectionEnd = model.selectionStart;
335
+ }
336
+
337
+ applyReal(editor: RandomizedEditor): void {
338
+ editor.insertText(this.position, this.text);
339
+ }
340
+
341
+ toString(): string {
342
+ return `${this.name}(${this.position}, ${JSON.stringify(this.text)})`;
343
+ }
344
+ }
345
+
346
+ class DeleteTextCommand extends EditorCommand {
347
+ readonly name = "delete-text" as const;
348
+
349
+ constructor(
350
+ private readonly start: number,
351
+ private readonly end: number,
352
+ ) {
353
+ super();
354
+ }
355
+
356
+ check(model: Readonly<EditorModel>): boolean {
357
+ return this.start <= this.end && this.end <= model.value.length;
358
+ }
359
+
360
+ applyModel(model: EditorModel): void {
361
+ setModelValue(model, `${model.value.slice(0, this.start)}${model.value.slice(this.end)}`);
362
+ model.selectionStart = this.start;
363
+ model.selectionEnd = this.start;
364
+ }
365
+
366
+ applyReal(editor: RandomizedEditor): void {
367
+ editor.deleteText(this.start, this.end);
368
+ }
369
+
370
+ toString(): string {
371
+ return `${this.name}(${this.start}, ${this.end})`;
372
+ }
373
+ }
374
+
375
+ class SelectTextCommand extends EditorCommand {
376
+ readonly name = "select-text" as const;
377
+
378
+ constructor(
379
+ private readonly start: number,
380
+ private readonly end: number,
381
+ ) {
382
+ super();
383
+ }
384
+
385
+ check(model: Readonly<EditorModel>): boolean {
386
+ return this.start <= this.end && this.end <= model.value.length;
387
+ }
388
+
389
+ applyModel(model: EditorModel): void {
390
+ model.selectionStart = this.start;
391
+ model.selectionEnd = this.end;
392
+ }
393
+
394
+ applyReal(editor: RandomizedEditor): void {
395
+ editor.selectText(this.start, this.end);
396
+ }
397
+
398
+ toString(): string {
399
+ return `${this.name}(${this.start}, ${this.end})`;
400
+ }
401
+ }
402
+
403
+ class InsertTagCommand extends EditorCommand {
404
+ readonly name = "insert-tag" as const;
405
+
406
+ constructor(
407
+ private readonly start: number,
408
+ private readonly end: number,
409
+ private readonly template: SsmlInsertionTemplate,
410
+ ) {
411
+ super();
412
+ }
413
+
414
+ check(model: Readonly<EditorModel>): boolean {
415
+ return this.start <= this.end && this.end <= model.value.length;
416
+ }
417
+
418
+ applyModel(model: EditorModel): void {
419
+ const result = createInsertionResult(model.value, this.start, this.end, this.template);
420
+ setModelValue(model, result.value);
421
+ model.selectionStart = Math.min(model.value.length, result.selectionStart);
422
+ model.selectionEnd = Math.min(model.value.length, result.selectionEnd);
423
+ }
424
+
425
+ applyReal(editor: RandomizedEditor): void {
426
+ editor.insertTag(this.start, this.end, this.template);
427
+ }
428
+
429
+ toString(): string {
430
+ return `${this.name}(${this.start}, ${this.end}, ${this.template.prefix})`;
431
+ }
432
+ }
433
+
434
+ class FormatXmlCommand extends EditorCommand {
435
+ readonly name = "format-xml" as const;
436
+
437
+ check(): boolean {
438
+ return true;
439
+ }
440
+
441
+ applyModel(model: EditorModel): void {
442
+ setModelValue(model, formatXmlFragment(model.value));
443
+ }
444
+
445
+ applyReal(editor: RandomizedEditor): void {
446
+ editor.formatXml();
447
+ }
448
+
449
+ toString(): string {
450
+ return this.name;
451
+ }
452
+ }
453
+
454
+ class ClearDocumentCommand extends EditorCommand {
455
+ readonly name = "clear-document" as const;
456
+
457
+ check(): boolean {
458
+ return true;
459
+ }
460
+
461
+ applyModel(model: EditorModel): void {
462
+ model.document = clearSsmlDocument(model.document);
463
+ model.value = getEditableText(model.document);
464
+ model.selectionStart = 0;
465
+ model.selectionEnd = 0;
466
+ }
467
+
468
+ applyReal(editor: RandomizedEditor): void {
469
+ editor.clearDocument();
470
+ }
471
+
472
+ toString(): string {
473
+ return this.name;
474
+ }
475
+ }
476
+
477
+ const offsetArbitrary = fc.integer({ min: 0, max: MAX_GENERATED_OFFSET });
478
+ const rangeArbitrary = fc.tuple(offsetArbitrary, offsetArbitrary).map(([first, second]) => ({
479
+ start: Math.min(first, second),
480
+ end: Math.max(first, second),
481
+ }));
482
+
483
+ const commandArbitrary = fc.oneof(
484
+ fc.record({ position: offsetArbitrary, text: insertionTextArbitrary }).map(({ position, text }) => {
485
+ return new InsertTextCommand(position, text);
486
+ }),
487
+ rangeArbitrary.map(({ start, end }) => new DeleteTextCommand(start, end)),
488
+ rangeArbitrary.map(({ start, end }) => new SelectTextCommand(start, end)),
489
+ fc
490
+ .tuple(rangeArbitrary, fc.constantFrom(...TAG_TEMPLATES))
491
+ .map(([{ start, end }, template]) => new InsertTagCommand(start, end, template)),
492
+ fc.constant(new FormatXmlCommand()),
493
+ fc.constant(new ClearDocumentCommand()),
494
+ );
495
+
496
+ let lastOperationLog: string[] = [];
497
+
498
+ test("preserves SSML invariants during model-based randomized editor operations", () => {
499
+ try {
500
+ fc.assert(
501
+ fc.property(fc.commands([commandArbitrary], { maxCommands: OPERATION_COUNT }), (commands) => {
502
+ const model = createModel();
503
+ const editor = new RandomizedEditor();
504
+ lastOperationLog = model.operationLog;
505
+ fc.modelRun(() => ({ model, real: editor }), commands);
506
+ checkInvariants(model, editor);
507
+ }),
508
+ {
509
+ endOnFailure: true,
510
+ numRuns: NUM_RUNS,
511
+ seed: SEED,
512
+ },
513
+ );
514
+ } catch (error) {
515
+ console.error(
516
+ `Random editor invariant failed with seed ${SEED}.\nExecuted operations:\n${lastOperationLog.join("\n") || "<none>"}`,
517
+ );
518
+ throw error;
519
+ }
520
+ });