@ssml-builder-js/ssml-editor-elements 2.8.0 → 2.9.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/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/SsmlEditorElement.ts
8
- import { buildSsml as buildSsml2, parseSsml as parseSsml2 } from "@ssml-builder-js/ssml-core";
8
+ import { buildSsml as buildSsml2, parseSsml as parseSsml2, validateAzureSsml } from "@ssml-builder-js/ssml-core";
9
9
 
10
10
  // ../ssml-editor-react/src/clearSsmlDocument.ts
11
11
  function getDocumentChildren(document2) {
@@ -69,6 +69,7 @@ var INTRINSICALLY_EMPTY_ELEMENTS = /* @__PURE__ */ new Set([
69
69
  "lexicon",
70
70
  "mark",
71
71
  "mstts:silence",
72
+ "mstts:audioduration",
72
73
  "mstts:viseme",
73
74
  "silence",
74
75
  "viseme"
@@ -727,6 +728,8 @@ function updateEditableText(document2, value) {
727
728
  // ../ssml-editor-react/src/constants/ssmlPresets.ts
728
729
  var ssmlPresets_exports = {};
729
730
  __export(ssmlPresets_exports, {
731
+ AUDIO_DURATION_DESCRIPTIONS: () => AUDIO_DURATION_DESCRIPTIONS,
732
+ AUDIO_DURATION_PRESETS: () => AUDIO_DURATION_PRESETS,
730
733
  BREAK_STRENGTH_PRESETS: () => BREAK_STRENGTH_PRESETS,
731
734
  BREAK_TIME_DESCRIPTIONS: () => BREAK_TIME_DESCRIPTIONS,
732
735
  BREAK_TIME_PRESETS: () => BREAK_TIME_PRESETS,
@@ -759,6 +762,103 @@ __export(ssmlPresets_exports, {
759
762
  getExpressAsStyleCategory: () => getExpressAsStyleCategory,
760
763
  resolveExpressAsStyles: () => resolveExpressAsStyles
761
764
  });
765
+
766
+ // ../ssml-editor-react/src/constants/azureVoiceStyleMap.generated.ts
767
+ var AZURE_VOICE_STYLE_MAP = {
768
+ "de-DE-ConradNeural": ["cheerful", "sad"],
769
+ "de-DE-KatjaNeural": ["cheerful", "sad"],
770
+ "en-US-AndrewNeural": ["empathetic", "relieved"],
771
+ "en-US-GuyNeural": [
772
+ "angry",
773
+ "cheerful",
774
+ "excited",
775
+ "friendly",
776
+ "hopeful",
777
+ "newscast",
778
+ "sad",
779
+ "shouting",
780
+ "terrified",
781
+ "unfriendly",
782
+ "whispering"
783
+ ],
784
+ "en-US-JennyMultilingualNeural": [
785
+ "cheerful",
786
+ "empathetic",
787
+ "excited",
788
+ "friendly",
789
+ "hopeful",
790
+ "sad",
791
+ "shouting",
792
+ "terrified",
793
+ "unfriendly",
794
+ "whispering"
795
+ ],
796
+ "en-US-JennyNeural": [
797
+ "assistant",
798
+ "chat",
799
+ "customerservice",
800
+ "newscast",
801
+ "cheerful",
802
+ "empathetic",
803
+ "excited",
804
+ "friendly",
805
+ "hopeful",
806
+ "sad",
807
+ "shouting",
808
+ "terrified",
809
+ "unfriendly",
810
+ "whispering"
811
+ ],
812
+ "es-ES-ElviraNeural": [],
813
+ "fil-PH-AngeloNeural": [],
814
+ "fr-FR-DeniseNeural": ["cheerful", "sad"],
815
+ "fr-FR-HenriNeural": ["cheerful", "sad"],
816
+ "id-ID-GadisNeural": [],
817
+ "it-IT-ElsaNeural": ["cheerful", "sad"],
818
+ "ja-JP-KeitaNeural": ["chat"],
819
+ "ja-JP-MayuNeural": ["calm", "cheerful", "sad"],
820
+ "ja-JP-NanamiNeural": ["chat", "customerservice", "cheerful", "whispering", "sad"],
821
+ "ko-KR-SunHiNeural": ["cheerful", "sad"],
822
+ "ms-MY-YasminNeural": [],
823
+ "pt-BR-FranciscaNeural": ["calm"],
824
+ "ru-RU-SvetlanaNeural": ["cheerful", "sad", "angry", "disgruntled", "embarrassed", "fearful"],
825
+ "th-TH-PremwadeeNeural": [],
826
+ "vi-VN-HoaiMyNeural": [],
827
+ "zh-CN-XiaoxiaoNeural": [
828
+ "assistant",
829
+ "chat",
830
+ "customerservice",
831
+ "newscast",
832
+ "cheerful",
833
+ "empathetic",
834
+ "excited",
835
+ "friendly",
836
+ "hopeful",
837
+ "sad",
838
+ "terrified",
839
+ "whispering",
840
+ "poetry-reading",
841
+ "sports_commentary",
842
+ "sports_commentary_excited",
843
+ "story"
844
+ ],
845
+ "zh-CN-YunxiNeural": [
846
+ "narration-relaxed",
847
+ "embarrassed",
848
+ "fearful",
849
+ "sad",
850
+ "disgruntled",
851
+ "serious",
852
+ "angry",
853
+ "depressed",
854
+ "chat",
855
+ "cheerful",
856
+ "assistant"
857
+ ],
858
+ "zh-TW-HsiaoChenNeural": []
859
+ };
860
+
861
+ // ../ssml-editor-react/src/constants/ssmlPresets.ts
762
862
  var SSML_PRESETS = [
763
863
  {
764
864
  id: "basic",
@@ -858,7 +958,7 @@ function getExpressAsStyleCategory(style) {
858
958
  }
859
959
  return "other";
860
960
  }
861
- var VOICE_STYLE_MAP = {
961
+ var LEGACY_VOICE_STYLE_MAP = {
862
962
  "ja-JP-MayuNeural": ["calm", "cheerful", "sad"],
863
963
  "ja-JP-KeitaNeural": ["chat"],
864
964
  "ja-JP-NanamiNeural": ["chat", "customerservice", "cheerful", "whispering", "sad"],
@@ -944,6 +1044,7 @@ var VOICE_STYLE_MAP = {
944
1044
  "de-DE-ConradNeural": ["cheerful", "sad"],
945
1045
  "ru-RU-SvetlanaNeural": ["cheerful", "sad", "angry", "disgruntled", "embarrassed", "fearful"]
946
1046
  };
1047
+ var VOICE_STYLE_MAP = Object.keys(AZURE_VOICE_STYLE_MAP).length > 0 ? AZURE_VOICE_STYLE_MAP : LEGACY_VOICE_STYLE_MAP;
947
1048
  var VOICE_STYLE_MAP_BY_NORMALIZED_NAME = new Map(
948
1049
  Object.entries(VOICE_STYLE_MAP).map(([voiceName, styles]) => [voiceName.toLowerCase(), styles])
949
1050
  );
@@ -986,6 +1087,7 @@ var SAY_AS_PRESETS = [
986
1087
  ];
987
1088
  var LANGUAGE_PRESETS = ["ja-JP", "en-US", "de-DE", "fr-FR"];
988
1089
  var SILENCE_VALUE_PRESETS = ["300ms", "500ms", "1s"];
1090
+ var AUDIO_DURATION_PRESETS = ["5s", "10s", "30s"];
989
1091
  var SILENCE_TYPE_PRESETS = [
990
1092
  "Leading",
991
1093
  "Tailing",
@@ -994,6 +1096,11 @@ var SILENCE_TYPE_PRESETS = [
994
1096
  "Semicolon",
995
1097
  "Enumerationcomma"
996
1098
  ];
1099
+ var AUDIO_DURATION_DESCRIPTIONS = {
1100
+ "5s": { ja: "5\u79D2", en: "5 seconds" },
1101
+ "10s": { ja: "10\u79D2", en: "10 seconds" },
1102
+ "30s": { ja: "30\u79D2", en: "30 seconds" }
1103
+ };
997
1104
  var PHONEME_ALPHABET_PRESETS = ["ipa", "sapi", "ups", "x-sampa"];
998
1105
  var VISEME_TYPE_PRESETS = ["redlips_front", "FacialExpression"];
999
1106
  var SSML_ATTRIBUTE_PRESETS = {
@@ -1037,6 +1144,14 @@ var SSML_ATTRIBUTE_PRESETS = {
1037
1144
  type: SILENCE_TYPE_PRESETS,
1038
1145
  value: SILENCE_VALUE_PRESETS
1039
1146
  },
1147
+ "mstts:audioduration": {
1148
+ value: AUDIO_DURATION_PRESETS
1149
+ },
1150
+ "mstts:backgroundaudio": {
1151
+ volume: PROSODY_VOLUME_PRESETS,
1152
+ fadein: AUDIO_DURATION_PRESETS,
1153
+ fadeout: AUDIO_DURATION_PRESETS
1154
+ },
1040
1155
  silence: {
1041
1156
  type: SILENCE_TYPE_PRESETS,
1042
1157
  value: SILENCE_VALUE_PRESETS
@@ -1524,6 +1639,28 @@ var SSML_COMPLETION_SNIPPETS = [
1524
1639
  label: "mstts:express-as",
1525
1640
  insertText: `<mstts:express-as style="cheerful">\${1:text}</mstts:express-as>`
1526
1641
  },
1642
+ {
1643
+ label: "mstts:audioduration",
1644
+ insertText: '<mstts:audioduration value="10s" />'
1645
+ },
1646
+ {
1647
+ label: "mstts:dialog",
1648
+ insertText: `<mstts:dialog>
1649
+ <mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>
1650
+ </mstts:dialog>`
1651
+ },
1652
+ {
1653
+ label: "mstts:turn",
1654
+ insertText: `<mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>`
1655
+ },
1656
+ {
1657
+ label: "mstts:backgroundaudio",
1658
+ insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:-3dB}" />`
1659
+ },
1660
+ {
1661
+ label: "mstts:ttsembedding",
1662
+ insertText: `<mstts:ttsembedding>\${1:text}</mstts:ttsembedding>`
1663
+ },
1527
1664
  {
1528
1665
  label: "sub",
1529
1666
  insertText: `<sub alias="\${1:\u8AAD\u307F}">\${2:\u6F22\u5B57}</sub>`
@@ -1791,6 +1928,13 @@ var SSML_HOVER_COPY = {
1791
1928
  parameters: {
1792
1929
  type: { title: "type", description: "\u30D3\u30BC\u30FC\u30E0\u30A4\u30D9\u30F3\u30C8\u306E\u5F62\u5F0F\u3002" }
1793
1930
  }
1931
+ },
1932
+ "mstts:audioduration": {
1933
+ title: "\u97F3\u58F0\u9577",
1934
+ description: "\u5408\u6210\u97F3\u58F0\u306E\u76EE\u6A19\u6642\u9593\u3092\u6307\u5B9A\u3057\u307E\u3059\u3002",
1935
+ parameters: {
1936
+ value: { title: "value", description: "\u76EE\u6A19\u6642\u9593\u3002\u4F8B: `10s`\u3001`5000ms`\u3001`00:00:10`\u3002" }
1937
+ }
1794
1938
  }
1795
1939
  }
1796
1940
  },
@@ -1948,6 +2092,13 @@ var SSML_HOVER_COPY = {
1948
2092
  parameters: {
1949
2093
  type: { title: "type", description: "The viseme event format." }
1950
2094
  }
2095
+ },
2096
+ "mstts:audioduration": {
2097
+ title: "Audio duration",
2098
+ description: "Sets the target duration of synthesized audio.",
2099
+ parameters: {
2100
+ value: { title: "value", description: "The target duration, such as `10s`, `5000ms`, or `00:00:10`." }
2101
+ }
1951
2102
  }
1952
2103
  }
1953
2104
  }
@@ -2244,6 +2395,74 @@ var SSML_TAG_DEFINITIONS = [
2244
2395
  values: VISEME_TYPE_PRESETS2
2245
2396
  }
2246
2397
  ]
2398
+ },
2399
+ {
2400
+ name: "mstts:audioduration",
2401
+ description: "Sets the target duration of synthesized audio.",
2402
+ parameters: [
2403
+ {
2404
+ name: "value",
2405
+ description: "The target duration, such as `10s`, `5000ms`, or `00:00:10`.",
2406
+ example: "10s"
2407
+ }
2408
+ ]
2409
+ },
2410
+ {
2411
+ name: "mstts:dialog",
2412
+ description: "Groups multiple Azure dialog turns that can use different voices.",
2413
+ parameters: []
2414
+ },
2415
+ {
2416
+ name: "mstts:turn",
2417
+ description: "Adds one dialog turn using the required Azure voice name.",
2418
+ parameters: [
2419
+ {
2420
+ name: "voice",
2421
+ description: "The Azure voice used for this turn, such as `en-US-JennyNeural`.",
2422
+ example: "en-US-JennyNeural"
2423
+ }
2424
+ ]
2425
+ },
2426
+ {
2427
+ name: "mstts:backgroundaudio",
2428
+ description: "Plays background audio while speech is synthesized.",
2429
+ parameters: [
2430
+ {
2431
+ name: "src",
2432
+ description: "An absolute HTTP(S) URL for the background audio file.",
2433
+ example: "https://example.com/music.mp3"
2434
+ },
2435
+ {
2436
+ name: "volume",
2437
+ description: "The background audio volume, for example `-3dB` or `medium`.",
2438
+ example: "-3dB"
2439
+ },
2440
+ {
2441
+ name: "fadein",
2442
+ description: "The fade-in duration, for example `1s`.",
2443
+ example: "1s"
2444
+ },
2445
+ {
2446
+ name: "fadeout",
2447
+ description: "The fade-out duration, for example `500ms`.",
2448
+ example: "500ms"
2449
+ }
2450
+ ]
2451
+ },
2452
+ {
2453
+ name: "mstts:ttsembedding",
2454
+ description: "Embeds custom voice or speaker profile metadata for Azure Speech.",
2455
+ parameters: []
2456
+ },
2457
+ {
2458
+ name: "mstts:embedding",
2459
+ description: "Specifies embedding metadata for custom voice scenarios.",
2460
+ parameters: []
2461
+ },
2462
+ {
2463
+ name: "mstts:voiceconversion",
2464
+ description: "Specifies voice conversion metadata for custom voice scenarios.",
2465
+ parameters: []
2247
2466
  }
2248
2467
  ];
2249
2468
  var definitionsByName = /* @__PURE__ */ new Map();
@@ -2785,13 +3004,34 @@ var SSML_INSERTIONS = [
2785
3004
  suffix: "",
2786
3005
  mode: "insert"
2787
3006
  })
3007
+ },
3008
+ {
3009
+ id: "mstts:audioduration",
3010
+ icon: "\u25F7",
3011
+ tagName: "mstts:audioduration",
3012
+ selfClosing: true,
3013
+ labels: { ja: "\u97F3\u58F0\u9577", en: "Audio duration" },
3014
+ descriptions: {
3015
+ ja: "\u5408\u6210\u97F3\u58F0\u306E\u76EE\u6A19\u6642\u9593\u3092\u6307\u5B9A\u3057\u307E\u3059\u3002",
3016
+ en: "Sets the target duration of synthesized audio."
3017
+ },
3018
+ parameterDescription: {
3019
+ ja: "\u76EE\u6A19\u6642\u9593\u3092\u9078\u629E\u3057\u307E\u3059\u3002",
3020
+ en: "Selects the target duration."
3021
+ },
3022
+ options: createInsertionOptions(AUDIO_DURATION_PRESETS, AUDIO_DURATION_DESCRIPTIONS),
3023
+ createTemplate: (value) => ({
3024
+ prefix: `<mstts:audioduration value="${value}"/>`,
3025
+ suffix: "",
3026
+ mode: "insert"
3027
+ })
2788
3028
  }
2789
3029
  ];
2790
3030
  var DEFAULT_INSERTION_GROUPS = [
2791
3031
  {
2792
3032
  id: "pauses",
2793
3033
  labels: { ja: "\u9593\u30FB\u7121\u97F3", en: "Pauses" },
2794
- insertionIds: ["break", "mstts:silence"]
3034
+ insertionIds: ["break", "mstts:silence", "mstts:audioduration"]
2795
3035
  },
2796
3036
  {
2797
3037
  id: "prosody",
@@ -3028,6 +3268,60 @@ section[data-ssml-editor] {
3028
3268
  border-radius: 0.25rem;
3029
3269
  overflow: visible;
3030
3270
  }
3271
+ [data-ssml-editor] .ssml-editor-visual {
3272
+ display: grid;
3273
+ gap: 0.75rem;
3274
+ min-height: 8rem;
3275
+ padding: 0.75rem;
3276
+ border: 1px solid var(--ssml-editor-control-border);
3277
+ border-radius: 0.25rem;
3278
+ }
3279
+ [data-ssml-editor] .ssml-editor-visual-layout {
3280
+ display: grid;
3281
+ grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
3282
+ gap: 1rem;
3283
+ }
3284
+ [data-ssml-editor] .ssml-editor-visual-tree ul {
3285
+ margin: 0;
3286
+ padding-left: 1.25rem;
3287
+ }
3288
+ [data-ssml-editor] .ssml-editor-visual button {
3289
+ padding: 0.35rem 0.5rem;
3290
+ border: 1px solid var(--ssml-editor-control-border);
3291
+ border-radius: 0.25rem;
3292
+ color: var(--ssml-editor-color);
3293
+ background: var(--ssml-editor-control-bg);
3294
+ cursor: pointer;
3295
+ }
3296
+ [data-ssml-editor] .ssml-editor-visual button[data-selected="true"] {
3297
+ border-color: var(--ssml-editor-active-border);
3298
+ background: var(--ssml-editor-active-bg);
3299
+ }
3300
+ [data-ssml-editor] .ssml-editor-visual textarea {
3301
+ box-sizing: border-box;
3302
+ width: 100%;
3303
+ min-height: 6rem;
3304
+ padding: 0.5rem;
3305
+ color: var(--ssml-editor-color);
3306
+ background: var(--ssml-editor-control-bg);
3307
+ border: 1px solid var(--ssml-editor-control-border);
3308
+ border-radius: 0.25rem;
3309
+ font: inherit;
3310
+ }
3311
+ [data-ssml-editor] .ssml-editor-visual-actions,
3312
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb {
3313
+ display: flex;
3314
+ flex-wrap: wrap;
3315
+ gap: 0.4rem;
3316
+ align-items: center;
3317
+ }
3318
+ [data-ssml-editor] .ssml-editor-visual-errors {
3319
+ padding: 0.5rem;
3320
+ color: #b91c1c;
3321
+ background: #fef2f2;
3322
+ border: 1px solid #b91c1c;
3323
+ border-radius: 0.25rem;
3324
+ }
3031
3325
  `.trim();
3032
3326
  function injectStyles() {
3033
3327
  if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
@@ -3041,6 +3335,55 @@ function injectStyles() {
3041
3335
  function isDarkTheme(theme) {
3042
3336
  return theme === "vs-dark" || theme.toLowerCase().includes("dark");
3043
3337
  }
3338
+ function isElement(node) {
3339
+ return typeof node !== "string" && node.type !== "text";
3340
+ }
3341
+ function visualElementName(element) {
3342
+ return element.type === "custom" || element.type === "element" ? element.name : element.type;
3343
+ }
3344
+ function collectVisualTextLeaves(nodes, path = [], ancestors = []) {
3345
+ return nodes.flatMap((node, index) => {
3346
+ const currentPath = [...path, index];
3347
+ if (typeof node === "string") return [{ path: currentPath, value: node, ancestors }];
3348
+ if (node.type === "text") return [{ path: currentPath, value: node.value, ancestors }];
3349
+ return collectVisualTextLeaves(node.children ?? [], currentPath, [...ancestors, visualElementName(node)]);
3350
+ });
3351
+ }
3352
+ function updateVisualNodes(nodes, path, update) {
3353
+ if (path.length === 0) return nodes;
3354
+ const [index, ...rest] = path;
3355
+ return nodes.flatMap((node, nodeIndex) => {
3356
+ if (nodeIndex !== index) return [node];
3357
+ if (rest.length === 0) {
3358
+ const next = update(node);
3359
+ return Array.isArray(next) ? next : [next];
3360
+ }
3361
+ if (!isElement(node)) return [node];
3362
+ return [{ ...node, children: updateVisualNodes(node.children ?? [], rest, update) }];
3363
+ });
3364
+ }
3365
+ function updateVisualText(document2, path, value) {
3366
+ return { ...document2, children: updateVisualNodes(document2.children ?? [], path, () => value) };
3367
+ }
3368
+ function wrapVisualText(document2, path, start, end, type, attributes) {
3369
+ return {
3370
+ ...document2,
3371
+ children: updateVisualNodes(document2.children ?? [], path, (node) => {
3372
+ const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
3373
+ if (!value || start === end) return node;
3374
+ if (type === "break") {
3375
+ return [value.slice(0, start), { type, attributes, children: [] }, value.slice(start)].filter(
3376
+ (part) => typeof part === "string" ? part.length > 0 : true
3377
+ );
3378
+ }
3379
+ const selected = value.slice(start, end);
3380
+ const wrapper = { type, attributes, children: [selected] };
3381
+ return [value.slice(0, start), wrapper, value.slice(end)].filter(
3382
+ (part) => typeof part === "string" ? part.length > 0 : true
3383
+ );
3384
+ })
3385
+ };
3386
+ }
3044
3387
  function getMenuPosition(trigger, menu) {
3045
3388
  const bounds = trigger.getBoundingClientRect();
3046
3389
  const margin = 8;
@@ -3062,6 +3405,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3062
3405
  this.toolbarActions = null;
3063
3406
  this.display = null;
3064
3407
  this.editorContainer = null;
3408
+ this.visualContainer = null;
3065
3409
  this.helpPanel = null;
3066
3410
  this.openMenu = null;
3067
3411
  this.openMenuTrigger = null;
@@ -3075,6 +3419,8 @@ var SsmlEditorElement = class extends HTMLElementBase {
3075
3419
  this.documentState = null;
3076
3420
  this.decorationsVisible = false;
3077
3421
  this.helpOpen = false;
3422
+ this.visualSelectedPath = null;
3423
+ this.visualSelection = { start: 0, end: 0 };
3078
3424
  this.handleDocumentPointerDown = (event) => {
3079
3425
  const target = event.target;
3080
3426
  if (this.openMenu && target instanceof Node && !this.openMenu.contains(target) && !this.openMenuTrigger?.contains(target)) {
@@ -3116,6 +3462,12 @@ var SsmlEditorElement = class extends HTMLElementBase {
3116
3462
  set locale(locale) {
3117
3463
  this.setAttribute("locale", locale);
3118
3464
  }
3465
+ get editMode() {
3466
+ return this.getAttribute("edit-mode") === "visual" ? "visual" : "code";
3467
+ }
3468
+ set editMode(mode) {
3469
+ this.setAttribute("edit-mode", mode);
3470
+ }
3119
3471
  prepareDocument(value) {
3120
3472
  try {
3121
3473
  this.documentState = parseSsml2(value);
@@ -3158,6 +3510,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3158
3510
  }
3159
3511
  }
3160
3512
  }
3513
+ this.renderVisualEditor();
3161
3514
  return;
3162
3515
  }
3163
3516
  if (name === "theme" && this.monaco) {
@@ -3175,6 +3528,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
3175
3528
  this.renderHelp();
3176
3529
  return;
3177
3530
  }
3531
+ if (name === "edit-mode") {
3532
+ this.updateEditMode();
3533
+ }
3178
3534
  if (name === "show-decorations") {
3179
3535
  this.decorationsVisible = newValue !== null;
3180
3536
  this.updateDecorations();
@@ -3199,7 +3555,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
3199
3555
  display.dataset.ssmlEditorDisplay = "";
3200
3556
  const editorContainer = document.createElement("div");
3201
3557
  editorContainer.className = "ssml-editor-editor";
3202
- display.append(editorContainer);
3558
+ const visualContainer = document.createElement("div");
3559
+ visualContainer.className = "ssml-editor-visual";
3560
+ display.append(editorContainer, visualContainer);
3203
3561
  root.append(toolbar, display);
3204
3562
  this.replaceChildren(root);
3205
3563
  this.root = root;
@@ -3207,8 +3565,11 @@ var SsmlEditorElement = class extends HTMLElementBase {
3207
3565
  this.toolbarActions = toolbarActions;
3208
3566
  this.display = display;
3209
3567
  this.editorContainer = editorContainer;
3568
+ this.visualContainer = visualContainer;
3210
3569
  this.renderToolbar();
3211
3570
  this.renderHelp();
3571
+ this.updateEditMode();
3572
+ this.renderVisualEditor();
3212
3573
  }
3213
3574
  renderToolbar() {
3214
3575
  const toolbar = this.toolbar;
@@ -3252,10 +3613,10 @@ var SsmlEditorElement = class extends HTMLElementBase {
3252
3613
  for (const id of toolbarIds) {
3253
3614
  const group = groupByButtonId.get(id);
3254
3615
  if (previousGroup !== void 0 && group !== previousGroup) {
3255
- const separator = document.createElement("span");
3256
- separator.className = "ssml-editor-toolbar-separator";
3257
- separator.setAttribute("aria-hidden", "true");
3258
- toolbarActions.append(separator);
3616
+ const separator2 = document.createElement("span");
3617
+ separator2.className = "ssml-editor-toolbar-separator";
3618
+ separator2.setAttribute("aria-hidden", "true");
3619
+ toolbarActions.append(separator2);
3259
3620
  }
3260
3621
  previousGroup = group;
3261
3622
  const insertion = insertionById.get(id);
@@ -3267,8 +3628,24 @@ var SsmlEditorElement = class extends HTMLElementBase {
3267
3628
  toolbarActions.append(this.createActionButton(id));
3268
3629
  }
3269
3630
  }
3631
+ const separator = document.createElement("span");
3632
+ separator.className = "ssml-editor-toolbar-separator";
3633
+ separator.setAttribute("aria-hidden", "true");
3634
+ toolbarActions.append(separator, this.createModeButton("visual", "Visual"), this.createModeButton("code", "Code"));
3270
3635
  this.updateActiveButtons();
3271
3636
  }
3637
+ createModeButton(mode, label) {
3638
+ const button = document.createElement("button");
3639
+ button.type = "button";
3640
+ button.className = "ssml-editor-toolbar-button";
3641
+ button.dataset.ssmlEditorButton = `edit-mode-${mode}`;
3642
+ button.setAttribute("aria-pressed", String(this.editMode === mode));
3643
+ button.textContent = label;
3644
+ button.addEventListener("click", () => {
3645
+ this.editMode = mode;
3646
+ });
3647
+ return button;
3648
+ }
3272
3649
  createActionButton(id) {
3273
3650
  const copy = EDITOR_COPY[this.locale];
3274
3651
  const labels = {
@@ -3648,6 +4025,150 @@ var SsmlEditorElement = class extends HTMLElementBase {
3648
4025
  this.root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
3649
4026
  }
3650
4027
  }
4028
+ updateEditMode() {
4029
+ if (this.editorContainer && this.visualContainer) {
4030
+ const visual = this.editMode === "visual";
4031
+ this.editorContainer.hidden = visual;
4032
+ this.visualContainer.hidden = !visual;
4033
+ }
4034
+ for (const mode of ["visual", "code"]) {
4035
+ const button = this.toolbarActions?.querySelector(
4036
+ `[data-ssml-editor-button="edit-mode-${mode}"]`
4037
+ );
4038
+ button?.setAttribute("aria-pressed", String(this.editMode === mode));
4039
+ button?.toggleAttribute("data-active", this.editMode === mode);
4040
+ }
4041
+ this.renderVisualEditor();
4042
+ }
4043
+ renderVisualEditor() {
4044
+ const container = this.visualContainer;
4045
+ if (!container) return;
4046
+ container.replaceChildren();
4047
+ if (!this.documentState) {
4048
+ const error = document.createElement("p");
4049
+ error.className = "ssml-editor-visual-errors";
4050
+ error.textContent = "SSML syntax must be valid before visual editing is available.";
4051
+ container.append(error);
4052
+ return;
4053
+ }
4054
+ const documentState = this.documentState;
4055
+ const leaves = collectVisualTextLeaves(documentState.children ?? []);
4056
+ const selectedLeaf = leaves.find((leaf) => leaf.path.join(".") === this.visualSelectedPath?.join(".")) ?? leaves[0];
4057
+ const breadcrumb = document.createElement("div");
4058
+ breadcrumb.className = "ssml-editor-visual-breadcrumb";
4059
+ breadcrumb.textContent = `<speak>${selectedLeaf ? ` / ${selectedLeaf.ancestors.map((name) => `<${name}>`).join(" / ")}` : ""}`;
4060
+ const clear = document.createElement("button");
4061
+ clear.type = "button";
4062
+ clear.textContent = "Clear parent";
4063
+ clear.disabled = !this.visualSelectedPath;
4064
+ clear.addEventListener("click", () => {
4065
+ this.visualSelectedPath = null;
4066
+ this.renderVisualEditor();
4067
+ });
4068
+ breadcrumb.append(clear);
4069
+ container.append(breadcrumb);
4070
+ const diagnostics = validateAzureSsml(buildSsml2(documentState));
4071
+ if (diagnostics.length > 0) {
4072
+ const errors = document.createElement("div");
4073
+ errors.className = "ssml-editor-visual-errors";
4074
+ errors.setAttribute("role", "alert");
4075
+ for (const diagnostic of diagnostics) {
4076
+ const message = document.createElement("div");
4077
+ message.textContent = diagnostic.message;
4078
+ errors.append(message);
4079
+ }
4080
+ container.append(errors);
4081
+ }
4082
+ const layout = document.createElement("div");
4083
+ layout.className = "ssml-editor-visual-layout";
4084
+ const tree = document.createElement("nav");
4085
+ tree.className = "ssml-editor-visual-tree";
4086
+ tree.setAttribute("aria-label", "SSML structure tree");
4087
+ tree.append(document.createTextNode("Structure"));
4088
+ const treeList = document.createElement("ul");
4089
+ const renderTree = (nodes, parent, parentPath = []) => {
4090
+ nodes.forEach((node, index) => {
4091
+ if (!isElement(node)) return;
4092
+ const path = [...parentPath, index];
4093
+ const item = document.createElement("li");
4094
+ const button = document.createElement("button");
4095
+ button.type = "button";
4096
+ button.textContent = `<${visualElementName(node)}>`;
4097
+ button.dataset.selected = String(path.join(".") === this.visualSelectedPath?.join("."));
4098
+ button.addEventListener("click", () => {
4099
+ this.visualSelectedPath = path;
4100
+ this.renderVisualEditor();
4101
+ });
4102
+ item.append(button);
4103
+ if ((node.children ?? []).some(isElement)) {
4104
+ const childList = document.createElement("ul");
4105
+ renderTree(node.children ?? [], childList, path);
4106
+ item.append(childList);
4107
+ }
4108
+ parent.append(item);
4109
+ });
4110
+ };
4111
+ renderTree(documentState.children ?? [], treeList);
4112
+ tree.append(treeList);
4113
+ layout.append(tree);
4114
+ const form = document.createElement("div");
4115
+ form.className = "ssml-editor-visual-form";
4116
+ if (selectedLeaf) {
4117
+ const label = document.createElement("label");
4118
+ label.append(document.createTextNode("Text"));
4119
+ const textarea = document.createElement("textarea");
4120
+ textarea.value = selectedLeaf.value;
4121
+ textarea.readOnly = this.readonly;
4122
+ textarea.addEventListener("select", () => {
4123
+ this.visualSelection = { start: textarea.selectionStart, end: textarea.selectionEnd };
4124
+ });
4125
+ textarea.addEventListener("input", () => {
4126
+ if (!this.readonly) this.replaceDocument(updateVisualText(documentState, selectedLeaf.path, textarea.value));
4127
+ });
4128
+ label.append(textarea);
4129
+ form.append(label);
4130
+ const actions = document.createElement("div");
4131
+ actions.className = "ssml-editor-visual-actions";
4132
+ const wrappers = [
4133
+ ["Rate", "prosody", { rate: "slow" }],
4134
+ ["Pitch", "prosody", { pitch: "high" }],
4135
+ ["Emotion", "mstts:express-as", { style: "cheerful" }],
4136
+ ["Pause", "break", { time: "500ms" }],
4137
+ ["Pronunciation", "phoneme", { alphabet: "ipa", ph: selectedLeaf.value }]
4138
+ ];
4139
+ for (const [labelText, type, attributes] of wrappers) {
4140
+ const button = document.createElement("button");
4141
+ button.type = "button";
4142
+ button.textContent = labelText;
4143
+ button.disabled = this.readonly;
4144
+ button.addEventListener("click", () => {
4145
+ const start = this.visualSelection.start === this.visualSelection.end ? 0 : this.visualSelection.start;
4146
+ const end = this.visualSelection.start === this.visualSelection.end ? selectedLeaf.value.length : this.visualSelection.end;
4147
+ this.replaceDocument(wrapVisualText(documentState, selectedLeaf.path, start, end, type, attributes));
4148
+ this.renderVisualEditor();
4149
+ });
4150
+ actions.append(button);
4151
+ }
4152
+ const preview = document.createElement("button");
4153
+ preview.type = "button";
4154
+ preview.textContent = "Preview selection";
4155
+ preview.addEventListener("click", () => {
4156
+ this.dispatchEvent(
4157
+ new CustomEvent("preview-selection", {
4158
+ detail: { ssml: buildSsml2({ ...documentState, children: [selectedLeaf.value] }) },
4159
+ bubbles: true,
4160
+ composed: true
4161
+ })
4162
+ );
4163
+ });
4164
+ actions.append(preview);
4165
+ form.append(actions);
4166
+ } else {
4167
+ form.textContent = "Select an element or text node to edit it.";
4168
+ }
4169
+ layout.append(form);
4170
+ container.append(layout);
4171
+ }
3651
4172
  updateActiveButtons() {
3652
4173
  const editor = this.editor;
3653
4174
  const model = this.model;
@@ -3675,6 +4196,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3675
4196
  return;
3676
4197
  }
3677
4198
  const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
4199
+ this.renderVisualEditor();
3678
4200
  const editor = monacoModule.editor.create(container, {
3679
4201
  model,
3680
4202
  theme: this.theme,
@@ -3773,7 +4295,8 @@ SsmlEditorElement.observedAttributes = [
3773
4295
  "locale",
3774
4296
  "show-toolbar",
3775
4297
  "show-toolbar-labels",
3776
- "show-decorations"
4298
+ "show-decorations",
4299
+ "edit-mode"
3777
4300
  ];
3778
4301
 
3779
4302
  // src/index.ts