@ssml-builder-js/ssml-editor-elements 2.8.1 → 2.10.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) {
@@ -1147,6 +1147,11 @@ var SSML_ATTRIBUTE_PRESETS = {
1147
1147
  "mstts:audioduration": {
1148
1148
  value: AUDIO_DURATION_PRESETS
1149
1149
  },
1150
+ "mstts:backgroundaudio": {
1151
+ volume: PROSODY_VOLUME_PRESETS,
1152
+ fadein: AUDIO_DURATION_PRESETS,
1153
+ fadeout: AUDIO_DURATION_PRESETS
1154
+ },
1150
1155
  silence: {
1151
1156
  type: SILENCE_TYPE_PRESETS,
1152
1157
  value: SILENCE_VALUE_PRESETS
@@ -1638,6 +1643,24 @@ var SSML_COMPLETION_SNIPPETS = [
1638
1643
  label: "mstts:audioduration",
1639
1644
  insertText: '<mstts:audioduration value="10s" />'
1640
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
+ },
1641
1664
  {
1642
1665
  label: "sub",
1643
1666
  insertText: `<sub alias="\${1:\u8AAD\u307F}">\${2:\u6F22\u5B57}</sub>`
@@ -2383,6 +2406,63 @@ var SSML_TAG_DEFINITIONS = [
2383
2406
  example: "10s"
2384
2407
  }
2385
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: []
2386
2466
  }
2387
2467
  ];
2388
2468
  var definitionsByName = /* @__PURE__ */ new Map();
@@ -3188,6 +3268,60 @@ section[data-ssml-editor] {
3188
3268
  border-radius: 0.25rem;
3189
3269
  overflow: visible;
3190
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
+ }
3191
3325
  `.trim();
3192
3326
  function injectStyles() {
3193
3327
  if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
@@ -3201,6 +3335,55 @@ function injectStyles() {
3201
3335
  function isDarkTheme(theme) {
3202
3336
  return theme === "vs-dark" || theme.toLowerCase().includes("dark");
3203
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
+ }
3204
3387
  function getMenuPosition(trigger, menu) {
3205
3388
  const bounds = trigger.getBoundingClientRect();
3206
3389
  const margin = 8;
@@ -3222,6 +3405,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3222
3405
  this.toolbarActions = null;
3223
3406
  this.display = null;
3224
3407
  this.editorContainer = null;
3408
+ this.visualContainer = null;
3225
3409
  this.helpPanel = null;
3226
3410
  this.openMenu = null;
3227
3411
  this.openMenuTrigger = null;
@@ -3235,6 +3419,8 @@ var SsmlEditorElement = class extends HTMLElementBase {
3235
3419
  this.documentState = null;
3236
3420
  this.decorationsVisible = false;
3237
3421
  this.helpOpen = false;
3422
+ this.visualSelectedPath = null;
3423
+ this.visualSelection = { start: 0, end: 0 };
3238
3424
  this.handleDocumentPointerDown = (event) => {
3239
3425
  const target = event.target;
3240
3426
  if (this.openMenu && target instanceof Node && !this.openMenu.contains(target) && !this.openMenuTrigger?.contains(target)) {
@@ -3276,6 +3462,12 @@ var SsmlEditorElement = class extends HTMLElementBase {
3276
3462
  set locale(locale) {
3277
3463
  this.setAttribute("locale", locale);
3278
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
+ }
3279
3471
  prepareDocument(value) {
3280
3472
  try {
3281
3473
  this.documentState = parseSsml2(value);
@@ -3318,6 +3510,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3318
3510
  }
3319
3511
  }
3320
3512
  }
3513
+ this.renderVisualEditor();
3321
3514
  return;
3322
3515
  }
3323
3516
  if (name === "theme" && this.monaco) {
@@ -3335,6 +3528,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
3335
3528
  this.renderHelp();
3336
3529
  return;
3337
3530
  }
3531
+ if (name === "edit-mode") {
3532
+ this.updateEditMode();
3533
+ }
3338
3534
  if (name === "show-decorations") {
3339
3535
  this.decorationsVisible = newValue !== null;
3340
3536
  this.updateDecorations();
@@ -3359,7 +3555,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
3359
3555
  display.dataset.ssmlEditorDisplay = "";
3360
3556
  const editorContainer = document.createElement("div");
3361
3557
  editorContainer.className = "ssml-editor-editor";
3362
- display.append(editorContainer);
3558
+ const visualContainer = document.createElement("div");
3559
+ visualContainer.className = "ssml-editor-visual";
3560
+ display.append(editorContainer, visualContainer);
3363
3561
  root.append(toolbar, display);
3364
3562
  this.replaceChildren(root);
3365
3563
  this.root = root;
@@ -3367,8 +3565,11 @@ var SsmlEditorElement = class extends HTMLElementBase {
3367
3565
  this.toolbarActions = toolbarActions;
3368
3566
  this.display = display;
3369
3567
  this.editorContainer = editorContainer;
3568
+ this.visualContainer = visualContainer;
3370
3569
  this.renderToolbar();
3371
3570
  this.renderHelp();
3571
+ this.updateEditMode();
3572
+ this.renderVisualEditor();
3372
3573
  }
3373
3574
  renderToolbar() {
3374
3575
  const toolbar = this.toolbar;
@@ -3412,10 +3613,10 @@ var SsmlEditorElement = class extends HTMLElementBase {
3412
3613
  for (const id of toolbarIds) {
3413
3614
  const group = groupByButtonId.get(id);
3414
3615
  if (previousGroup !== void 0 && group !== previousGroup) {
3415
- const separator = document.createElement("span");
3416
- separator.className = "ssml-editor-toolbar-separator";
3417
- separator.setAttribute("aria-hidden", "true");
3418
- 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);
3419
3620
  }
3420
3621
  previousGroup = group;
3421
3622
  const insertion = insertionById.get(id);
@@ -3427,8 +3628,24 @@ var SsmlEditorElement = class extends HTMLElementBase {
3427
3628
  toolbarActions.append(this.createActionButton(id));
3428
3629
  }
3429
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"));
3430
3635
  this.updateActiveButtons();
3431
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
+ }
3432
3649
  createActionButton(id) {
3433
3650
  const copy = EDITOR_COPY[this.locale];
3434
3651
  const labels = {
@@ -3808,6 +4025,150 @@ var SsmlEditorElement = class extends HTMLElementBase {
3808
4025
  this.root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
3809
4026
  }
3810
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
+ }
3811
4172
  updateActiveButtons() {
3812
4173
  const editor = this.editor;
3813
4174
  const model = this.model;
@@ -3835,6 +4196,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
3835
4196
  return;
3836
4197
  }
3837
4198
  const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
4199
+ this.renderVisualEditor();
3838
4200
  const editor = monacoModule.editor.create(container, {
3839
4201
  model,
3840
4202
  theme: this.theme,
@@ -3933,7 +4295,8 @@ SsmlEditorElement.observedAttributes = [
3933
4295
  "locale",
3934
4296
  "show-toolbar",
3935
4297
  "show-toolbar-labels",
3936
- "show-decorations"
4298
+ "show-decorations",
4299
+ "edit-mode"
3937
4300
  ];
3938
4301
 
3939
4302
  // src/index.ts