@ssml-builder-js/ssml-editor-elements 2.8.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/ssml-editor-elements",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
4
4
  "description": "Framework-independent SSML editor custom element",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,6 @@
37
37
  "monaco-editor": "^0.56.0"
38
38
  },
39
39
  "dependencies": {
40
- "@ssml-builder-js/ssml-core": "^2.8.1"
40
+ "@ssml-builder-js/ssml-core": "^2.9.0"
41
41
  }
42
42
  }
@@ -1,6 +1,6 @@
1
1
  import type * as monaco from "monaco-editor";
2
- import { buildSsml, parseSsml } from "@ssml-builder-js/ssml-core";
3
- import type { SsmlDocument } from "@ssml-builder-js/ssml-core";
2
+ import { buildSsml, parseSsml, validateAzureSsml } from "@ssml-builder-js/ssml-core";
3
+ import type { SsmlDocument, SsmlElement, SsmlNode } from "@ssml-builder-js/ssml-core";
4
4
  import { clearSsmlDocument } from "../../ssml-editor-react/src/clearSsmlDocument";
5
5
  import { getEditableRegion, getEditableText, updateEditableText } from "../../ssml-editor-react/src/editableSsml";
6
6
  import { formatXmlFragment } from "../../ssml-editor-react/src/formatXml";
@@ -245,6 +245,60 @@ section[data-ssml-editor] {
245
245
  border-radius: 0.25rem;
246
246
  overflow: visible;
247
247
  }
248
+ [data-ssml-editor] .ssml-editor-visual {
249
+ display: grid;
250
+ gap: 0.75rem;
251
+ min-height: 8rem;
252
+ padding: 0.75rem;
253
+ border: 1px solid var(--ssml-editor-control-border);
254
+ border-radius: 0.25rem;
255
+ }
256
+ [data-ssml-editor] .ssml-editor-visual-layout {
257
+ display: grid;
258
+ grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
259
+ gap: 1rem;
260
+ }
261
+ [data-ssml-editor] .ssml-editor-visual-tree ul {
262
+ margin: 0;
263
+ padding-left: 1.25rem;
264
+ }
265
+ [data-ssml-editor] .ssml-editor-visual button {
266
+ padding: 0.35rem 0.5rem;
267
+ border: 1px solid var(--ssml-editor-control-border);
268
+ border-radius: 0.25rem;
269
+ color: var(--ssml-editor-color);
270
+ background: var(--ssml-editor-control-bg);
271
+ cursor: pointer;
272
+ }
273
+ [data-ssml-editor] .ssml-editor-visual button[data-selected="true"] {
274
+ border-color: var(--ssml-editor-active-border);
275
+ background: var(--ssml-editor-active-bg);
276
+ }
277
+ [data-ssml-editor] .ssml-editor-visual textarea {
278
+ box-sizing: border-box;
279
+ width: 100%;
280
+ min-height: 6rem;
281
+ padding: 0.5rem;
282
+ color: var(--ssml-editor-color);
283
+ background: var(--ssml-editor-control-bg);
284
+ border: 1px solid var(--ssml-editor-control-border);
285
+ border-radius: 0.25rem;
286
+ font: inherit;
287
+ }
288
+ [data-ssml-editor] .ssml-editor-visual-actions,
289
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb {
290
+ display: flex;
291
+ flex-wrap: wrap;
292
+ gap: 0.4rem;
293
+ align-items: center;
294
+ }
295
+ [data-ssml-editor] .ssml-editor-visual-errors {
296
+ padding: 0.5rem;
297
+ color: #b91c1c;
298
+ background: #fef2f2;
299
+ border: 1px solid #b91c1c;
300
+ border-radius: 0.25rem;
301
+ }
248
302
  `.trim();
249
303
 
250
304
  function injectStyles(): void {
@@ -262,6 +316,78 @@ function isDarkTheme(theme: SsmlEditorTheme): boolean {
262
316
  return theme === "vs-dark" || theme.toLowerCase().includes("dark");
263
317
  }
264
318
 
319
+ function isElement(node: SsmlNode): node is SsmlElement {
320
+ return typeof node !== "string" && node.type !== "text";
321
+ }
322
+
323
+ function visualElementName(element: SsmlElement): string {
324
+ return element.type === "custom" || element.type === "element" ? element.name : element.type;
325
+ }
326
+
327
+ interface VisualTextLeaf {
328
+ path: number[];
329
+ value: string;
330
+ ancestors: string[];
331
+ }
332
+
333
+ function collectVisualTextLeaves(nodes: SsmlNode[], path: number[] = [], ancestors: string[] = []): VisualTextLeaf[] {
334
+ return nodes.flatMap((node, index) => {
335
+ const currentPath = [...path, index];
336
+ if (typeof node === "string") return [{ path: currentPath, value: node, ancestors }];
337
+ if (node.type === "text") return [{ path: currentPath, value: node.value, ancestors }];
338
+ return collectVisualTextLeaves(node.children ?? [], currentPath, [...ancestors, visualElementName(node)]);
339
+ });
340
+ }
341
+
342
+ function updateVisualNodes(
343
+ nodes: SsmlNode[],
344
+ path: number[],
345
+ update: (node: SsmlNode) => SsmlNode | SsmlNode[],
346
+ ): SsmlNode[] {
347
+ if (path.length === 0) return nodes;
348
+ const [index, ...rest] = path;
349
+ return nodes.flatMap((node, nodeIndex) => {
350
+ if (nodeIndex !== index) return [node];
351
+ if (rest.length === 0) {
352
+ const next = update(node);
353
+ return Array.isArray(next) ? next : [next];
354
+ }
355
+ if (!isElement(node)) return [node];
356
+ return [{ ...node, children: updateVisualNodes(node.children ?? [], rest, update) }];
357
+ });
358
+ }
359
+
360
+ function updateVisualText(document: SsmlDocument, path: number[], value: string): SsmlDocument {
361
+ return { ...document, children: updateVisualNodes(document.children ?? [], path, () => value) };
362
+ }
363
+
364
+ function wrapVisualText(
365
+ document: SsmlDocument,
366
+ path: number[],
367
+ start: number,
368
+ end: number,
369
+ type: SsmlElement["type"],
370
+ attributes: Record<string, string>,
371
+ ): SsmlDocument {
372
+ return {
373
+ ...document,
374
+ children: updateVisualNodes(document.children ?? [], path, (node) => {
375
+ const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
376
+ if (!value || start === end) return node;
377
+ if (type === "break") {
378
+ return [value.slice(0, start), { type, attributes, children: [] } as SsmlElement, value.slice(start)].filter(
379
+ (part) => (typeof part === "string" ? part.length > 0 : true),
380
+ );
381
+ }
382
+ const selected = value.slice(start, end);
383
+ const wrapper = { type, attributes, children: [selected] } as SsmlElement;
384
+ return [value.slice(0, start), wrapper, value.slice(end)].filter((part) =>
385
+ typeof part === "string" ? part.length > 0 : true,
386
+ );
387
+ }),
388
+ };
389
+ }
390
+
265
391
  function getMenuPosition(trigger: HTMLElement, menu: HTMLElement): { top: number; left: number } {
266
392
  const bounds = trigger.getBoundingClientRect();
267
393
  const margin = 8;
@@ -283,6 +409,7 @@ export class SsmlEditorElement extends HTMLElementBase {
283
409
  "show-toolbar",
284
410
  "show-toolbar-labels",
285
411
  "show-decorations",
412
+ "edit-mode",
286
413
  ];
287
414
 
288
415
  private editor: MonacoEditor | null = null;
@@ -293,6 +420,7 @@ export class SsmlEditorElement extends HTMLElementBase {
293
420
  private toolbarActions: HTMLElement | null = null;
294
421
  private display: HTMLElement | null = null;
295
422
  private editorContainer: HTMLDivElement | null = null;
423
+ private visualContainer: HTMLDivElement | null = null;
296
424
  private helpPanel: HTMLElement | null = null;
297
425
  private openMenu: HTMLElement | null = null;
298
426
  private openMenuTrigger: HTMLButtonElement | null = null;
@@ -307,6 +435,8 @@ export class SsmlEditorElement extends HTMLElementBase {
307
435
  private documentState: SsmlDocument | null = null;
308
436
  private decorationsVisible = false;
309
437
  private helpOpen = false;
438
+ private visualSelectedPath: number[] | null = null;
439
+ private visualSelection = { start: 0, end: 0 };
310
440
 
311
441
  get value(): string {
312
442
  return this.valueState ?? this.getAttribute("value") ?? "";
@@ -345,6 +475,14 @@ export class SsmlEditorElement extends HTMLElementBase {
345
475
  this.setAttribute("locale", locale);
346
476
  }
347
477
 
478
+ get editMode(): "code" | "visual" {
479
+ return this.getAttribute("edit-mode") === "visual" ? "visual" : "code";
480
+ }
481
+
482
+ set editMode(mode: "code" | "visual") {
483
+ this.setAttribute("edit-mode", mode);
484
+ }
485
+
348
486
  private prepareDocument(value: string): string {
349
487
  try {
350
488
  this.documentState = parseSsml(value);
@@ -391,6 +529,7 @@ export class SsmlEditorElement extends HTMLElementBase {
391
529
  }
392
530
  }
393
531
  }
532
+ this.renderVisualEditor();
394
533
  return;
395
534
  }
396
535
 
@@ -412,6 +551,10 @@ export class SsmlEditorElement extends HTMLElementBase {
412
551
  return;
413
552
  }
414
553
 
554
+ if (name === "edit-mode") {
555
+ this.updateEditMode();
556
+ }
557
+
415
558
  if (name === "show-decorations") {
416
559
  this.decorationsVisible = newValue !== null;
417
560
  this.updateDecorations();
@@ -439,7 +582,9 @@ export class SsmlEditorElement extends HTMLElementBase {
439
582
  display.dataset.ssmlEditorDisplay = "";
440
583
  const editorContainer = document.createElement("div");
441
584
  editorContainer.className = "ssml-editor-editor";
442
- display.append(editorContainer);
585
+ const visualContainer = document.createElement("div");
586
+ visualContainer.className = "ssml-editor-visual";
587
+ display.append(editorContainer, visualContainer);
443
588
  root.append(toolbar, display);
444
589
  this.replaceChildren(root);
445
590
 
@@ -448,8 +593,11 @@ export class SsmlEditorElement extends HTMLElementBase {
448
593
  this.toolbarActions = toolbarActions;
449
594
  this.display = display;
450
595
  this.editorContainer = editorContainer;
596
+ this.visualContainer = visualContainer;
451
597
  this.renderToolbar();
452
598
  this.renderHelp();
599
+ this.updateEditMode();
600
+ this.renderVisualEditor();
453
601
  }
454
602
 
455
603
  private renderToolbar(): void {
@@ -513,9 +661,26 @@ export class SsmlEditorElement extends HTMLElementBase {
513
661
  toolbarActions.append(this.createActionButton(id));
514
662
  }
515
663
  }
664
+ const separator = document.createElement("span");
665
+ separator.className = "ssml-editor-toolbar-separator";
666
+ separator.setAttribute("aria-hidden", "true");
667
+ toolbarActions.append(separator, this.createModeButton("visual", "Visual"), this.createModeButton("code", "Code"));
516
668
  this.updateActiveButtons();
517
669
  }
518
670
 
671
+ private createModeButton(mode: "code" | "visual", label: string): HTMLButtonElement {
672
+ const button = document.createElement("button");
673
+ button.type = "button";
674
+ button.className = "ssml-editor-toolbar-button";
675
+ button.dataset.ssmlEditorButton = `edit-mode-${mode}`;
676
+ button.setAttribute("aria-pressed", String(this.editMode === mode));
677
+ button.textContent = label;
678
+ button.addEventListener("click", () => {
679
+ this.editMode = mode;
680
+ });
681
+ return button;
682
+ }
683
+
519
684
  private createActionButton(id: string): HTMLButtonElement {
520
685
  const copy = EDITOR_COPY[this.locale];
521
686
  const labels: Record<string, string> = {
@@ -938,6 +1103,159 @@ export class SsmlEditorElement extends HTMLElementBase {
938
1103
  }
939
1104
  }
940
1105
 
1106
+ private updateEditMode(): void {
1107
+ if (this.editorContainer && this.visualContainer) {
1108
+ const visual = this.editMode === "visual";
1109
+ this.editorContainer.hidden = visual;
1110
+ this.visualContainer.hidden = !visual;
1111
+ }
1112
+ for (const mode of ["visual", "code"] as const) {
1113
+ const button = this.toolbarActions?.querySelector<HTMLButtonElement>(
1114
+ `[data-ssml-editor-button="edit-mode-${mode}"]`,
1115
+ );
1116
+ button?.setAttribute("aria-pressed", String(this.editMode === mode));
1117
+ button?.toggleAttribute("data-active", this.editMode === mode);
1118
+ }
1119
+ this.renderVisualEditor();
1120
+ }
1121
+
1122
+ private renderVisualEditor(): void {
1123
+ const container = this.visualContainer;
1124
+ if (!container) return;
1125
+ container.replaceChildren();
1126
+ if (!this.documentState) {
1127
+ const error = document.createElement("p");
1128
+ error.className = "ssml-editor-visual-errors";
1129
+ error.textContent = "SSML syntax must be valid before visual editing is available.";
1130
+ container.append(error);
1131
+ return;
1132
+ }
1133
+
1134
+ const documentState = this.documentState;
1135
+ const leaves = collectVisualTextLeaves(documentState.children ?? []);
1136
+ const selectedLeaf = leaves.find((leaf) => leaf.path.join(".") === this.visualSelectedPath?.join(".")) ?? leaves[0];
1137
+ const breadcrumb = document.createElement("div");
1138
+ breadcrumb.className = "ssml-editor-visual-breadcrumb";
1139
+ breadcrumb.textContent = `<speak>${selectedLeaf ? ` / ${selectedLeaf.ancestors.map((name) => `<${name}>`).join(" / ")}` : ""}`;
1140
+ const clear = document.createElement("button");
1141
+ clear.type = "button";
1142
+ clear.textContent = "Clear parent";
1143
+ clear.disabled = !this.visualSelectedPath;
1144
+ clear.addEventListener("click", () => {
1145
+ this.visualSelectedPath = null;
1146
+ this.renderVisualEditor();
1147
+ });
1148
+ breadcrumb.append(clear);
1149
+ container.append(breadcrumb);
1150
+
1151
+ const diagnostics = validateAzureSsml(buildSsml(documentState));
1152
+ if (diagnostics.length > 0) {
1153
+ const errors = document.createElement("div");
1154
+ errors.className = "ssml-editor-visual-errors";
1155
+ errors.setAttribute("role", "alert");
1156
+ for (const diagnostic of diagnostics) {
1157
+ const message = document.createElement("div");
1158
+ message.textContent = diagnostic.message;
1159
+ errors.append(message);
1160
+ }
1161
+ container.append(errors);
1162
+ }
1163
+
1164
+ const layout = document.createElement("div");
1165
+ layout.className = "ssml-editor-visual-layout";
1166
+ const tree = document.createElement("nav");
1167
+ tree.className = "ssml-editor-visual-tree";
1168
+ tree.setAttribute("aria-label", "SSML structure tree");
1169
+ tree.append(document.createTextNode("Structure"));
1170
+ const treeList = document.createElement("ul");
1171
+ const renderTree = (nodes: SsmlNode[], parent: HTMLElement, parentPath: number[] = []): void => {
1172
+ nodes.forEach((node, index) => {
1173
+ if (!isElement(node)) return;
1174
+ const path = [...parentPath, index];
1175
+ const item = document.createElement("li");
1176
+ const button = document.createElement("button");
1177
+ button.type = "button";
1178
+ button.textContent = `<${visualElementName(node)}>`;
1179
+ button.dataset.selected = String(path.join(".") === this.visualSelectedPath?.join("."));
1180
+ button.addEventListener("click", () => {
1181
+ this.visualSelectedPath = path;
1182
+ this.renderVisualEditor();
1183
+ });
1184
+ item.append(button);
1185
+ if ((node.children ?? []).some(isElement)) {
1186
+ const childList = document.createElement("ul");
1187
+ renderTree(node.children ?? [], childList, path);
1188
+ item.append(childList);
1189
+ }
1190
+ parent.append(item);
1191
+ });
1192
+ };
1193
+ renderTree(documentState.children ?? [], treeList);
1194
+ tree.append(treeList);
1195
+ layout.append(tree);
1196
+
1197
+ const form = document.createElement("div");
1198
+ form.className = "ssml-editor-visual-form";
1199
+ if (selectedLeaf) {
1200
+ const label = document.createElement("label");
1201
+ label.append(document.createTextNode("Text"));
1202
+ const textarea = document.createElement("textarea");
1203
+ textarea.value = selectedLeaf.value;
1204
+ textarea.readOnly = this.readonly;
1205
+ textarea.addEventListener("select", () => {
1206
+ this.visualSelection = { start: textarea.selectionStart, end: textarea.selectionEnd };
1207
+ });
1208
+ textarea.addEventListener("input", () => {
1209
+ if (!this.readonly) this.replaceDocument(updateVisualText(documentState, selectedLeaf.path, textarea.value));
1210
+ });
1211
+ label.append(textarea);
1212
+ form.append(label);
1213
+ const actions = document.createElement("div");
1214
+ actions.className = "ssml-editor-visual-actions";
1215
+ const wrappers: readonly [string, SsmlElement["type"], Record<string, string>][] = [
1216
+ ["Rate", "prosody", { rate: "slow" }],
1217
+ ["Pitch", "prosody", { pitch: "high" }],
1218
+ ["Emotion", "mstts:express-as", { style: "cheerful" }],
1219
+ ["Pause", "break", { time: "500ms" }],
1220
+ ["Pronunciation", "phoneme", { alphabet: "ipa", ph: selectedLeaf.value }],
1221
+ ];
1222
+ for (const [labelText, type, attributes] of wrappers) {
1223
+ const button = document.createElement("button");
1224
+ button.type = "button";
1225
+ button.textContent = labelText;
1226
+ button.disabled = this.readonly;
1227
+ button.addEventListener("click", () => {
1228
+ const start = this.visualSelection.start === this.visualSelection.end ? 0 : this.visualSelection.start;
1229
+ const end =
1230
+ this.visualSelection.start === this.visualSelection.end
1231
+ ? selectedLeaf.value.length
1232
+ : this.visualSelection.end;
1233
+ this.replaceDocument(wrapVisualText(documentState, selectedLeaf.path, start, end, type, attributes));
1234
+ this.renderVisualEditor();
1235
+ });
1236
+ actions.append(button);
1237
+ }
1238
+ const preview = document.createElement("button");
1239
+ preview.type = "button";
1240
+ preview.textContent = "Preview selection";
1241
+ preview.addEventListener("click", () => {
1242
+ this.dispatchEvent(
1243
+ new CustomEvent("preview-selection", {
1244
+ detail: { ssml: buildSsml({ ...documentState, children: [selectedLeaf.value] }) },
1245
+ bubbles: true,
1246
+ composed: true,
1247
+ }),
1248
+ );
1249
+ });
1250
+ actions.append(preview);
1251
+ form.append(actions);
1252
+ } else {
1253
+ form.textContent = "Select an element or text node to edit it.";
1254
+ }
1255
+ layout.append(form);
1256
+ container.append(layout);
1257
+ }
1258
+
941
1259
  private updateActiveButtons(): void {
942
1260
  const editor = this.editor;
943
1261
  const model = this.model;
@@ -969,6 +1287,7 @@ export class SsmlEditorElement extends HTMLElementBase {
969
1287
  }
970
1288
 
971
1289
  const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
1290
+ this.renderVisualEditor();
972
1291
  const editor = monacoModule.editor.create(container, {
973
1292
  model,
974
1293
  theme: this.theme,
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { SsmlEditorElement } from "./SsmlEditorElement";
2
2
 
3
3
  export { SsmlEditorElement } from "./SsmlEditorElement";
4
4
  export type { SsmlEditorChangeDetail, SsmlEditorLocale, SsmlEditorTheme } from "./SsmlEditorElement";
5
+ export type SsmlEditorEditMode = "code" | "visual";
5
6
 
6
7
  export function defineSsmlEditorElement(): void {
7
8
  if (typeof customElements === "undefined" || customElements.get(SsmlEditorElement.tagName)) {