@ssml-builder-js/ssml-editor-react 2.12.0 → 2.14.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.14.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.14.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,52 @@ 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
+ regions?: readonly string[];
17
+ styles?: readonly string[];
18
+ supportedTags?: readonly string[];
19
+ unsupportedTags?: readonly string[];
20
+ preview?: boolean;
21
+ status?: "ga" | "preview" | "deprecated";
22
+ }
23
+
24
+ export interface VoiceSelectorRenderProps {
25
+ value: string;
26
+ voices: readonly VisualVoiceCatalogEntry[];
27
+ selectedVoice?: VisualVoiceCatalogEntry;
28
+ readOnly: boolean;
29
+ locale: SsmlEditorLocale;
30
+ onChange: (voice: string) => void;
31
+ }
32
+
33
+ export interface VisualInspectorRenderProps {
34
+ document: SsmlDocument;
35
+ element: SsmlElement;
36
+ path: number[];
37
+ readOnly: boolean;
38
+ onChange: (document: SsmlDocument) => void;
39
+ locale: SsmlEditorLocale;
40
+ }
41
+
42
+ export type VisualInspectorRenderer = (props: VisualInspectorRenderProps) => ReactNode;
10
43
 
11
44
  export interface VisualSsmlEditorProps {
12
45
  document: SsmlDocument;
13
46
  readOnly?: boolean;
14
47
  onChange?: (document: SsmlDocument) => void;
15
48
  onPreviewSelection?: (ssml: string) => void;
49
+ locale?: SsmlEditorLocale;
50
+ customInspectors?: Readonly<Record<string, VisualInspectorRenderer>>;
51
+ renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
52
+ voiceCatalog?: readonly VisualVoiceCatalogEntry[];
53
+ voiceLocale?: string;
54
+ voiceRegion?: string;
55
+ voiceStyle?: string;
16
56
  }
17
57
 
18
58
  interface TextLeaf {
@@ -192,20 +232,133 @@ function getElementFields(element: SsmlElement): readonly VisualElementField[] {
192
232
  return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
193
233
  }
194
234
 
235
+ function localizedElementLabel(element: SsmlElement, locale: SsmlEditorLocale): string {
236
+ return SSML_ELEMENT_COPY[locale][elementLabel(element)]?.label ?? elementLabel(element);
237
+ }
238
+
239
+ function filteredVoices(
240
+ voiceCatalog: readonly VisualVoiceCatalogEntry[] | undefined,
241
+ locale: string | undefined,
242
+ region: string | undefined,
243
+ style: string | undefined,
244
+ ): readonly VisualVoiceCatalogEntry[] {
245
+ return (voiceCatalog ?? []).filter((voice) => {
246
+ if (locale && voice.locale.toLowerCase() !== locale.toLowerCase()) return false;
247
+ const regions = [voice.region, ...(voice.regions ?? [])].filter((candidate): candidate is string =>
248
+ Boolean(candidate),
249
+ );
250
+ if (region && regions.length > 0 && !regions.some((candidate) => candidate.toLowerCase() === region.toLowerCase()))
251
+ return false;
252
+ if (style && !voice.styles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase())) return false;
253
+ return true;
254
+ });
255
+ }
256
+
257
+ function VoiceCapabilityMatrix({
258
+ voice,
259
+ locale,
260
+ }: {
261
+ voice: VisualVoiceCatalogEntry;
262
+ locale: SsmlEditorLocale;
263
+ }): ReactElement {
264
+ const status = voice.status ?? (voice.preview ? "preview" : undefined);
265
+ return (
266
+ <div className="ssml-editor-voice-capabilities" data-voice-capabilities="">
267
+ <div>
268
+ {locale === "ja" ? "ステータス" : "Status"}: {status?.toUpperCase() ?? (locale === "ja" ? "GA" : "GA")}
269
+ {status === "preview" || status === "deprecated" ? (
270
+ <span role="status" aria-label={status} className="ssml-editor-voice-warning-badge">
271
+ {status === "preview" ? "⚠ Preview" : "⚠ Deprecated"}
272
+ </span>
273
+ ) : null}
274
+ </div>
275
+ {(voice.regions?.length || voice.region) && (
276
+ <div>
277
+ {locale === "ja" ? "リージョン" : "Regions"}:{" "}
278
+ {[...(voice.regions ?? []), ...(voice.region ? [voice.region] : [])]
279
+ .filter((region, index, all) => all.indexOf(region) === index)
280
+ .join(", ")}
281
+ </div>
282
+ )}
283
+ {voice.supportedTags && voice.supportedTags.length > 0 && (
284
+ <div>
285
+ {locale === "ja" ? "対応タグ" : "Supported tags"}: {voice.supportedTags.join(", ")}
286
+ </div>
287
+ )}
288
+ {voice.unsupportedTags && voice.unsupportedTags.length > 0 && (
289
+ <div className="ssml-editor-voice-unsupported">
290
+ {locale === "ja" ? "非対応タグ" : "Unsupported tags"}: {voice.unsupportedTags.join(", ")}
291
+ </div>
292
+ )}
293
+ </div>
294
+ );
295
+ }
296
+
297
+ function DefaultVoiceSelector({
298
+ value,
299
+ voices,
300
+ selectedVoice,
301
+ readOnly,
302
+ locale,
303
+ onChange,
304
+ }: VoiceSelectorRenderProps): ReactElement {
305
+ return (
306
+ <>
307
+ <select
308
+ aria-label={locale === "ja" ? "音声" : "Voice"}
309
+ value={value}
310
+ disabled={readOnly}
311
+ onChange={(event) => onChange(event.target.value)}
312
+ >
313
+ <option value="">{locale === "ja" ? "音声を選択" : "Select a voice"}</option>
314
+ {voices.map((voice) => (
315
+ <option key={voice.name} value={voice.name}>
316
+ {voice.name}
317
+ {voice.preview || voice.status === "preview" ? " (preview)" : ""}
318
+ </option>
319
+ ))}
320
+ </select>
321
+ {selectedVoice ? <VoiceCapabilityMatrix voice={selectedVoice} locale={locale} /> : null}
322
+ </>
323
+ );
324
+ }
325
+
195
326
  function VisualElementInspector({
196
327
  document,
197
328
  element,
198
329
  path,
199
330
  readOnly,
200
331
  commit,
332
+ locale,
333
+ customInspector,
334
+ renderVoiceSelector,
335
+ voiceCatalog,
336
+ voiceLocale,
337
+ voiceRegion,
338
+ voiceStyle,
201
339
  }: {
202
340
  document: SsmlDocument;
203
341
  element: SsmlElement;
204
342
  path: number[];
205
343
  readOnly: boolean;
206
344
  commit: (document: SsmlDocument) => void;
345
+ locale: SsmlEditorLocale;
346
+ customInspector?: VisualInspectorRenderer;
347
+ renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
348
+ voiceCatalog?: readonly VisualVoiceCatalogEntry[];
349
+ voiceLocale?: string;
350
+ voiceRegion?: string;
351
+ voiceStyle?: string;
207
352
  }): ReactElement {
353
+ if (customInspector) {
354
+ return <>{customInspector({ document, element, path, readOnly, onChange: commit, locale })}</>;
355
+ }
208
356
  const fields = getElementFields(element);
357
+ const availableVoices = filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle);
358
+ const selectedVoice = voiceCatalog?.find(
359
+ (voice) => voice.name === (element.type === "voice" ? element.name : undefined),
360
+ );
361
+ const renderSelector = renderVoiceSelector ?? DefaultVoiceSelector;
209
362
  return (
210
363
  <fieldset>
211
364
  <legend>{`<${elementLabel(element)}>`}</legend>
@@ -218,8 +371,54 @@ function VisualElementInspector({
218
371
  const inputId = `ssml-visual-${field.key}`;
219
372
  return (
220
373
  <label key={field.key} htmlFor={inputId}>
221
- {field.label}
222
- {field.multiline ? (
374
+ {locale === "ja"
375
+ ? ((
376
+ {
377
+ name: "名前",
378
+ effect: "効果",
379
+ rate: "速度",
380
+ pitch: "ピッチ",
381
+ volume: "音量",
382
+ contour: "ピッチ曲線",
383
+ range: "範囲",
384
+ style: "スタイル",
385
+ styleDegree: "スタイル強度",
386
+ role: "役割",
387
+ interpretAs: "解釈",
388
+ alphabet: "アルファベット",
389
+ ph: "発音",
390
+ level: "強調レベル",
391
+ src: "音声 URL",
392
+ desc: "説明",
393
+ clipBegin: "開始位置",
394
+ clipEnd: "終了位置",
395
+ speed: "速度",
396
+ repeatCount: "繰り返し回数",
397
+ repeatDuration: "繰り返し時間",
398
+ soundLevel: "音量レベル",
399
+ mark: "ブックマーク名",
400
+ alias: "別名",
401
+ lang: "言語",
402
+ uri: "辞書 URI",
403
+ typeValue: "無音の種類",
404
+ value: "値",
405
+ id: "埋め込み ID",
406
+ speakerProfileId: "話者プロファイル ID",
407
+ url: "音声変換 URL",
408
+ profile: "プロファイル",
409
+ } as Record<string, string>
410
+ )[field.key] ?? field.label)
411
+ : field.label}
412
+ {field.key === "name" && elementLabel(element) === "voice" && voiceCatalog ? (
413
+ renderSelector({
414
+ value: inputValue,
415
+ voices: availableVoices,
416
+ selectedVoice,
417
+ readOnly,
418
+ locale,
419
+ onChange: (value) => commit(updateOptionalElementProperty(document, path, field.key, value)),
420
+ })
421
+ ) : field.multiline ? (
223
422
  <textarea
224
423
  id={inputId}
225
424
  value={inputValue}
@@ -346,12 +545,23 @@ export function VisualSsmlEditor({
346
545
  readOnly = false,
347
546
  onChange,
348
547
  onPreviewSelection,
548
+ locale = "en",
549
+ customInspectors,
550
+ renderVoiceSelector,
551
+ voiceCatalog,
552
+ voiceLocale,
553
+ voiceRegion,
554
+ voiceStyle,
349
555
  }: VisualSsmlEditorProps): ReactElement {
350
556
  const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
351
557
  const [selection, setSelection] = useState({ start: 0, end: 0 });
352
558
  const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
353
559
  const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
354
560
  const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
561
+ const selectedCustomInspector =
562
+ selectedElement && isElement(selectedElement)
563
+ ? (customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type])
564
+ : undefined;
355
565
  const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
356
566
  const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
357
567
 
@@ -399,33 +609,55 @@ export function VisualSsmlEditor({
399
609
  </ul>
400
610
  </nav>
401
611
  <div className="ssml-editor-visual-form">
402
- {selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
612
+ {selectedCustomInspector && selectedElement && isElement(selectedElement) ? (
613
+ selectedCustomInspector({
614
+ document,
615
+ element: selectedElement,
616
+ path: selectedPath ?? [],
617
+ readOnly,
618
+ onChange: commit,
619
+ locale,
620
+ })
621
+ ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
403
622
  <fieldset>
404
- <legend>Dialog</legend>
405
- <p>Add and edit speaker turns in this dialog.</p>
623
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
624
+ <p>{SSML_ELEMENT_COPY[locale][elementLabel(selectedElement)]?.description}</p>
406
625
  <button
407
626
  type="button"
408
627
  disabled={readOnly}
409
628
  onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
410
629
  >
411
- Add turn
630
+ {locale === "ja" ? "ターンを追加" : "Add turn"}
412
631
  </button>
413
632
  </fieldset>
414
633
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
415
634
  <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
- />
635
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
636
+ <label htmlFor="ssml-visual-turn-voice">
637
+ {locale === "ja" ? "音声" : "Voice"}
638
+ {voiceCatalog ? (
639
+ (renderVoiceSelector ?? DefaultVoiceSelector)({
640
+ value: selectedElement.voice ?? "",
641
+ voices: filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle),
642
+ selectedVoice: voiceCatalog?.find((voice) => voice.name === selectedElement.voice),
643
+ readOnly,
644
+ locale,
645
+ onChange: (value) =>
646
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", value)),
647
+ })
648
+ ) : (
649
+ <input
650
+ id="ssml-visual-turn-voice"
651
+ value={selectedElement.voice ?? ""}
652
+ readOnly={readOnly}
653
+ onChange={(event) =>
654
+ commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
655
+ }
656
+ />
657
+ )}
426
658
  </label>
427
659
  <label>
428
- Speaker
660
+ {locale === "ja" ? "話者" : "Speaker"}
429
661
  <input
430
662
  value={selectedElement.speaker ?? ""}
431
663
  readOnly={readOnly}
@@ -435,7 +667,7 @@ export function VisualSsmlEditor({
435
667
  />
436
668
  </label>
437
669
  <label>
438
- Turn text
670
+ {locale === "ja" ? "発話テキスト" : "Turn text"}
439
671
  <textarea
440
672
  value={
441
673
  selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
@@ -454,9 +686,9 @@ export function VisualSsmlEditor({
454
686
  </fieldset>
455
687
  ) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
456
688
  <fieldset>
457
- <legend>Background audio</legend>
689
+ <legend>{localizedElementLabel(selectedElement, locale)}</legend>
458
690
  <label>
459
- Source URL
691
+ {locale === "ja" ? "音声 URL" : "Source URL"}
460
692
  <input
461
693
  value={selectedElement.src ?? ""}
462
694
  readOnly={readOnly}
@@ -466,7 +698,7 @@ export function VisualSsmlEditor({
466
698
  />
467
699
  </label>
468
700
  <label>
469
- Volume
701
+ {locale === "ja" ? "音量" : "Volume"}
470
702
  <input
471
703
  value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
472
704
  readOnly={readOnly}
@@ -476,7 +708,7 @@ export function VisualSsmlEditor({
476
708
  />
477
709
  </label>
478
710
  <label>
479
- Fade in
711
+ {locale === "ja" ? "フェードイン" : "Fade in"}
480
712
  <input
481
713
  value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
482
714
  readOnly={readOnly}
@@ -486,7 +718,7 @@ export function VisualSsmlEditor({
486
718
  />
487
719
  </label>
488
720
  <label>
489
- Fade out
721
+ {locale === "ja" ? "フェードアウト" : "Fade out"}
490
722
  <input
491
723
  value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
492
724
  readOnly={readOnly}
@@ -503,6 +735,15 @@ export function VisualSsmlEditor({
503
735
  path={selectedPath ?? []}
504
736
  readOnly={readOnly}
505
737
  commit={commit}
738
+ locale={locale}
739
+ customInspector={
740
+ customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type]
741
+ }
742
+ renderVoiceSelector={renderVoiceSelector}
743
+ voiceCatalog={voiceCatalog}
744
+ voiceLocale={voiceLocale}
745
+ voiceRegion={voiceRegion}
746
+ voiceStyle={voiceStyle}
506
747
  />
507
748
  ) : selectedLeaf ? (
508
749
  <>
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,