@ssml-builder-js/ssml-editor-react 2.10.0 → 2.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/ssml-editor-react",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "React-based SSML editor component",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,6 +52,6 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@ssml-builder-js/ssml-core": "^2.10.0"
55
+ "@ssml-builder-js/ssml-core": "^2.12.0"
56
56
  }
57
57
  }
@@ -60,6 +60,222 @@ function updateTextAtPath(document: SsmlDocument, path: number[], value: string)
60
60
  return { ...document, children: updateNodesAtPath(document.children ?? [], path, () => value) };
61
61
  }
62
62
 
63
+ function getNodeAtPath(nodes: SsmlNode[], path: number[]): SsmlNode | undefined {
64
+ let current: SsmlNode | undefined;
65
+ let currentNodes = nodes;
66
+ for (const index of path) {
67
+ current = currentNodes[index];
68
+ if (current === undefined) return undefined;
69
+ if (!isElement(current)) {
70
+ currentNodes = [];
71
+ } else {
72
+ currentNodes = current.children ?? [];
73
+ }
74
+ }
75
+ return current;
76
+ }
77
+
78
+ function updateElementAtPath(
79
+ document: SsmlDocument,
80
+ path: number[],
81
+ update: (element: SsmlElement) => SsmlElement,
82
+ ): SsmlDocument {
83
+ return {
84
+ ...document,
85
+ children: updateNodesAtPath(document.children ?? [], path, (node) => (isElement(node) ? update(node) : node)),
86
+ };
87
+ }
88
+
89
+ function updateElementText(element: SsmlElement, value: string): SsmlElement {
90
+ return { ...element, children: [value] };
91
+ }
92
+
93
+ function updateOptionalElementProperty(
94
+ document: SsmlDocument,
95
+ path: number[],
96
+ property: string,
97
+ value: string,
98
+ ): SsmlDocument {
99
+ return updateElementAtPath(document, path, (element) => {
100
+ const next = { ...element } as SsmlElement & Record<string, unknown>;
101
+ if (value.trim()) next[property] = value;
102
+ else delete next[property];
103
+ return next;
104
+ });
105
+ }
106
+
107
+ interface VisualElementField {
108
+ key: string;
109
+ label: string;
110
+ multiline?: boolean;
111
+ }
112
+
113
+ const VISUAL_ELEMENT_FIELDS: Readonly<Record<string, readonly VisualElementField[]>> = {
114
+ voice: [
115
+ { key: "name", label: "Voice name" },
116
+ { key: "effect", label: "Effect" },
117
+ ],
118
+ prosody: [
119
+ { key: "rate", label: "Rate" },
120
+ { key: "pitch", label: "Pitch" },
121
+ { key: "volume", label: "Volume" },
122
+ { key: "contour", label: "Contour" },
123
+ { key: "range", label: "Range" },
124
+ ],
125
+ "express-as": [
126
+ { key: "style", label: "Style" },
127
+ { key: "styleDegree", label: "Style degree" },
128
+ { key: "role", label: "Role" },
129
+ ],
130
+ expressAs: [
131
+ { key: "style", label: "Style" },
132
+ { key: "styleDegree", label: "Style degree" },
133
+ { key: "role", label: "Role" },
134
+ ],
135
+ "mstts:express-as": [
136
+ { key: "style", label: "Style" },
137
+ { key: "styleDegree", label: "Style degree" },
138
+ { key: "role", label: "Role" },
139
+ ],
140
+ "say-as": [
141
+ { key: "interpretAs", label: "Interpret as" },
142
+ { key: "format", label: "Format" },
143
+ { key: "detail", label: "Detail" },
144
+ ],
145
+ sayAs: [
146
+ { key: "interpretAs", label: "Interpret as" },
147
+ { key: "format", label: "Format" },
148
+ { key: "detail", label: "Detail" },
149
+ ],
150
+ phoneme: [
151
+ { key: "alphabet", label: "Alphabet" },
152
+ { key: "ph", label: "Pronunciation" },
153
+ ],
154
+ emphasis: [{ key: "level", label: "Level" }],
155
+ audio: [
156
+ { key: "src", label: "Source URL" },
157
+ { key: "desc", label: "Description" },
158
+ { key: "clipBegin", label: "Clip begin" },
159
+ { key: "clipEnd", label: "Clip end" },
160
+ { key: "speed", label: "Speed" },
161
+ { key: "repeatCount", label: "Repeat count" },
162
+ { key: "repeatDuration", label: "Repeat duration" },
163
+ { key: "soundLevel", label: "Sound level" },
164
+ ],
165
+ mark: [{ key: "name", label: "Mark name" }],
166
+ bookmark: [{ key: "mark", label: "Bookmark name" }],
167
+ sub: [{ key: "alias", label: "Alias" }],
168
+ lang: [{ key: "lang", label: "Language" }],
169
+ lexicon: [{ key: "uri", label: "Lexicon URI" }],
170
+ "mstts:silence": [
171
+ { key: "typeValue", label: "Silence type" },
172
+ { key: "value", label: "Value" },
173
+ ],
174
+ silence: [
175
+ { key: "typeValue", label: "Silence type" },
176
+ { key: "value", label: "Value" },
177
+ ],
178
+ "mstts:audioduration": [{ key: "value", label: "Duration" }],
179
+ "mstts:ttsembedding": [{ key: "speakerProfileId", label: "Speaker profile ID" }],
180
+ "mstts:embedding": [
181
+ { key: "id", label: "Embedding ID" },
182
+ { key: "speakerProfileId", label: "Speaker profile ID" },
183
+ ],
184
+ "mstts:voiceconversion": [
185
+ { key: "url", label: "Source URL" },
186
+ { key: "profile", label: "Profile" },
187
+ { key: "speakerProfileId", label: "Speaker profile ID" },
188
+ ],
189
+ };
190
+
191
+ function getElementFields(element: SsmlElement): readonly VisualElementField[] {
192
+ return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
193
+ }
194
+
195
+ function VisualElementInspector({
196
+ document,
197
+ element,
198
+ path,
199
+ readOnly,
200
+ commit,
201
+ }: {
202
+ document: SsmlDocument;
203
+ element: SsmlElement;
204
+ path: number[];
205
+ readOnly: boolean;
206
+ commit: (document: SsmlDocument) => void;
207
+ }): ReactElement {
208
+ const fields = getElementFields(element);
209
+ return (
210
+ <fieldset>
211
+ <legend>{`<${elementLabel(element)}>`}</legend>
212
+ {fields.length === 0 ? (
213
+ <p>This element is preserved in the visual tree. Edit its attributes in Code mode.</p>
214
+ ) : (
215
+ fields.map((field) => {
216
+ const value = (element as SsmlElement & Record<string, unknown>)[field.key];
217
+ const inputValue = value === undefined ? "" : String(value);
218
+ const inputId = `ssml-visual-${field.key}`;
219
+ return (
220
+ <label key={field.key} htmlFor={inputId}>
221
+ {field.label}
222
+ {field.multiline ? (
223
+ <textarea
224
+ id={inputId}
225
+ value={inputValue}
226
+ readOnly={readOnly}
227
+ onChange={(event) =>
228
+ commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
229
+ }
230
+ />
231
+ ) : (
232
+ <input
233
+ id={inputId}
234
+ value={inputValue}
235
+ readOnly={readOnly}
236
+ onChange={(event) =>
237
+ commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
238
+ }
239
+ />
240
+ )}
241
+ </label>
242
+ );
243
+ })
244
+ )}
245
+ </fieldset>
246
+ );
247
+ }
248
+
249
+ function getElementAncestors(nodes: SsmlNode[], path: number[], ancestors: SsmlElement[] = []): SsmlElement[] {
250
+ const [index, ...rest] = path;
251
+ const node = nodes[index];
252
+ if (!node || !isElement(node)) return ancestors;
253
+ if (rest.length === 0) return ancestors;
254
+ return getElementAncestors(node.children ?? [], rest, [...ancestors, node]);
255
+ }
256
+
257
+ function buildVisualPreview(document: SsmlDocument, leaf: TextLeaf, selection: { start: number; end: number }): string {
258
+ const start = Math.max(0, Math.min(selection.start, leaf.value.length));
259
+ const end = Math.max(start, Math.min(selection.end, leaf.value.length));
260
+ const value = start === end ? leaf.value : leaf.value.slice(start, end);
261
+ const ancestors = getElementAncestors(document.children ?? [], leaf.path);
262
+ const children = ancestors.reduceRight<SsmlNode[]>(
263
+ (current, ancestor) => [{ ...ancestor, children: current }],
264
+ [value],
265
+ );
266
+ return buildSsml({ ...document, children });
267
+ }
268
+
269
+ function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
270
+ return updateElementAtPath(document, path, (element) => ({
271
+ ...element,
272
+ children: [
273
+ ...(element.children ?? []),
274
+ { type: "mstts:turn", voice: "en-US-JennyNeural", children: ["New dialog turn"] },
275
+ ],
276
+ }));
277
+ }
278
+
63
279
  function wrapTextAtPath(
64
280
  document: SsmlDocument,
65
281
  path: number[],
@@ -135,6 +351,7 @@ export function VisualSsmlEditor({
135
351
  const [selection, setSelection] = useState({ start: 0, end: 0 });
136
352
  const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
137
353
  const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
354
+ const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
138
355
  const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
139
356
  const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
140
357
 
@@ -182,7 +399,112 @@ export function VisualSsmlEditor({
182
399
  </ul>
183
400
  </nav>
184
401
  <div className="ssml-editor-visual-form">
185
- {selectedLeaf ? (
402
+ {selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
403
+ <fieldset>
404
+ <legend>Dialog</legend>
405
+ <p>Add and edit speaker turns in this dialog.</p>
406
+ <button
407
+ type="button"
408
+ disabled={readOnly}
409
+ onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
410
+ >
411
+ Add turn
412
+ </button>
413
+ </fieldset>
414
+ ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
415
+ <fieldset>
416
+ <legend>Dialog turn</legend>
417
+ <label>
418
+ Voice
419
+ <input
420
+ value={selectedElement.voice ?? ""}
421
+ readOnly={readOnly}
422
+ onChange={(event) =>
423
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
424
+ }
425
+ />
426
+ </label>
427
+ <label>
428
+ Speaker
429
+ <input
430
+ value={selectedElement.speaker ?? ""}
431
+ readOnly={readOnly}
432
+ onChange={(event) =>
433
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "speaker", event.target.value))
434
+ }
435
+ />
436
+ </label>
437
+ <label>
438
+ Turn text
439
+ <textarea
440
+ value={
441
+ selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
442
+ ""
443
+ }
444
+ readOnly={readOnly}
445
+ onChange={(event) =>
446
+ commit(
447
+ updateElementAtPath(document, selectedPath ?? [], (element) =>
448
+ updateElementText(element, event.target.value),
449
+ ),
450
+ )
451
+ }
452
+ />
453
+ </label>
454
+ </fieldset>
455
+ ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
456
+ <fieldset>
457
+ <legend>Background audio</legend>
458
+ <label>
459
+ Source URL
460
+ <input
461
+ value={selectedElement.src ?? ""}
462
+ readOnly={readOnly}
463
+ onChange={(event) =>
464
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "src", event.target.value))
465
+ }
466
+ />
467
+ </label>
468
+ <label>
469
+ Volume
470
+ <input
471
+ value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
472
+ readOnly={readOnly}
473
+ onChange={(event) =>
474
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "volume", event.target.value))
475
+ }
476
+ />
477
+ </label>
478
+ <label>
479
+ Fade in
480
+ <input
481
+ value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
482
+ readOnly={readOnly}
483
+ onChange={(event) =>
484
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeIn", event.target.value))
485
+ }
486
+ />
487
+ </label>
488
+ <label>
489
+ Fade out
490
+ <input
491
+ value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
492
+ readOnly={readOnly}
493
+ onChange={(event) =>
494
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeOut", event.target.value))
495
+ }
496
+ />
497
+ </label>
498
+ </fieldset>
499
+ ) : selectedElement && isElement(selectedElement) ? (
500
+ <VisualElementInspector
501
+ document={document}
502
+ element={selectedElement}
503
+ path={selectedPath ?? []}
504
+ readOnly={readOnly}
505
+ commit={commit}
506
+ />
507
+ ) : selectedLeaf ? (
186
508
  <>
187
509
  <label>
188
510
  Text
@@ -223,7 +545,7 @@ export function VisualSsmlEditor({
223
545
  {onPreviewSelection && (
224
546
  <button
225
547
  type="button"
226
- onClick={() => onPreviewSelection(buildSsml({ ...document, children: [selectedLeaf.value] }))}
548
+ onClick={() => onPreviewSelection(buildVisualPreview(document, selectedLeaf, selection))}
227
549
  >
228
550
  Preview selection
229
551
  </button>
@@ -46,6 +46,9 @@ export const AZURE_VOICE_STYLE_MAP = {
46
46
  ],
47
47
  "es-ES-ElviraNeural": [],
48
48
  "fil-PH-AngeloNeural": [],
49
+ "fil-PH-Angelo:DragonHDLatestNeural": [],
50
+ "fil-PH-BlessicaNeural": [],
51
+ "fil-PH-Blessica:DragonHDLatestNeural": [],
49
52
  "fr-FR-DeniseNeural": ["cheerful", "sad"],
50
53
  "fr-FR-HenriNeural": ["cheerful", "sad"],
51
54
  "id-ID-GadisNeural": [],
@@ -262,6 +262,7 @@ export const SAY_AS_PRESETS = [
262
262
  export const LANGUAGE_PRESETS = ["ja-JP", "en-US", "de-DE", "fr-FR"] as const;
263
263
  export const SILENCE_VALUE_PRESETS = ["300ms", "500ms", "1s"] as const;
264
264
  export const AUDIO_DURATION_PRESETS = ["5s", "10s", "30s"] as const;
265
+ export const BACKGROUND_AUDIO_DURATION_PRESETS = ["0", "500", "1000", "3000", "10000"] as const;
265
266
  export const SILENCE_TYPE_PRESETS = [
266
267
  "Leading",
267
268
  "Tailing",
@@ -324,8 +325,8 @@ export const SSML_ATTRIBUTE_PRESETS = {
324
325
  },
325
326
  "mstts:backgroundaudio": {
326
327
  volume: PROSODY_VOLUME_PRESETS,
327
- fadein: AUDIO_DURATION_PRESETS,
328
- fadeout: AUDIO_DURATION_PRESETS,
328
+ fadein: BACKGROUND_AUDIO_DURATION_PRESETS,
329
+ fadeout: BACKGROUND_AUDIO_DURATION_PRESETS,
329
330
  },
330
331
  silence: {
331
332
  type: SILENCE_TYPE_PRESETS,
@@ -56,7 +56,7 @@ const SSML_COMPLETION_SNIPPETS = [
56
56
  },
57
57
  {
58
58
  label: "mstts:backgroundaudio",
59
- insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:-3dB}" />`,
59
+ insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:70}" fadein="\${3:1000}" fadeout="\${4:1000}" />`,
60
60
  },
61
61
  {
62
62
  label: "mstts:ttsembedding",
package/src/ssmlHover.ts CHANGED
@@ -366,18 +366,18 @@ const SSML_TAG_DEFINITIONS: readonly SsmlTagDefinition[] = [
366
366
  },
367
367
  {
368
368
  name: "volume",
369
- description: "The background audio volume, for example `-3dB` or `medium`.",
370
- example: "-3dB",
369
+ description: "The background audio volume from 0 to 100.",
370
+ example: "70",
371
371
  },
372
372
  {
373
373
  name: "fadein",
374
- description: "The fade-in duration, for example `1s`.",
375
- example: "1s",
374
+ description: "The fade-in duration in milliseconds from 0 to 10000.",
375
+ example: "1000",
376
376
  },
377
377
  {
378
378
  name: "fadeout",
379
- description: "The fade-out duration, for example `500ms`.",
380
- example: "500ms",
379
+ description: "The fade-out duration in milliseconds from 0 to 10000.",
380
+ example: "1000",
381
381
  },
382
382
  ],
383
383
  },
@@ -584,6 +584,146 @@ describe("SsmlEditor props", () => {
584
584
  expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ version: "1.0" }));
585
585
  });
586
586
 
587
+ it("adds and edits Azure dialog turns in the visual editor", async () => {
588
+ const user = userEvent.setup();
589
+ const onChange = vi.fn();
590
+ renderEditor({
591
+ editMode: "visual",
592
+ onChange,
593
+ document: {
594
+ ...editorDocument,
595
+ children: [
596
+ {
597
+ type: "voice",
598
+ name: "en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural",
599
+ children: [
600
+ { type: "mstts:dialog", children: [{ type: "mstts:turn", speaker: "ava", children: ["Hello"] }] },
601
+ ],
602
+ },
603
+ ],
604
+ },
605
+ });
606
+
607
+ await user.click(screen.getByRole("button", { name: "<mstts:dialog>" }));
608
+ await user.click(screen.getByRole("button", { name: "Add turn" }));
609
+ expect(onChange).toHaveBeenLastCalledWith(
610
+ expect.objectContaining({
611
+ children: [
612
+ expect.objectContaining({
613
+ children: [
614
+ expect.objectContaining({
615
+ children: expect.arrayContaining([expect.objectContaining({ type: "mstts:turn" })]),
616
+ }),
617
+ ],
618
+ }),
619
+ ],
620
+ }),
621
+ );
622
+ });
623
+
624
+ it("provides forms for every supported Azure element", async () => {
625
+ const user = userEvent.setup();
626
+ const onChange = vi.fn();
627
+ renderEditor({
628
+ editMode: "visual",
629
+ onChange,
630
+ document: {
631
+ ...editorDocument,
632
+ children: [
633
+ { type: "voice", name: "en-US-JennyNeural", children: ["Hello"] },
634
+ { type: "prosody", rate: "slow", children: ["Hello"] },
635
+ { type: "say-as", interpretAs: "cardinal", children: ["1"] },
636
+ { type: "phoneme", alphabet: "ipa", ph: "həˈloʊ", children: ["Hello"] },
637
+ { type: "audio", src: "https://allowed.test/a.mp3" },
638
+ { type: "mark", name: "chapter-1" },
639
+ { type: "bookmark", mark: "chapter-1" },
640
+ { type: "mstts:silence", typeValue: "Comma", value: "100ms" },
641
+ { type: "mstts:audioduration", value: "10s" },
642
+ { type: "mstts:embedding", id: "speaker-1" },
643
+ { type: "mstts:voiceconversion", url: "https://allowed.test/profile" },
644
+ ],
645
+ },
646
+ });
647
+
648
+ const tree = screen.getByRole("navigation", { name: "SSML structure tree" });
649
+ const expectedFields = [
650
+ ["<voice>", "Voice name"],
651
+ ["<prosody>", "Rate"],
652
+ ["<say-as>", "Interpret as"],
653
+ ["<phoneme>", "Alphabet"],
654
+ ["<audio>", "Source URL"],
655
+ ["<mark>", "Mark name"],
656
+ ["<bookmark>", "Bookmark name"],
657
+ ["<mstts:silence>", "Silence type"],
658
+ ["<mstts:audioduration>", "Duration"],
659
+ ["<mstts:embedding>", "Embedding ID"],
660
+ ["<mstts:voiceconversion>", "Source URL"],
661
+ ];
662
+
663
+ for (const [element, field] of expectedFields) {
664
+ await user.click(within(tree).getByRole("button", { name: element }));
665
+ expect(screen.getByLabelText(field)).toBeTruthy();
666
+ }
667
+
668
+ await user.click(within(tree).getByRole("button", { name: "<mstts:embedding>" }));
669
+ await user.clear(screen.getByLabelText("Embedding ID"));
670
+ await user.type(screen.getByLabelText("Embedding ID"), "speaker-2");
671
+ expect(onChange).toHaveBeenLastCalledWith(
672
+ expect.objectContaining({
673
+ children: expect.arrayContaining([expect.objectContaining({ type: "mstts:embedding", id: "speaker-2" })]),
674
+ }),
675
+ );
676
+ });
677
+
678
+ it("retains voice and prosody context for visual selection preview", async () => {
679
+ const user = userEvent.setup();
680
+ const onPreviewSelection = vi.fn();
681
+ renderEditor({
682
+ editMode: "visual",
683
+ onPreviewSelection,
684
+ document: {
685
+ ...editorDocument,
686
+ children: [
687
+ {
688
+ type: "voice",
689
+ name: "en-US-JennyNeural",
690
+ children: [{ type: "prosody", rate: "slow", children: ["Hello world"] }],
691
+ },
692
+ ],
693
+ },
694
+ });
695
+
696
+ await user.click(screen.getByRole("button", { name: "Preview selection" }));
697
+ expect(onPreviewSelection).toHaveBeenCalledWith(
698
+ '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"><voice name="en-US-JennyNeural"><prosody rate="slow">Hello world</prosody></voice></speak>',
699
+ );
700
+ });
701
+
702
+ it("edits Azure background audio settings in the visual editor", async () => {
703
+ const user = userEvent.setup();
704
+ const onChange = vi.fn();
705
+ renderEditor({
706
+ editMode: "visual",
707
+ onChange,
708
+ document: {
709
+ ...editorDocument,
710
+ children: [
711
+ { type: "mstts:backgroundaudio", src: "https://allowed.test/music.mp3", volume: "50", fadeIn: "500" },
712
+ { type: "voice", name: "en-US-JennyNeural", children: ["Hello"] },
713
+ ],
714
+ },
715
+ });
716
+
717
+ await user.click(screen.getByRole("button", { name: "<mstts:backgroundaudio>" }));
718
+ await user.clear(screen.getByRole("textbox", { name: "Volume" }));
719
+ await user.type(screen.getByRole("textbox", { name: "Volume" }), "70");
720
+ expect(onChange).toHaveBeenLastCalledWith(
721
+ expect.objectContaining({
722
+ children: expect.arrayContaining([expect.objectContaining({ type: "mstts:backgroundaudio", volume: "70" })]),
723
+ }),
724
+ );
725
+ });
726
+
587
727
  it("updates toolbar and popover text when the locale changes", async () => {
588
728
  const user = userEvent.setup();
589
729
  const { rerender } = renderEditor({ locale: "ja", showToolbarLabels: true });