ssml-builder-js 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/elements.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import {
2
+ AUDIO_DURATION_DESCRIPTIONS,
3
+ AUDIO_DURATION_PRESETS,
2
4
  BREAK_TIME_DESCRIPTIONS,
3
5
  BREAK_TIME_PRESETS,
4
6
  EDITOR_COPY,
@@ -32,8 +34,9 @@ import {
32
34
  parseSsml,
33
35
  registerSsmlCompletionProvider,
34
36
  resolveExpressAsStyles,
35
- updateEditableText
36
- } from "./chunk-4RQETJUI.mjs";
37
+ updateEditableText,
38
+ validateAzureSsml
39
+ } from "./chunk-JNJVTEL6.mjs";
37
40
  import "./chunk-6S5ODO6A.mjs";
38
41
 
39
42
  // packages/ssml-editor-react/src/ssmlInsertions.ts
@@ -226,13 +229,34 @@ var SSML_INSERTIONS = [
226
229
  suffix: "",
227
230
  mode: "insert"
228
231
  })
232
+ },
233
+ {
234
+ id: "mstts:audioduration",
235
+ icon: "\u25F7",
236
+ tagName: "mstts:audioduration",
237
+ selfClosing: true,
238
+ labels: { ja: "\u97F3\u58F0\u9577", en: "Audio duration" },
239
+ descriptions: {
240
+ ja: "\u5408\u6210\u97F3\u58F0\u306E\u76EE\u6A19\u6642\u9593\u3092\u6307\u5B9A\u3057\u307E\u3059\u3002",
241
+ en: "Sets the target duration of synthesized audio."
242
+ },
243
+ parameterDescription: {
244
+ ja: "\u76EE\u6A19\u6642\u9593\u3092\u9078\u629E\u3057\u307E\u3059\u3002",
245
+ en: "Selects the target duration."
246
+ },
247
+ options: createInsertionOptions(AUDIO_DURATION_PRESETS, AUDIO_DURATION_DESCRIPTIONS),
248
+ createTemplate: (value) => ({
249
+ prefix: `<mstts:audioduration value="${value}"/>`,
250
+ suffix: "",
251
+ mode: "insert"
252
+ })
229
253
  }
230
254
  ];
231
255
  var DEFAULT_INSERTION_GROUPS = [
232
256
  {
233
257
  id: "pauses",
234
258
  labels: { ja: "\u9593\u30FB\u7121\u97F3", en: "Pauses" },
235
- insertionIds: ["break", "mstts:silence"]
259
+ insertionIds: ["break", "mstts:silence", "mstts:audioduration"]
236
260
  },
237
261
  {
238
262
  id: "prosody",
@@ -469,6 +493,60 @@ section[data-ssml-editor] {
469
493
  border-radius: 0.25rem;
470
494
  overflow: visible;
471
495
  }
496
+ [data-ssml-editor] .ssml-editor-visual {
497
+ display: grid;
498
+ gap: 0.75rem;
499
+ min-height: 8rem;
500
+ padding: 0.75rem;
501
+ border: 1px solid var(--ssml-editor-control-border);
502
+ border-radius: 0.25rem;
503
+ }
504
+ [data-ssml-editor] .ssml-editor-visual-layout {
505
+ display: grid;
506
+ grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
507
+ gap: 1rem;
508
+ }
509
+ [data-ssml-editor] .ssml-editor-visual-tree ul {
510
+ margin: 0;
511
+ padding-left: 1.25rem;
512
+ }
513
+ [data-ssml-editor] .ssml-editor-visual button {
514
+ padding: 0.35rem 0.5rem;
515
+ border: 1px solid var(--ssml-editor-control-border);
516
+ border-radius: 0.25rem;
517
+ color: var(--ssml-editor-color);
518
+ background: var(--ssml-editor-control-bg);
519
+ cursor: pointer;
520
+ }
521
+ [data-ssml-editor] .ssml-editor-visual button[data-selected="true"] {
522
+ border-color: var(--ssml-editor-active-border);
523
+ background: var(--ssml-editor-active-bg);
524
+ }
525
+ [data-ssml-editor] .ssml-editor-visual textarea {
526
+ box-sizing: border-box;
527
+ width: 100%;
528
+ min-height: 6rem;
529
+ padding: 0.5rem;
530
+ color: var(--ssml-editor-color);
531
+ background: var(--ssml-editor-control-bg);
532
+ border: 1px solid var(--ssml-editor-control-border);
533
+ border-radius: 0.25rem;
534
+ font: inherit;
535
+ }
536
+ [data-ssml-editor] .ssml-editor-visual-actions,
537
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb {
538
+ display: flex;
539
+ flex-wrap: wrap;
540
+ gap: 0.4rem;
541
+ align-items: center;
542
+ }
543
+ [data-ssml-editor] .ssml-editor-visual-errors {
544
+ padding: 0.5rem;
545
+ color: #b91c1c;
546
+ background: #fef2f2;
547
+ border: 1px solid #b91c1c;
548
+ border-radius: 0.25rem;
549
+ }
472
550
  `.trim();
473
551
  function injectStyles() {
474
552
  if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
@@ -482,6 +560,55 @@ function injectStyles() {
482
560
  function isDarkTheme(theme) {
483
561
  return theme === "vs-dark" || theme.toLowerCase().includes("dark");
484
562
  }
563
+ function isElement(node) {
564
+ return typeof node !== "string" && node.type !== "text";
565
+ }
566
+ function visualElementName(element) {
567
+ return element.type === "custom" || element.type === "element" ? element.name : element.type;
568
+ }
569
+ function collectVisualTextLeaves(nodes, path = [], ancestors = []) {
570
+ return nodes.flatMap((node, index) => {
571
+ const currentPath = [...path, index];
572
+ if (typeof node === "string") return [{ path: currentPath, value: node, ancestors }];
573
+ if (node.type === "text") return [{ path: currentPath, value: node.value, ancestors }];
574
+ return collectVisualTextLeaves(node.children ?? [], currentPath, [...ancestors, visualElementName(node)]);
575
+ });
576
+ }
577
+ function updateVisualNodes(nodes, path, update) {
578
+ if (path.length === 0) return nodes;
579
+ const [index, ...rest] = path;
580
+ return nodes.flatMap((node, nodeIndex) => {
581
+ if (nodeIndex !== index) return [node];
582
+ if (rest.length === 0) {
583
+ const next = update(node);
584
+ return Array.isArray(next) ? next : [next];
585
+ }
586
+ if (!isElement(node)) return [node];
587
+ return [{ ...node, children: updateVisualNodes(node.children ?? [], rest, update) }];
588
+ });
589
+ }
590
+ function updateVisualText(document2, path, value) {
591
+ return { ...document2, children: updateVisualNodes(document2.children ?? [], path, () => value) };
592
+ }
593
+ function wrapVisualText(document2, path, start, end, type, attributes) {
594
+ return {
595
+ ...document2,
596
+ children: updateVisualNodes(document2.children ?? [], path, (node) => {
597
+ const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
598
+ if (!value || start === end) return node;
599
+ if (type === "break") {
600
+ return [value.slice(0, start), { type, attributes, children: [] }, value.slice(start)].filter(
601
+ (part) => typeof part === "string" ? part.length > 0 : true
602
+ );
603
+ }
604
+ const selected = value.slice(start, end);
605
+ const wrapper = { type, attributes, children: [selected] };
606
+ return [value.slice(0, start), wrapper, value.slice(end)].filter(
607
+ (part) => typeof part === "string" ? part.length > 0 : true
608
+ );
609
+ })
610
+ };
611
+ }
485
612
  function getMenuPosition(trigger, menu) {
486
613
  const bounds = trigger.getBoundingClientRect();
487
614
  const margin = 8;
@@ -503,6 +630,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
503
630
  this.toolbarActions = null;
504
631
  this.display = null;
505
632
  this.editorContainer = null;
633
+ this.visualContainer = null;
506
634
  this.helpPanel = null;
507
635
  this.openMenu = null;
508
636
  this.openMenuTrigger = null;
@@ -516,6 +644,8 @@ var SsmlEditorElement = class extends HTMLElementBase {
516
644
  this.documentState = null;
517
645
  this.decorationsVisible = false;
518
646
  this.helpOpen = false;
647
+ this.visualSelectedPath = null;
648
+ this.visualSelection = { start: 0, end: 0 };
519
649
  this.handleDocumentPointerDown = (event) => {
520
650
  const target = event.target;
521
651
  if (this.openMenu && target instanceof Node && !this.openMenu.contains(target) && !this.openMenuTrigger?.contains(target)) {
@@ -557,6 +687,12 @@ var SsmlEditorElement = class extends HTMLElementBase {
557
687
  set locale(locale) {
558
688
  this.setAttribute("locale", locale);
559
689
  }
690
+ get editMode() {
691
+ return this.getAttribute("edit-mode") === "visual" ? "visual" : "code";
692
+ }
693
+ set editMode(mode) {
694
+ this.setAttribute("edit-mode", mode);
695
+ }
560
696
  prepareDocument(value) {
561
697
  try {
562
698
  this.documentState = parseSsml(value);
@@ -599,6 +735,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
599
735
  }
600
736
  }
601
737
  }
738
+ this.renderVisualEditor();
602
739
  return;
603
740
  }
604
741
  if (name === "theme" && this.monaco) {
@@ -616,6 +753,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
616
753
  this.renderHelp();
617
754
  return;
618
755
  }
756
+ if (name === "edit-mode") {
757
+ this.updateEditMode();
758
+ }
619
759
  if (name === "show-decorations") {
620
760
  this.decorationsVisible = newValue !== null;
621
761
  this.updateDecorations();
@@ -640,7 +780,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
640
780
  display.dataset.ssmlEditorDisplay = "";
641
781
  const editorContainer = document.createElement("div");
642
782
  editorContainer.className = "ssml-editor-editor";
643
- display.append(editorContainer);
783
+ const visualContainer = document.createElement("div");
784
+ visualContainer.className = "ssml-editor-visual";
785
+ display.append(editorContainer, visualContainer);
644
786
  root.append(toolbar, display);
645
787
  this.replaceChildren(root);
646
788
  this.root = root;
@@ -648,8 +790,11 @@ var SsmlEditorElement = class extends HTMLElementBase {
648
790
  this.toolbarActions = toolbarActions;
649
791
  this.display = display;
650
792
  this.editorContainer = editorContainer;
793
+ this.visualContainer = visualContainer;
651
794
  this.renderToolbar();
652
795
  this.renderHelp();
796
+ this.updateEditMode();
797
+ this.renderVisualEditor();
653
798
  }
654
799
  renderToolbar() {
655
800
  const toolbar = this.toolbar;
@@ -693,10 +838,10 @@ var SsmlEditorElement = class extends HTMLElementBase {
693
838
  for (const id of toolbarIds) {
694
839
  const group = groupByButtonId.get(id);
695
840
  if (previousGroup !== void 0 && group !== previousGroup) {
696
- const separator = document.createElement("span");
697
- separator.className = "ssml-editor-toolbar-separator";
698
- separator.setAttribute("aria-hidden", "true");
699
- toolbarActions.append(separator);
841
+ const separator2 = document.createElement("span");
842
+ separator2.className = "ssml-editor-toolbar-separator";
843
+ separator2.setAttribute("aria-hidden", "true");
844
+ toolbarActions.append(separator2);
700
845
  }
701
846
  previousGroup = group;
702
847
  const insertion = insertionById.get(id);
@@ -708,8 +853,24 @@ var SsmlEditorElement = class extends HTMLElementBase {
708
853
  toolbarActions.append(this.createActionButton(id));
709
854
  }
710
855
  }
856
+ const separator = document.createElement("span");
857
+ separator.className = "ssml-editor-toolbar-separator";
858
+ separator.setAttribute("aria-hidden", "true");
859
+ toolbarActions.append(separator, this.createModeButton("visual", "Visual"), this.createModeButton("code", "Code"));
711
860
  this.updateActiveButtons();
712
861
  }
862
+ createModeButton(mode, label) {
863
+ const button = document.createElement("button");
864
+ button.type = "button";
865
+ button.className = "ssml-editor-toolbar-button";
866
+ button.dataset.ssmlEditorButton = `edit-mode-${mode}`;
867
+ button.setAttribute("aria-pressed", String(this.editMode === mode));
868
+ button.textContent = label;
869
+ button.addEventListener("click", () => {
870
+ this.editMode = mode;
871
+ });
872
+ return button;
873
+ }
713
874
  createActionButton(id) {
714
875
  const copy = EDITOR_COPY[this.locale];
715
876
  const labels = {
@@ -1089,6 +1250,150 @@ var SsmlEditorElement = class extends HTMLElementBase {
1089
1250
  this.root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
1090
1251
  }
1091
1252
  }
1253
+ updateEditMode() {
1254
+ if (this.editorContainer && this.visualContainer) {
1255
+ const visual = this.editMode === "visual";
1256
+ this.editorContainer.hidden = visual;
1257
+ this.visualContainer.hidden = !visual;
1258
+ }
1259
+ for (const mode of ["visual", "code"]) {
1260
+ const button = this.toolbarActions?.querySelector(
1261
+ `[data-ssml-editor-button="edit-mode-${mode}"]`
1262
+ );
1263
+ button?.setAttribute("aria-pressed", String(this.editMode === mode));
1264
+ button?.toggleAttribute("data-active", this.editMode === mode);
1265
+ }
1266
+ this.renderVisualEditor();
1267
+ }
1268
+ renderVisualEditor() {
1269
+ const container = this.visualContainer;
1270
+ if (!container) return;
1271
+ container.replaceChildren();
1272
+ if (!this.documentState) {
1273
+ const error = document.createElement("p");
1274
+ error.className = "ssml-editor-visual-errors";
1275
+ error.textContent = "SSML syntax must be valid before visual editing is available.";
1276
+ container.append(error);
1277
+ return;
1278
+ }
1279
+ const documentState = this.documentState;
1280
+ const leaves = collectVisualTextLeaves(documentState.children ?? []);
1281
+ const selectedLeaf = leaves.find((leaf) => leaf.path.join(".") === this.visualSelectedPath?.join(".")) ?? leaves[0];
1282
+ const breadcrumb = document.createElement("div");
1283
+ breadcrumb.className = "ssml-editor-visual-breadcrumb";
1284
+ breadcrumb.textContent = `<speak>${selectedLeaf ? ` / ${selectedLeaf.ancestors.map((name) => `<${name}>`).join(" / ")}` : ""}`;
1285
+ const clear = document.createElement("button");
1286
+ clear.type = "button";
1287
+ clear.textContent = "Clear parent";
1288
+ clear.disabled = !this.visualSelectedPath;
1289
+ clear.addEventListener("click", () => {
1290
+ this.visualSelectedPath = null;
1291
+ this.renderVisualEditor();
1292
+ });
1293
+ breadcrumb.append(clear);
1294
+ container.append(breadcrumb);
1295
+ const diagnostics = validateAzureSsml(buildSsml(documentState));
1296
+ if (diagnostics.length > 0) {
1297
+ const errors = document.createElement("div");
1298
+ errors.className = "ssml-editor-visual-errors";
1299
+ errors.setAttribute("role", "alert");
1300
+ for (const diagnostic of diagnostics) {
1301
+ const message = document.createElement("div");
1302
+ message.textContent = diagnostic.message;
1303
+ errors.append(message);
1304
+ }
1305
+ container.append(errors);
1306
+ }
1307
+ const layout = document.createElement("div");
1308
+ layout.className = "ssml-editor-visual-layout";
1309
+ const tree = document.createElement("nav");
1310
+ tree.className = "ssml-editor-visual-tree";
1311
+ tree.setAttribute("aria-label", "SSML structure tree");
1312
+ tree.append(document.createTextNode("Structure"));
1313
+ const treeList = document.createElement("ul");
1314
+ const renderTree = (nodes, parent, parentPath = []) => {
1315
+ nodes.forEach((node, index) => {
1316
+ if (!isElement(node)) return;
1317
+ const path = [...parentPath, index];
1318
+ const item = document.createElement("li");
1319
+ const button = document.createElement("button");
1320
+ button.type = "button";
1321
+ button.textContent = `<${visualElementName(node)}>`;
1322
+ button.dataset.selected = String(path.join(".") === this.visualSelectedPath?.join("."));
1323
+ button.addEventListener("click", () => {
1324
+ this.visualSelectedPath = path;
1325
+ this.renderVisualEditor();
1326
+ });
1327
+ item.append(button);
1328
+ if ((node.children ?? []).some(isElement)) {
1329
+ const childList = document.createElement("ul");
1330
+ renderTree(node.children ?? [], childList, path);
1331
+ item.append(childList);
1332
+ }
1333
+ parent.append(item);
1334
+ });
1335
+ };
1336
+ renderTree(documentState.children ?? [], treeList);
1337
+ tree.append(treeList);
1338
+ layout.append(tree);
1339
+ const form = document.createElement("div");
1340
+ form.className = "ssml-editor-visual-form";
1341
+ if (selectedLeaf) {
1342
+ const label = document.createElement("label");
1343
+ label.append(document.createTextNode("Text"));
1344
+ const textarea = document.createElement("textarea");
1345
+ textarea.value = selectedLeaf.value;
1346
+ textarea.readOnly = this.readonly;
1347
+ textarea.addEventListener("select", () => {
1348
+ this.visualSelection = { start: textarea.selectionStart, end: textarea.selectionEnd };
1349
+ });
1350
+ textarea.addEventListener("input", () => {
1351
+ if (!this.readonly) this.replaceDocument(updateVisualText(documentState, selectedLeaf.path, textarea.value));
1352
+ });
1353
+ label.append(textarea);
1354
+ form.append(label);
1355
+ const actions = document.createElement("div");
1356
+ actions.className = "ssml-editor-visual-actions";
1357
+ const wrappers = [
1358
+ ["Rate", "prosody", { rate: "slow" }],
1359
+ ["Pitch", "prosody", { pitch: "high" }],
1360
+ ["Emotion", "mstts:express-as", { style: "cheerful" }],
1361
+ ["Pause", "break", { time: "500ms" }],
1362
+ ["Pronunciation", "phoneme", { alphabet: "ipa", ph: selectedLeaf.value }]
1363
+ ];
1364
+ for (const [labelText, type, attributes] of wrappers) {
1365
+ const button = document.createElement("button");
1366
+ button.type = "button";
1367
+ button.textContent = labelText;
1368
+ button.disabled = this.readonly;
1369
+ button.addEventListener("click", () => {
1370
+ const start = this.visualSelection.start === this.visualSelection.end ? 0 : this.visualSelection.start;
1371
+ const end = this.visualSelection.start === this.visualSelection.end ? selectedLeaf.value.length : this.visualSelection.end;
1372
+ this.replaceDocument(wrapVisualText(documentState, selectedLeaf.path, start, end, type, attributes));
1373
+ this.renderVisualEditor();
1374
+ });
1375
+ actions.append(button);
1376
+ }
1377
+ const preview = document.createElement("button");
1378
+ preview.type = "button";
1379
+ preview.textContent = "Preview selection";
1380
+ preview.addEventListener("click", () => {
1381
+ this.dispatchEvent(
1382
+ new CustomEvent("preview-selection", {
1383
+ detail: { ssml: buildSsml({ ...documentState, children: [selectedLeaf.value] }) },
1384
+ bubbles: true,
1385
+ composed: true
1386
+ })
1387
+ );
1388
+ });
1389
+ actions.append(preview);
1390
+ form.append(actions);
1391
+ } else {
1392
+ form.textContent = "Select an element or text node to edit it.";
1393
+ }
1394
+ layout.append(form);
1395
+ container.append(layout);
1396
+ }
1092
1397
  updateActiveButtons() {
1093
1398
  const editor = this.editor;
1094
1399
  const model = this.model;
@@ -1116,6 +1421,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
1116
1421
  return;
1117
1422
  }
1118
1423
  const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
1424
+ this.renderVisualEditor();
1119
1425
  const editor = monacoModule.editor.create(container, {
1120
1426
  model,
1121
1427
  theme: this.theme,
@@ -1214,7 +1520,8 @@ SsmlEditorElement.observedAttributes = [
1214
1520
  "locale",
1215
1521
  "show-toolbar",
1216
1522
  "show-toolbar-labels",
1217
- "show-decorations"
1523
+ "show-decorations",
1524
+ "edit-mode"
1218
1525
  ];
1219
1526
 
1220
1527
  // packages/ssml-editor-elements/src/index.ts