@ssml-builder-js/ssml-editor-react 2.12.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.12.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.12.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 {
@@ -192,20 +228,76 @@ function getElementFields(element: SsmlElement): readonly VisualElementField[] {
192
228
  return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
193
229
  }
194
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
+
195
268
  function VisualElementInspector({
196
269
  document,
197
270
  element,
198
271
  path,
199
272
  readOnly,
200
273
  commit,
274
+ locale,
275
+ customInspector,
276
+ renderVoiceSelector,
277
+ voiceCatalog,
278
+ voiceLocale,
279
+ voiceRegion,
280
+ voiceStyle,
201
281
  }: {
202
282
  document: SsmlDocument;
203
283
  element: SsmlElement;
204
284
  path: number[];
205
285
  readOnly: boolean;
206
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;
207
294
  }): ReactElement {
295
+ if (customInspector) {
296
+ return <>{customInspector({ document, element, path, readOnly, onChange: commit, locale })}</>;
297
+ }
208
298
  const fields = getElementFields(element);
299
+ const availableVoices = filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle);
300
+ const renderSelector = renderVoiceSelector ?? DefaultVoiceSelector;
209
301
  return (
210
302
  <fieldset>
211
303
  <legend>{`<${elementLabel(element)}>`}</legend>
@@ -218,8 +310,53 @@ function VisualElementInspector({
218
310
  const inputId = `ssml-visual-${field.key}`;
219
311
  return (
220
312
  <label key={field.key} htmlFor={inputId}>
221
- {field.label}
222
- {field.multiline ? (
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 ? (
223
360
  <textarea
224
361
  id={inputId}
225
362
  value={inputValue}
@@ -346,12 +483,23 @@ export function VisualSsmlEditor({
346
483
  readOnly = false,
347
484
  onChange,
348
485
  onPreviewSelection,
486
+ locale = "en",
487
+ customInspectors,
488
+ renderVoiceSelector,
489
+ voiceCatalog,
490
+ voiceLocale,
491
+ voiceRegion,
492
+ voiceStyle,
349
493
  }: VisualSsmlEditorProps): ReactElement {
350
494
  const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
351
495
  const [selection, setSelection] = useState({ start: 0, end: 0 });
352
496
  const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
353
497
  const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
354
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;
355
503
  const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
356
504
  const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
357
505
 
@@ -399,33 +547,54 @@ export function VisualSsmlEditor({
399
547
  </ul>
400
548
  </nav>
401
549
  <div className="ssml-editor-visual-form">
402
- {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" ? (
403
560
  <fieldset>
404
- <legend>Dialog</legend>
405
- <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>
406
563
  <button
407
564
  type="button"
408
565
  disabled={readOnly}
409
566
  onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
410
567
  >
411
- Add turn
568
+ {locale === "ja" ? "ターンを追加" : "Add turn"}
412
569
  </button>
413
570
  </fieldset>
414
571
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
415
572
  <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
- />
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
+ )}
426
595
  </label>
427
596
  <label>
428
- Speaker
597
+ {locale === "ja" ? "話者" : "Speaker"}
429
598
  <input
430
599
  value={selectedElement.speaker ?? ""}
431
600
  readOnly={readOnly}
@@ -435,7 +604,7 @@ export function VisualSsmlEditor({
435
604
  />
436
605
  </label>
437
606
  <label>
438
- Turn text
607
+ {locale === "ja" ? "発話テキスト" : "Turn text"}
439
608
  <textarea
440
609
  value={
441
610
  selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
@@ -454,9 +623,9 @@ export function VisualSsmlEditor({
454
623
  </fieldset>
455
624
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
456
625
  <fieldset>
457
- <legend>Background audio</legend>
626
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
458
627
  <label>
459
- Source URL
628
+ {locale === "ja" ? "音声 URL" : "Source URL"}
460
629
  <input
461
630
  value={selectedElement.src ?? ""}
462
631
  readOnly={readOnly}
@@ -466,7 +635,7 @@ export function VisualSsmlEditor({
466
635
  />
467
636
  </label>
468
637
  <label>
469
- Volume
638
+ {locale === "ja" ? "音量" : "Volume"}
470
639
  <input
471
640
  value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
472
641
  readOnly={readOnly}
@@ -476,7 +645,7 @@ export function VisualSsmlEditor({
476
645
  />
477
646
  </label>
478
647
  <label>
479
- Fade in
648
+ {locale === "ja" ? "フェードイン" : "Fade in"}
480
649
  <input
481
650
  value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
482
651
  readOnly={readOnly}
@@ -486,7 +655,7 @@ export function VisualSsmlEditor({
486
655
  />
487
656
  </label>
488
657
  <label>
489
- Fade out
658
+ {locale === "ja" ? "フェードアウト" : "Fade out"}
490
659
  <input
491
660
  value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
492
661
  readOnly={readOnly}
@@ -503,6 +672,15 @@ export function VisualSsmlEditor({
503
672
  path={selectedPath ?? []}
504
673
  readOnly={readOnly}
505
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}
506
684
  />
507
685
  ) : selectedLeaf ? (
508
686
  <>
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,