@ssml-builder-js/ssml-editor-react 2.11.0 → 2.13.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.11.0",
3
+ "version": "2.13.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.11.0"
55
+ "@ssml-builder-js/ssml-core": "^2.13.0"
56
56
  }
57
57
  }
@@ -40,7 +40,12 @@ import { InsertionPopover } from "./components/popovers/InsertionPopover";
40
40
  import { useSsmlEditorState } from "./hooks/useSsmlEditorState";
41
41
  import { useSsmlMonaco } from "./hooks/useSsmlMonaco";
42
42
  import { editorStyles as styles } from "./styles/editorStyles";
43
- import { VisualSsmlEditor } from "./components/VisualSsmlEditor";
43
+ import {
44
+ VisualSsmlEditor,
45
+ type VisualInspectorRenderer,
46
+ type VisualVoiceCatalogEntry,
47
+ type VoiceSelectorRenderProps,
48
+ } from "./components/VisualSsmlEditor";
44
49
  const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
45
50
  const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence", "mstts:audioduration"]);
46
51
  const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
@@ -764,6 +769,18 @@ export interface SsmlEditorProps {
764
769
  customInsertions?: SsmlEditorCustomInsertionCollection;
765
770
  /** Adds insertion definitions without replacing built-in definitions. */
766
771
  additionalInsertions?: SsmlEditorCustomInsertionCollection;
772
+ /** Replaces the visual inspector for an element type or serialized tag name. */
773
+ customInspectors?: Readonly<Record<string, VisualInspectorRenderer>>;
774
+ /** Replaces the built-in voice catalog selector in visual mode. */
775
+ renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
776
+ /** Voice metadata used by the visual selector. */
777
+ voiceCatalog?: readonly VisualVoiceCatalogEntry[];
778
+ /** Optional locale filter for the visual voice selector. */
779
+ voiceLocale?: string;
780
+ /** Optional region filter for the visual voice selector. */
781
+ voiceRegion?: string;
782
+ /** Optional style filter for the visual voice selector. */
783
+ voiceStyle?: string;
767
784
  /** Candidate style values shown by the built-in emotion insertion. */
768
785
  emotionStyles?: readonly string[];
769
786
  /** Class name applied to the editor container. */
@@ -865,6 +882,12 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
865
882
  insertionGroups,
866
883
  customInsertions,
867
884
  additionalInsertions,
885
+ customInspectors,
886
+ renderVoiceSelector,
887
+ voiceCatalog,
888
+ voiceLocale,
889
+ voiceRegion,
890
+ voiceStyle,
868
891
  emotionStyles,
869
892
  className,
870
893
  style,
@@ -1347,6 +1370,13 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
1347
1370
  readOnly={isReadOnly}
1348
1371
  onChange={commit}
1349
1372
  onPreviewSelection={onPreviewSelection}
1373
+ locale={localeProp ?? languageProp}
1374
+ customInspectors={customInspectors}
1375
+ renderVoiceSelector={renderVoiceSelector}
1376
+ voiceCatalog={voiceCatalog}
1377
+ voiceLocale={voiceLocale}
1378
+ voiceRegion={voiceRegion}
1379
+ voiceStyle={voiceStyle}
1350
1380
  />
1351
1381
  </div>
1352
1382
  ) : (
@@ -1,5 +1,5 @@
1
1
  import { useMemo, useState } from "react";
2
- import type { ReactElement } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
3
  import {
4
4
  buildSsml,
5
5
  validateAzureSsml,
@@ -7,12 +7,48 @@ import {
7
7
  type SsmlElement,
8
8
  type SsmlNode,
9
9
  } from "@ssml-builder-js/ssml-core";
10
+ import { SSML_ELEMENT_COPY, type SsmlEditorLocale } from "../locales";
11
+
12
+ export interface VisualVoiceCatalogEntry {
13
+ name: string;
14
+ locale: string;
15
+ region?: string;
16
+ styles?: readonly string[];
17
+ preview?: boolean;
18
+ status?: "ga" | "preview" | "deprecated";
19
+ }
20
+
21
+ export interface VoiceSelectorRenderProps {
22
+ value: string;
23
+ voices: readonly VisualVoiceCatalogEntry[];
24
+ readOnly: boolean;
25
+ locale: SsmlEditorLocale;
26
+ onChange: (voice: string) => void;
27
+ }
28
+
29
+ export interface VisualInspectorRenderProps {
30
+ document: SsmlDocument;
31
+ element: SsmlElement;
32
+ path: number[];
33
+ readOnly: boolean;
34
+ onChange: (document: SsmlDocument) => void;
35
+ locale: SsmlEditorLocale;
36
+ }
37
+
38
+ export type VisualInspectorRenderer = (props: VisualInspectorRenderProps) => ReactNode;
10
39
 
11
40
  export interface VisualSsmlEditorProps {
12
41
  document: SsmlDocument;
13
42
  readOnly?: boolean;
14
43
  onChange?: (document: SsmlDocument) => void;
15
44
  onPreviewSelection?: (ssml: string) => void;
45
+ locale?: SsmlEditorLocale;
46
+ customInspectors?: Readonly<Record<string, VisualInspectorRenderer>>;
47
+ renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
48
+ voiceCatalog?: readonly VisualVoiceCatalogEntry[];
49
+ voiceLocale?: string;
50
+ voiceRegion?: string;
51
+ voiceStyle?: string;
16
52
  }
17
53
 
18
54
  interface TextLeaf {
@@ -93,7 +129,7 @@ function updateElementText(element: SsmlElement, value: string): SsmlElement {
93
129
  function updateOptionalElementProperty(
94
130
  document: SsmlDocument,
95
131
  path: number[],
96
- property: "voice" | "speaker" | "src" | "volume" | "fadeIn" | "fadeOut",
132
+ property: string,
97
133
  value: string,
98
134
  ): SsmlDocument {
99
135
  return updateElementAtPath(document, path, (element) => {
@@ -104,6 +140,269 @@ function updateOptionalElementProperty(
104
140
  });
105
141
  }
106
142
 
143
+ interface VisualElementField {
144
+ key: string;
145
+ label: string;
146
+ multiline?: boolean;
147
+ }
148
+
149
+ const VISUAL_ELEMENT_FIELDS: Readonly<Record<string, readonly VisualElementField[]>> = {
150
+ voice: [
151
+ { key: "name", label: "Voice name" },
152
+ { key: "effect", label: "Effect" },
153
+ ],
154
+ prosody: [
155
+ { key: "rate", label: "Rate" },
156
+ { key: "pitch", label: "Pitch" },
157
+ { key: "volume", label: "Volume" },
158
+ { key: "contour", label: "Contour" },
159
+ { key: "range", label: "Range" },
160
+ ],
161
+ "express-as": [
162
+ { key: "style", label: "Style" },
163
+ { key: "styleDegree", label: "Style degree" },
164
+ { key: "role", label: "Role" },
165
+ ],
166
+ expressAs: [
167
+ { key: "style", label: "Style" },
168
+ { key: "styleDegree", label: "Style degree" },
169
+ { key: "role", label: "Role" },
170
+ ],
171
+ "mstts:express-as": [
172
+ { key: "style", label: "Style" },
173
+ { key: "styleDegree", label: "Style degree" },
174
+ { key: "role", label: "Role" },
175
+ ],
176
+ "say-as": [
177
+ { key: "interpretAs", label: "Interpret as" },
178
+ { key: "format", label: "Format" },
179
+ { key: "detail", label: "Detail" },
180
+ ],
181
+ sayAs: [
182
+ { key: "interpretAs", label: "Interpret as" },
183
+ { key: "format", label: "Format" },
184
+ { key: "detail", label: "Detail" },
185
+ ],
186
+ phoneme: [
187
+ { key: "alphabet", label: "Alphabet" },
188
+ { key: "ph", label: "Pronunciation" },
189
+ ],
190
+ emphasis: [{ key: "level", label: "Level" }],
191
+ audio: [
192
+ { key: "src", label: "Source URL" },
193
+ { key: "desc", label: "Description" },
194
+ { key: "clipBegin", label: "Clip begin" },
195
+ { key: "clipEnd", label: "Clip end" },
196
+ { key: "speed", label: "Speed" },
197
+ { key: "repeatCount", label: "Repeat count" },
198
+ { key: "repeatDuration", label: "Repeat duration" },
199
+ { key: "soundLevel", label: "Sound level" },
200
+ ],
201
+ mark: [{ key: "name", label: "Mark name" }],
202
+ bookmark: [{ key: "mark", label: "Bookmark name" }],
203
+ sub: [{ key: "alias", label: "Alias" }],
204
+ lang: [{ key: "lang", label: "Language" }],
205
+ lexicon: [{ key: "uri", label: "Lexicon URI" }],
206
+ "mstts:silence": [
207
+ { key: "typeValue", label: "Silence type" },
208
+ { key: "value", label: "Value" },
209
+ ],
210
+ silence: [
211
+ { key: "typeValue", label: "Silence type" },
212
+ { key: "value", label: "Value" },
213
+ ],
214
+ "mstts:audioduration": [{ key: "value", label: "Duration" }],
215
+ "mstts:ttsembedding": [{ key: "speakerProfileId", label: "Speaker profile ID" }],
216
+ "mstts:embedding": [
217
+ { key: "id", label: "Embedding ID" },
218
+ { key: "speakerProfileId", label: "Speaker profile ID" },
219
+ ],
220
+ "mstts:voiceconversion": [
221
+ { key: "url", label: "Source URL" },
222
+ { key: "profile", label: "Profile" },
223
+ { key: "speakerProfileId", label: "Speaker profile ID" },
224
+ ],
225
+ };
226
+
227
+ function getElementFields(element: SsmlElement): readonly VisualElementField[] {
228
+ return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
229
+ }
230
+
231
+ function localizedElementLabel(element: SsmlElement, locale: SsmlEditorLocale): string {
232
+ return SSML_ELEMENT_COPY[locale][elementLabel(element)]?.label ?? elementLabel(element);
233
+ }
234
+
235
+ function filteredVoices(
236
+ voiceCatalog: readonly VisualVoiceCatalogEntry[] | undefined,
237
+ locale: string | undefined,
238
+ region: string | undefined,
239
+ style: string | undefined,
240
+ ): readonly VisualVoiceCatalogEntry[] {
241
+ return (voiceCatalog ?? []).filter((voice) => {
242
+ if (locale && voice.locale.toLowerCase() !== locale.toLowerCase()) return false;
243
+ if (region && voice.region && voice.region.toLowerCase() !== region.toLowerCase()) return false;
244
+ if (style && !voice.styles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase())) return false;
245
+ return true;
246
+ });
247
+ }
248
+
249
+ function DefaultVoiceSelector({ value, voices, readOnly, locale, onChange }: VoiceSelectorRenderProps): ReactElement {
250
+ return (
251
+ <select
252
+ aria-label={locale === "ja" ? "音声" : "Voice"}
253
+ value={value}
254
+ disabled={readOnly}
255
+ onChange={(event) => onChange(event.target.value)}
256
+ >
257
+ <option value="">{locale === "ja" ? "音声を選択" : "Select a voice"}</option>
258
+ {voices.map((voice) => (
259
+ <option key={voice.name} value={voice.name}>
260
+ {voice.name}
261
+ {voice.preview || voice.status === "preview" ? " (preview)" : ""}
262
+ </option>
263
+ ))}
264
+ </select>
265
+ );
266
+ }
267
+
268
+ function VisualElementInspector({
269
+ document,
270
+ element,
271
+ path,
272
+ readOnly,
273
+ commit,
274
+ locale,
275
+ customInspector,
276
+ renderVoiceSelector,
277
+ voiceCatalog,
278
+ voiceLocale,
279
+ voiceRegion,
280
+ voiceStyle,
281
+ }: {
282
+ document: SsmlDocument;
283
+ element: SsmlElement;
284
+ path: number[];
285
+ readOnly: boolean;
286
+ commit: (document: SsmlDocument) => void;
287
+ locale: SsmlEditorLocale;
288
+ customInspector?: VisualInspectorRenderer;
289
+ renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
290
+ voiceCatalog?: readonly VisualVoiceCatalogEntry[];
291
+ voiceLocale?: string;
292
+ voiceRegion?: string;
293
+ voiceStyle?: string;
294
+ }): ReactElement {
295
+ if (customInspector) {
296
+ return <>{customInspector({ document, element, path, readOnly, onChange: commit, locale })}</>;
297
+ }
298
+ const fields = getElementFields(element);
299
+ const availableVoices = filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle);
300
+ const renderSelector = renderVoiceSelector ?? DefaultVoiceSelector;
301
+ return (
302
+ <fieldset>
303
+ <legend>{`<${elementLabel(element)}>`}</legend>
304
+ {fields.length === 0 ? (
305
+ <p>This element is preserved in the visual tree. Edit its attributes in Code mode.</p>
306
+ ) : (
307
+ fields.map((field) => {
308
+ const value = (element as SsmlElement & Record<string, unknown>)[field.key];
309
+ const inputValue = value === undefined ? "" : String(value);
310
+ const inputId = `ssml-visual-${field.key}`;
311
+ return (
312
+ <label key={field.key} htmlFor={inputId}>
313
+ {locale === "ja"
314
+ ? ((
315
+ {
316
+ name: "名前",
317
+ effect: "効果",
318
+ rate: "速度",
319
+ pitch: "ピッチ",
320
+ volume: "音量",
321
+ contour: "ピッチ曲線",
322
+ range: "範囲",
323
+ style: "スタイル",
324
+ styleDegree: "スタイル強度",
325
+ role: "役割",
326
+ interpretAs: "解釈",
327
+ alphabet: "アルファベット",
328
+ ph: "発音",
329
+ level: "強調レベル",
330
+ src: "音声 URL",
331
+ desc: "説明",
332
+ clipBegin: "開始位置",
333
+ clipEnd: "終了位置",
334
+ speed: "速度",
335
+ repeatCount: "繰り返し回数",
336
+ repeatDuration: "繰り返し時間",
337
+ soundLevel: "音量レベル",
338
+ mark: "ブックマーク名",
339
+ alias: "別名",
340
+ lang: "言語",
341
+ uri: "辞書 URI",
342
+ typeValue: "無音の種類",
343
+ value: "値",
344
+ id: "埋め込み ID",
345
+ speakerProfileId: "話者プロファイル ID",
346
+ url: "音声変換 URL",
347
+ profile: "プロファイル",
348
+ } as Record<string, string>
349
+ )[field.key] ?? field.label)
350
+ : field.label}
351
+ {field.key === "name" && elementLabel(element) === "voice" && voiceCatalog ? (
352
+ renderSelector({
353
+ value: inputValue,
354
+ voices: availableVoices,
355
+ readOnly,
356
+ locale,
357
+ onChange: (value) => commit(updateOptionalElementProperty(document, path, field.key, value)),
358
+ })
359
+ ) : field.multiline ? (
360
+ <textarea
361
+ id={inputId}
362
+ value={inputValue}
363
+ readOnly={readOnly}
364
+ onChange={(event) =>
365
+ commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
366
+ }
367
+ />
368
+ ) : (
369
+ <input
370
+ id={inputId}
371
+ value={inputValue}
372
+ readOnly={readOnly}
373
+ onChange={(event) =>
374
+ commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
375
+ }
376
+ />
377
+ )}
378
+ </label>
379
+ );
380
+ })
381
+ )}
382
+ </fieldset>
383
+ );
384
+ }
385
+
386
+ function getElementAncestors(nodes: SsmlNode[], path: number[], ancestors: SsmlElement[] = []): SsmlElement[] {
387
+ const [index, ...rest] = path;
388
+ const node = nodes[index];
389
+ if (!node || !isElement(node)) return ancestors;
390
+ if (rest.length === 0) return ancestors;
391
+ return getElementAncestors(node.children ?? [], rest, [...ancestors, node]);
392
+ }
393
+
394
+ function buildVisualPreview(document: SsmlDocument, leaf: TextLeaf, selection: { start: number; end: number }): string {
395
+ const start = Math.max(0, Math.min(selection.start, leaf.value.length));
396
+ const end = Math.max(start, Math.min(selection.end, leaf.value.length));
397
+ const value = start === end ? leaf.value : leaf.value.slice(start, end);
398
+ const ancestors = getElementAncestors(document.children ?? [], leaf.path);
399
+ const children = ancestors.reduceRight<SsmlNode[]>(
400
+ (current, ancestor) => [{ ...ancestor, children: current }],
401
+ [value],
402
+ );
403
+ return buildSsml({ ...document, children });
404
+ }
405
+
107
406
  function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
108
407
  return updateElementAtPath(document, path, (element) => ({
109
408
  ...element,
@@ -184,12 +483,23 @@ export function VisualSsmlEditor({
184
483
  readOnly = false,
185
484
  onChange,
186
485
  onPreviewSelection,
486
+ locale = "en",
487
+ customInspectors,
488
+ renderVoiceSelector,
489
+ voiceCatalog,
490
+ voiceLocale,
491
+ voiceRegion,
492
+ voiceStyle,
187
493
  }: VisualSsmlEditorProps): ReactElement {
188
494
  const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
189
495
  const [selection, setSelection] = useState({ start: 0, end: 0 });
190
496
  const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
191
497
  const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
192
498
  const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
499
+ const selectedCustomInspector =
500
+ selectedElement && isElement(selectedElement)
501
+ ? (customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type])
502
+ : undefined;
193
503
  const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
194
504
  const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
195
505
 
@@ -237,33 +547,54 @@ export function VisualSsmlEditor({
237
547
  </ul>
238
548
  </nav>
239
549
  <div className="ssml-editor-visual-form">
240
- {selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
550
+ {selectedCustomInspector && selectedElement && isElement(selectedElement) ? (
551
+ selectedCustomInspector({
552
+ document,
553
+ element: selectedElement,
554
+ path: selectedPath ?? [],
555
+ readOnly,
556
+ onChange: commit,
557
+ locale,
558
+ })
559
+ ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
241
560
  <fieldset>
242
- <legend>Dialog</legend>
243
- <p>Add and edit speaker turns in this dialog.</p>
561
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
562
+ <p>{SSML_ELEMENT_COPY[locale][elementLabel(selectedElement)]?.description}</p>
244
563
  <button
245
564
  type="button"
246
565
  disabled={readOnly}
247
566
  onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
248
567
  >
249
- Add turn
568
+ {locale === "ja" ? "ターンを追加" : "Add turn"}
250
569
  </button>
251
570
  </fieldset>
252
571
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
253
572
  <fieldset>
254
- <legend>Dialog turn</legend>
255
- <label>
256
- Voice
257
- <input
258
- value={selectedElement.voice ?? ""}
259
- readOnly={readOnly}
260
- onChange={(event) =>
261
- commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
262
- }
263
- />
573
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
574
+ <label htmlFor="ssml-visual-turn-voice">
575
+ {locale === "ja" ? "音声" : "Voice"}
576
+ {voiceCatalog ? (
577
+ (renderVoiceSelector ?? DefaultVoiceSelector)({
578
+ value: selectedElement.voice ?? "",
579
+ voices: filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle),
580
+ readOnly,
581
+ locale,
582
+ onChange: (value) =>
583
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", value)),
584
+ })
585
+ ) : (
586
+ <input
587
+ id="ssml-visual-turn-voice"
588
+ value={selectedElement.voice ?? ""}
589
+ readOnly={readOnly}
590
+ onChange={(event) =>
591
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
592
+ }
593
+ />
594
+ )}
264
595
  </label>
265
596
  <label>
266
- Speaker
597
+ {locale === "ja" ? "話者" : "Speaker"}
267
598
  <input
268
599
  value={selectedElement.speaker ?? ""}
269
600
  readOnly={readOnly}
@@ -273,7 +604,7 @@ export function VisualSsmlEditor({
273
604
  />
274
605
  </label>
275
606
  <label>
276
- Turn text
607
+ {locale === "ja" ? "発話テキスト" : "Turn text"}
277
608
  <textarea
278
609
  value={
279
610
  selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
@@ -292,9 +623,9 @@ export function VisualSsmlEditor({
292
623
  </fieldset>
293
624
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
294
625
  <fieldset>
295
- <legend>Background audio</legend>
626
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
296
627
  <label>
297
- Source URL
628
+ {locale === "ja" ? "音声 URL" : "Source URL"}
298
629
  <input
299
630
  value={selectedElement.src ?? ""}
300
631
  readOnly={readOnly}
@@ -304,7 +635,7 @@ export function VisualSsmlEditor({
304
635
  />
305
636
  </label>
306
637
  <label>
307
- Volume
638
+ {locale === "ja" ? "音量" : "Volume"}
308
639
  <input
309
640
  value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
310
641
  readOnly={readOnly}
@@ -314,7 +645,7 @@ export function VisualSsmlEditor({
314
645
  />
315
646
  </label>
316
647
  <label>
317
- Fade in
648
+ {locale === "ja" ? "フェードイン" : "Fade in"}
318
649
  <input
319
650
  value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
320
651
  readOnly={readOnly}
@@ -324,7 +655,7 @@ export function VisualSsmlEditor({
324
655
  />
325
656
  </label>
326
657
  <label>
327
- Fade out
658
+ {locale === "ja" ? "フェードアウト" : "Fade out"}
328
659
  <input
329
660
  value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
330
661
  readOnly={readOnly}
@@ -334,6 +665,23 @@ export function VisualSsmlEditor({
334
665
  />
335
666
  </label>
336
667
  </fieldset>
668
+ ) : selectedElement && isElement(selectedElement) ? (
669
+ <VisualElementInspector
670
+ document={document}
671
+ element={selectedElement}
672
+ path={selectedPath ?? []}
673
+ readOnly={readOnly}
674
+ commit={commit}
675
+ locale={locale}
676
+ customInspector={
677
+ customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type]
678
+ }
679
+ renderVoiceSelector={renderVoiceSelector}
680
+ voiceCatalog={voiceCatalog}
681
+ voiceLocale={voiceLocale}
682
+ voiceRegion={voiceRegion}
683
+ voiceStyle={voiceStyle}
684
+ />
337
685
  ) : selectedLeaf ? (
338
686
  <>
339
687
  <label>
@@ -375,7 +723,7 @@ export function VisualSsmlEditor({
375
723
  {onPreviewSelection && (
376
724
  <button
377
725
  type="button"
378
- onClick={() => onPreviewSelection(buildSsml({ ...document, children: [selectedLeaf.value] }))}
726
+ onClick={() => onPreviewSelection(buildVisualPreview(document, selectedLeaf, selection))}
379
727
  >
380
728
  Preview selection
381
729
  </button>
package/src/index.tsx CHANGED
@@ -4,7 +4,13 @@
4
4
 
5
5
  export { SsmlEditor } from "./SsmlEditor";
6
6
  export { VisualSsmlEditor } from "./components/VisualSsmlEditor";
7
- export type { VisualSsmlEditorProps } from "./components/VisualSsmlEditor";
7
+ export type {
8
+ VisualInspectorRenderProps,
9
+ VisualInspectorRenderer,
10
+ VisualSsmlEditorProps,
11
+ VisualVoiceCatalogEntry,
12
+ VoiceSelectorRenderProps,
13
+ } from "./components/VisualSsmlEditor";
8
14
  export type {
9
15
  SsmlEditorButton,
10
16
  SsmlEditorButtonVisibility,
@@ -32,7 +38,7 @@ export type {
32
38
  SsmlInsertionOption,
33
39
  SsmlInsertionTemplate,
34
40
  } from "./SsmlEditor";
35
- export { EDITOR_COPY, INLINE_BADGE_COPY, SSML_HOVER_COPY } from "./locales";
41
+ export { EDITOR_COPY, INLINE_BADGE_COPY, SSML_ELEMENT_COPY, SSML_HOVER_COPY } from "./locales";
36
42
  export type {
37
43
  EditorCopy,
38
44
  InlineBadgeCopy,
@@ -42,6 +48,7 @@ export type {
42
48
  SsmlHoverLocale,
43
49
  SsmlHoverParameterCopy,
44
50
  SsmlHoverTagCopy,
51
+ SsmlElementLocaleCopy,
45
52
  } from "./locales";
46
53
  export {
47
54
  createSsmlEditorInsertionDefinition,