@ssml-builder-js/ssml-editor-elements 2.4.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.
@@ -0,0 +1,1084 @@
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";
4
+ import { clearSsmlDocument } from "../../ssml-editor-react/src/clearSsmlDocument";
5
+ import { getEditableRegion, getEditableText, updateEditableText } from "../../ssml-editor-react/src/editableSsml";
6
+ import { formatXmlFragment } from "../../ssml-editor-react/src/formatXml";
7
+ import { registerSsmlCompletionProvider } from "../../ssml-editor-react/src/ssmlCompletion";
8
+ import { findActiveSsmlTags, findSsmlVoiceContext } from "../../ssml-editor-react/src/ssmlContext";
9
+ import { findSsmlHoverTarget, formatSsmlHover } from "../../ssml-editor-react/src/ssmlHover";
10
+ import { createSsmlInsertionEdit } from "../../ssml-editor-react/src/ssmlInsertion";
11
+ import {
12
+ DEFAULT_INSERTION_GROUPS,
13
+ SSML_INSERTIONS,
14
+ type SsmlEditorInsertionDefinition,
15
+ type SsmlEditorInsertionOption,
16
+ } from "../../ssml-editor-react/src/ssmlInsertions";
17
+ import { EDITOR_COPY } from "../../ssml-editor-react/src/locales";
18
+ import { getExpressAsStyleCategory, resolveExpressAsStyles } from "../../ssml-editor-react/src/constants/ssmlPresets";
19
+
20
+ type Monaco = typeof monaco;
21
+ type MonacoEditor = monaco.editor.IStandaloneCodeEditor;
22
+ type MonacoModel = monaco.editor.ITextModel;
23
+ type MonacoDisposable = { dispose(): void };
24
+
25
+ export type SsmlEditorTheme = "vs-dark" | "light" | (string & {});
26
+ export type SsmlEditorLocale = "ja" | "en";
27
+
28
+ export interface SsmlEditorChangeDetail {
29
+ value: string;
30
+ }
31
+
32
+ const HTMLElementBase = (typeof HTMLElement === "undefined" ? class {} : HTMLElement) as typeof HTMLElement;
33
+ const STYLE_ID = "ssml-editor-elements-theme";
34
+ const STYLE_CSS = `
35
+ [data-ssml-editor] {
36
+ --ssml-editor-color: #111827;
37
+ --ssml-editor-bg: #ffffff;
38
+ --ssml-editor-border: #d1d5db;
39
+ --ssml-editor-control-bg: #f9fafb;
40
+ --ssml-editor-control-border: #9ca3af;
41
+ --ssml-editor-active-bg: #dbeafe;
42
+ --ssml-editor-active-border: #2563eb;
43
+ --ssml-editor-preview-bg: #f3f4f6;
44
+ }
45
+ section[data-ssml-editor] {
46
+ display: grid;
47
+ grid-template-rows: auto minmax(8rem, 1fr);
48
+ gap: 0.75rem;
49
+ box-sizing: border-box;
50
+ width: 100%;
51
+ min-height: 100%;
52
+ padding: 1rem;
53
+ border: 1px solid var(--ssml-editor-border);
54
+ border-radius: 0.5rem;
55
+ color: var(--ssml-editor-color);
56
+ background: var(--ssml-editor-bg);
57
+ }
58
+ [data-ssml-editor][data-theme="dark"] {
59
+ --ssml-editor-color: #f9fafb;
60
+ --ssml-editor-bg: #1f2937;
61
+ --ssml-editor-border: #374151;
62
+ --ssml-editor-control-bg: #111827;
63
+ --ssml-editor-control-border: #4b5563;
64
+ --ssml-editor-active-bg: #1e3a8a;
65
+ --ssml-editor-active-border: #60a5fa;
66
+ --ssml-editor-preview-bg: #111827;
67
+ }
68
+ [data-ssml-editor] .ssml-editor-toolbar {
69
+ display: flex;
70
+ align-items: center;
71
+ flex-wrap: wrap;
72
+ gap: 0.5rem;
73
+ }
74
+ [data-ssml-editor] .ssml-editor-toolbar-actions {
75
+ display: flex;
76
+ align-items: center;
77
+ flex-wrap: wrap;
78
+ gap: 0.5rem;
79
+ }
80
+ [data-ssml-editor] .ssml-editor-toolbar-separator {
81
+ width: 1px;
82
+ height: 2.25rem;
83
+ margin: 0 0.25rem;
84
+ background: var(--ssml-editor-border);
85
+ }
86
+ [data-ssml-editor] .ssml-editor-toolbar-dropdown {
87
+ display: inline-block;
88
+ }
89
+ [data-ssml-editor] .ssml-editor-toolbar-button {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ gap: 0.375rem;
93
+ min-height: 2.25rem;
94
+ padding: 0.375rem 0.625rem;
95
+ border: 1px solid var(--ssml-editor-control-border);
96
+ border-radius: 0.25rem;
97
+ color: var(--ssml-editor-color);
98
+ background: var(--ssml-editor-control-bg);
99
+ font: inherit;
100
+ cursor: pointer;
101
+ }
102
+ [data-ssml-editor] .ssml-editor-toolbar-button:hover,
103
+ [data-ssml-editor] .ssml-editor-toolbar-option:hover {
104
+ background: var(--ssml-editor-preview-bg);
105
+ }
106
+ [data-ssml-editor] .ssml-editor-toolbar-button:disabled {
107
+ cursor: not-allowed;
108
+ opacity: 0.55;
109
+ }
110
+ [data-ssml-editor] .ssml-editor-toolbar-button[data-active="true"] {
111
+ border-color: var(--ssml-editor-active-border);
112
+ background: var(--ssml-editor-active-bg);
113
+ }
114
+ [data-ssml-editor] .ssml-editor-toolbar-icon {
115
+ display: inline-flex;
116
+ width: 1.25rem;
117
+ justify-content: center;
118
+ font-size: 1.1rem;
119
+ line-height: 1;
120
+ }
121
+ [data-ssml-editor] .ssml-editor-toolbar-chevron {
122
+ font-size: 0.7rem;
123
+ line-height: 1;
124
+ }
125
+ [data-ssml-editor] .ssml-editor-toolbar-switch {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 0.375rem;
129
+ min-height: 2.25rem;
130
+ }
131
+ [data-ssml-editor] .ssml-editor-switch-track {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ width: 2.75rem;
135
+ height: 1.5rem;
136
+ padding: 0.1875rem;
137
+ border: 0;
138
+ border-radius: 999px;
139
+ background: var(--ssml-editor-control-border);
140
+ cursor: pointer;
141
+ transition: background-color 0.2s ease;
142
+ }
143
+ [data-ssml-editor] .ssml-editor-switch-track[aria-checked="true"] {
144
+ background: var(--ssml-editor-active-border);
145
+ }
146
+ [data-ssml-editor] .ssml-editor-switch-track:focus-visible {
147
+ outline: 2px solid var(--ssml-editor-active-border);
148
+ outline-offset: 2px;
149
+ }
150
+ [data-ssml-editor] .ssml-editor-switch-thumb {
151
+ width: 1.125rem;
152
+ height: 1.125rem;
153
+ border-radius: 50%;
154
+ background: var(--ssml-editor-bg);
155
+ transition: transform 0.2s ease;
156
+ }
157
+ [data-ssml-editor] .ssml-editor-switch-track[aria-checked="true"] .ssml-editor-switch-thumb {
158
+ transform: translateX(1.25rem);
159
+ }
160
+ [data-ssml-editor].ssml-editor-toolbar-menu {
161
+ position: fixed;
162
+ z-index: 9999;
163
+ display: grid;
164
+ min-width: max-content;
165
+ max-height: min(24rem, calc(100vh - 1rem));
166
+ gap: 0.125rem;
167
+ padding: 0.25rem;
168
+ border: 1px solid var(--ssml-editor-control-border);
169
+ border-radius: 0.25rem;
170
+ background: var(--ssml-editor-control-bg);
171
+ box-shadow: 0 0.25rem 0.75rem rgb(0 0 0 / 20%);
172
+ overflow-y: auto;
173
+ }
174
+ [data-ssml-editor] .ssml-editor-toolbar-option {
175
+ padding: 0.375rem 0.5rem;
176
+ border: 0;
177
+ border-radius: 0.125rem;
178
+ color: var(--ssml-editor-color);
179
+ background: transparent;
180
+ font: inherit;
181
+ text-align: left;
182
+ white-space: nowrap;
183
+ cursor: pointer;
184
+ }
185
+ [data-ssml-editor] .ssml-editor-toolbar-option-group {
186
+ display: grid;
187
+ gap: 0.125rem;
188
+ margin: 0;
189
+ padding: 0;
190
+ border: 0;
191
+ }
192
+ [data-ssml-editor] .ssml-editor-toolbar-option-group legend {
193
+ padding: 0.375rem 0.5rem 0.125rem;
194
+ font-size: 0.875rem;
195
+ font-weight: 600;
196
+ white-space: nowrap;
197
+ }
198
+ [data-ssml-editor] .ssml-editor-help {
199
+ display: grid;
200
+ gap: 0.5rem;
201
+ padding: 0.75rem;
202
+ border: 1px solid var(--ssml-editor-control-border);
203
+ border-radius: 0.25rem;
204
+ background: var(--ssml-editor-preview-bg);
205
+ }
206
+ [data-ssml-editor] .ssml-editor-help h3,
207
+ [data-ssml-editor] .ssml-editor-help p {
208
+ margin: 0;
209
+ }
210
+ [data-ssml-editor] .ssml-editor-help-list {
211
+ display: grid;
212
+ gap: 0.375rem;
213
+ margin: 0;
214
+ padding-left: 1.25rem;
215
+ }
216
+ [data-ssml-editor] .ssml-editor-help-item {
217
+ list-style: none;
218
+ }
219
+ [data-ssml-editor] .ssml-editor-help-item details {
220
+ margin-top: 0.375rem;
221
+ border: 1px solid var(--ssml-editor-control-border);
222
+ border-radius: 0.25rem;
223
+ background: var(--ssml-editor-control-bg);
224
+ }
225
+ [data-ssml-editor] .ssml-editor-help-item summary {
226
+ padding: 0.5rem 0.625rem;
227
+ cursor: pointer;
228
+ }
229
+ [data-ssml-editor] .ssml-editor-help-item details p,
230
+ [data-ssml-editor] .ssml-editor-help-item details ul {
231
+ margin: 0.5rem 0.75rem 0.75rem;
232
+ font-size: 0.875rem;
233
+ }
234
+ [data-ssml-editor] .ssml-editor-display {
235
+ display: grid;
236
+ grid-template-rows: auto minmax(8rem, 1fr);
237
+ gap: 0.5rem;
238
+ min-height: 0;
239
+ }
240
+ [data-ssml-editor] .ssml-editor-editor {
241
+ position: relative;
242
+ min-height: 8rem;
243
+ height: 100%;
244
+ border: 1px solid var(--ssml-editor-control-border);
245
+ border-radius: 0.25rem;
246
+ overflow: visible;
247
+ }
248
+ `.trim();
249
+
250
+ function injectStyles(): void {
251
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
252
+ return;
253
+ }
254
+
255
+ const style = document.createElement("style");
256
+ style.id = STYLE_ID;
257
+ style.textContent = STYLE_CSS;
258
+ document.head.appendChild(style);
259
+ }
260
+
261
+ function isDarkTheme(theme: SsmlEditorTheme): boolean {
262
+ return theme === "vs-dark" || theme.toLowerCase().includes("dark");
263
+ }
264
+
265
+ function getMenuPosition(trigger: HTMLElement, menu: HTMLElement): { top: number; left: number } {
266
+ const bounds = trigger.getBoundingClientRect();
267
+ const margin = 8;
268
+ const top = Math.min(bounds.bottom + 4, window.innerHeight - menu.offsetHeight - margin);
269
+ const left = Math.min(bounds.left, window.innerWidth - menu.offsetWidth - margin);
270
+ return {
271
+ top: Math.max(margin, top),
272
+ left: Math.max(margin, left),
273
+ };
274
+ }
275
+
276
+ export class SsmlEditorElement extends HTMLElementBase {
277
+ static readonly tagName = "ssml-editor";
278
+ static readonly observedAttributes = [
279
+ "value",
280
+ "theme",
281
+ "readonly",
282
+ "locale",
283
+ "show-toolbar",
284
+ "show-toolbar-labels",
285
+ "show-decorations",
286
+ ];
287
+
288
+ private editor: MonacoEditor | null = null;
289
+ private model: MonacoModel | null = null;
290
+ private monaco: Monaco | null = null;
291
+ private root: HTMLElement | null = null;
292
+ private toolbar: HTMLElement | null = null;
293
+ private toolbarActions: HTMLElement | null = null;
294
+ private display: HTMLElement | null = null;
295
+ private editorContainer: HTMLDivElement | null = null;
296
+ private helpPanel: HTMLElement | null = null;
297
+ private openMenu: HTMLElement | null = null;
298
+ private openMenuTrigger: HTMLButtonElement | null = null;
299
+ private toolbarButtons = new Map<string, HTMLButtonElement>();
300
+ private contentDisposable: MonacoDisposable | null = null;
301
+ private cursorDisposable: MonacoDisposable | null = null;
302
+ private completionDisposable: MonacoDisposable | null = null;
303
+ private hoverDisposable: MonacoDisposable | null = null;
304
+ private suppressChangeEvent = false;
305
+ private initializationToken = 0;
306
+ private valueState: string | undefined;
307
+ private documentState: SsmlDocument | null = null;
308
+ private decorationsVisible = false;
309
+ private helpOpen = false;
310
+
311
+ get value(): string {
312
+ return this.valueState ?? this.getAttribute("value") ?? "";
313
+ }
314
+
315
+ set value(value: string) {
316
+ this.valueState = value;
317
+ this.setAttribute("value", value);
318
+ }
319
+
320
+ get theme(): SsmlEditorTheme {
321
+ return this.getAttribute("theme") || "light";
322
+ }
323
+
324
+ set theme(theme: SsmlEditorTheme) {
325
+ this.setAttribute("theme", theme);
326
+ }
327
+
328
+ get readonly(): boolean {
329
+ return this.hasAttribute("readonly");
330
+ }
331
+
332
+ set readonly(readonly: boolean) {
333
+ if (readonly) {
334
+ this.setAttribute("readonly", "");
335
+ } else {
336
+ this.removeAttribute("readonly");
337
+ }
338
+ }
339
+
340
+ get locale(): SsmlEditorLocale {
341
+ return this.getAttribute("locale") === "en" ? "en" : "ja";
342
+ }
343
+
344
+ set locale(locale: SsmlEditorLocale) {
345
+ this.setAttribute("locale", locale);
346
+ }
347
+
348
+ private prepareDocument(value: string): string {
349
+ try {
350
+ this.documentState = parseSsml(value);
351
+ return getEditableText(this.documentState);
352
+ } catch {
353
+ this.documentState = null;
354
+ return value;
355
+ }
356
+ }
357
+
358
+ connectedCallback(): void {
359
+ if (this.editor || this.root) {
360
+ return;
361
+ }
362
+
363
+ injectStyles();
364
+ document.addEventListener("pointerdown", this.handleDocumentPointerDown);
365
+ document.addEventListener("keydown", this.handleDocumentKeyDown);
366
+ this.render();
367
+ const token = ++this.initializationToken;
368
+ void this.initialize(token);
369
+ }
370
+
371
+ disconnectedCallback(): void {
372
+ this.initializationToken += 1;
373
+ document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
374
+ document.removeEventListener("keydown", this.handleDocumentKeyDown);
375
+ this.closeMenu();
376
+ this.disposeEditor();
377
+ }
378
+
379
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {
380
+ if (name === "value") {
381
+ this.valueState = newValue ?? "";
382
+ if (this.editor) {
383
+ const value = newValue ?? "";
384
+ const editableValue = this.prepareDocument(value);
385
+ if (this.editor.getValue() !== editableValue) {
386
+ this.suppressChangeEvent = true;
387
+ try {
388
+ this.editor.setValue(editableValue);
389
+ } finally {
390
+ this.suppressChangeEvent = false;
391
+ }
392
+ }
393
+ }
394
+ return;
395
+ }
396
+
397
+ if (name === "theme" && this.monaco) {
398
+ this.monaco.editor.setTheme(this.theme);
399
+ this.updateTheme();
400
+ return;
401
+ }
402
+
403
+ if (name === "readonly") {
404
+ this.editor?.updateOptions({ readOnly: this.readonly });
405
+ this.renderToolbar();
406
+ return;
407
+ }
408
+
409
+ if (name === "locale" || name === "show-toolbar" || name === "show-toolbar-labels") {
410
+ this.renderToolbar();
411
+ this.renderHelp();
412
+ return;
413
+ }
414
+
415
+ if (name === "show-decorations") {
416
+ this.decorationsVisible = newValue !== null;
417
+ this.updateDecorations();
418
+ }
419
+ }
420
+
421
+ private render(): void {
422
+ const root = document.createElement("section");
423
+ root.dataset.ssmlEditor = "";
424
+ root.setAttribute("aria-label", EDITOR_COPY[this.locale].editorAriaLabel);
425
+ root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
426
+
427
+ const toolbar = document.createElement("div");
428
+ toolbar.className = "ssml-editor-toolbar";
429
+ toolbar.dataset.ssmlEditorToolbar = "";
430
+ const toolbarActions = document.createElement("div");
431
+ toolbarActions.className = "ssml-editor-toolbar-actions";
432
+ toolbarActions.setAttribute("role", "toolbar");
433
+ toolbarActions.setAttribute("aria-label", EDITOR_COPY[this.locale].toolbarAriaLabel);
434
+ toolbarActions.dataset.ssmlEditorToolbarActions = "";
435
+ toolbar.append(toolbarActions);
436
+
437
+ const display = document.createElement("div");
438
+ display.className = "ssml-editor-display";
439
+ display.dataset.ssmlEditorDisplay = "";
440
+ const editorContainer = document.createElement("div");
441
+ editorContainer.className = "ssml-editor-editor";
442
+ display.append(editorContainer);
443
+ root.append(toolbar, display);
444
+ this.replaceChildren(root);
445
+
446
+ this.root = root;
447
+ this.toolbar = toolbar;
448
+ this.toolbarActions = toolbarActions;
449
+ this.display = display;
450
+ this.editorContainer = editorContainer;
451
+ this.renderToolbar();
452
+ this.renderHelp();
453
+ }
454
+
455
+ private renderToolbar(): void {
456
+ const toolbar = this.toolbar;
457
+ const toolbarActions = this.toolbarActions;
458
+ if (!toolbar || !toolbarActions) {
459
+ return;
460
+ }
461
+
462
+ this.closeMenu();
463
+ const copy = EDITOR_COPY[this.locale];
464
+ toolbar.hidden = this.getAttribute("show-toolbar") === "false";
465
+ toolbar.setAttribute("aria-label", copy.toolbarAriaLabel);
466
+ toolbarActions.setAttribute("aria-label", copy.toolbarAriaLabel);
467
+ toolbarActions.replaceChildren();
468
+ this.toolbarButtons.clear();
469
+ if (toolbar.hidden) {
470
+ return;
471
+ }
472
+
473
+ const insertionById = new Map(SSML_INSERTIONS.map((insertion) => [insertion.id, insertion]));
474
+ const toolbarIds = [
475
+ "undo",
476
+ "redo",
477
+ ...DEFAULT_INSERTION_GROUPS.flatMap((group) => group.insertionIds),
478
+ "clearAll",
479
+ "format",
480
+ "decorations",
481
+ "help",
482
+ ];
483
+ const groupByButtonId = new Map<string, string>();
484
+ for (const group of DEFAULT_INSERTION_GROUPS) {
485
+ for (const buttonId of group.insertionIds) {
486
+ groupByButtonId.set(buttonId, group.id);
487
+ }
488
+ }
489
+ groupByButtonId.set("undo", "history");
490
+ groupByButtonId.set("redo", "history");
491
+ groupByButtonId.set("clearAll", "document");
492
+ groupByButtonId.set("format", "document");
493
+ groupByButtonId.set("decorations", "document");
494
+ groupByButtonId.set("help", "help");
495
+
496
+ let previousGroup: string | undefined;
497
+ for (const id of toolbarIds) {
498
+ const group = groupByButtonId.get(id);
499
+ if (previousGroup !== undefined && group !== previousGroup) {
500
+ const separator = document.createElement("span");
501
+ separator.className = "ssml-editor-toolbar-separator";
502
+ separator.setAttribute("aria-hidden", "true");
503
+ toolbarActions.append(separator);
504
+ }
505
+ previousGroup = group;
506
+
507
+ const insertion = insertionById.get(id);
508
+ if (insertion) {
509
+ toolbarActions.append(this.createInsertionButton(insertion));
510
+ } else if (id === "decorations") {
511
+ toolbarActions.append(this.createDecorationsSwitch());
512
+ } else {
513
+ toolbarActions.append(this.createActionButton(id));
514
+ }
515
+ }
516
+ this.updateActiveButtons();
517
+ }
518
+
519
+ private createActionButton(id: string): HTMLButtonElement {
520
+ const copy = EDITOR_COPY[this.locale];
521
+ const labels: Record<string, string> = {
522
+ undo: copy.undo,
523
+ redo: copy.redo,
524
+ clearAll: copy.clearAll,
525
+ format: copy.format,
526
+ help: copy.help,
527
+ };
528
+ const icons: Record<string, string> = {
529
+ undo: "↩",
530
+ redo: "↪",
531
+ clearAll: "×",
532
+ format: "≡",
533
+ help: "?",
534
+ };
535
+ const titles: Record<string, string> = {
536
+ undo: copy.undoTitle,
537
+ redo: copy.redoTitle,
538
+ clearAll: copy.clearAllTitle,
539
+ format: copy.formatTitle,
540
+ help: copy.helpTitle,
541
+ };
542
+ const button = document.createElement("button");
543
+ button.type = "button";
544
+ button.className = "ssml-editor-toolbar-button";
545
+ button.dataset.ssmlEditorButton = id;
546
+ button.setAttribute("aria-label", labels[id] ?? id);
547
+ button.title = titles[id] ?? labels[id] ?? id;
548
+ button.disabled = this.readonly && id !== "help";
549
+ if (id === "help") {
550
+ button.setAttribute("aria-expanded", String(this.helpOpen));
551
+ }
552
+ const icon = document.createElement("span");
553
+ icon.className = "ssml-editor-toolbar-icon";
554
+ icon.setAttribute("aria-hidden", "true");
555
+ icon.textContent = icons[id] ?? "";
556
+ button.append(icon);
557
+ if (this.hasAttribute("show-toolbar-labels")) {
558
+ button.append(document.createTextNode(labels[id] ?? id));
559
+ }
560
+ button.addEventListener("click", () => {
561
+ if (id === "help") {
562
+ this.helpOpen = !this.helpOpen;
563
+ button.setAttribute("aria-expanded", String(this.helpOpen));
564
+ this.renderHelp();
565
+ } else if (!this.readonly) {
566
+ this.handleAction(id);
567
+ }
568
+ });
569
+ this.toolbarButtons.set(id, button);
570
+ return button;
571
+ }
572
+
573
+ private createDecorationsSwitch(): HTMLElement {
574
+ const copy = EDITOR_COPY[this.locale];
575
+ const wrapper = document.createElement("div");
576
+ wrapper.className = "ssml-editor-toolbar-switch";
577
+ const icon = document.createElement("span");
578
+ icon.className = "ssml-editor-toolbar-icon";
579
+ icon.setAttribute("aria-hidden", "true");
580
+ icon.textContent = "☆";
581
+ wrapper.append(icon);
582
+ if (this.hasAttribute("show-toolbar-labels")) {
583
+ wrapper.append(document.createTextNode(copy.decorations));
584
+ }
585
+ const button = document.createElement("button");
586
+ button.type = "button";
587
+ button.className = "ssml-editor-switch-track";
588
+ button.dataset.ssmlEditorButton = "decorations";
589
+ button.setAttribute("role", "switch");
590
+ button.setAttribute("aria-label", copy.decorations);
591
+ button.addEventListener("click", () => {
592
+ this.decorationsVisible = !this.decorationsVisible;
593
+ this.updateDecorations();
594
+ });
595
+ wrapper.append(button);
596
+ this.toolbarButtons.set("decorations", button);
597
+ this.updateDecorations();
598
+ return wrapper;
599
+ }
600
+
601
+ private createInsertionButton(insertion: SsmlEditorInsertionDefinition): HTMLElement {
602
+ const button = document.createElement("button");
603
+ button.type = "button";
604
+ button.className = "ssml-editor-toolbar-button";
605
+ button.dataset.ssmlEditorButton = insertion.id;
606
+ button.setAttribute("aria-label", insertion.labels[this.locale]);
607
+ button.setAttribute("aria-haspopup", "menu");
608
+ button.setAttribute("aria-expanded", "false");
609
+ button.title =
610
+ insertion.titles?.[this.locale] ?? `${insertion.labels[this.locale]} — ${insertion.descriptions[this.locale]}`;
611
+ const icon = document.createElement("span");
612
+ icon.className = "ssml-editor-toolbar-icon";
613
+ icon.setAttribute("aria-hidden", "true");
614
+ icon.textContent = insertion.icon;
615
+ button.append(icon);
616
+ if (this.hasAttribute("show-toolbar-labels")) {
617
+ button.append(document.createTextNode(insertion.labels[this.locale]));
618
+ }
619
+ const chevron = document.createElement("span");
620
+ chevron.className = "ssml-editor-toolbar-chevron";
621
+ chevron.setAttribute("aria-hidden", "true");
622
+ chevron.textContent = "▾";
623
+ button.append(chevron);
624
+ button.addEventListener("click", () => this.toggleInsertionMenu(insertion, button));
625
+ this.toolbarButtons.set(insertion.id, button);
626
+ const wrapper = document.createElement("div");
627
+ wrapper.className = "ssml-editor-toolbar-dropdown";
628
+ wrapper.append(button);
629
+ return wrapper;
630
+ }
631
+
632
+ private toggleInsertionMenu(insertion: SsmlEditorInsertionDefinition, trigger: HTMLButtonElement): void {
633
+ if (this.openMenuTrigger === trigger) {
634
+ this.closeMenu();
635
+ return;
636
+ }
637
+
638
+ this.closeMenu();
639
+ const menu = this.createInsertionMenu(insertion);
640
+ document.body.append(menu);
641
+ const position = getMenuPosition(trigger, menu);
642
+ menu.style.top = `${position.top}px`;
643
+ menu.style.left = `${position.left}px`;
644
+ trigger.setAttribute("aria-expanded", "true");
645
+ trigger.setAttribute("aria-controls", menu.id);
646
+ this.openMenu = menu;
647
+ this.openMenuTrigger = trigger;
648
+ }
649
+
650
+ private createInsertionMenu(insertion: SsmlEditorInsertionDefinition): HTMLElement {
651
+ const menu = document.createElement("div");
652
+ menu.className = "ssml-editor-toolbar-menu";
653
+ menu.dataset.ssmlEditor = "";
654
+ menu.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
655
+ menu.id = `ssml-editor-elements-menu-${insertion.id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
656
+ menu.setAttribute("role", "menu");
657
+ menu.setAttribute("aria-label", insertion.labels[this.locale]);
658
+ menu.addEventListener("pointerdown", (event) => event.stopPropagation());
659
+
660
+ const options = this.getInsertionOptions(insertion);
661
+ if (options.length === 0) {
662
+ const empty = document.createElement("p");
663
+ empty.className = "ssml-editor-toolbar-option";
664
+ empty.textContent = EDITOR_COPY[this.locale].noAvailableOptions;
665
+ menu.append(empty);
666
+ } else {
667
+ for (const group of this.getOptionGroups(insertion, options)) {
668
+ if (group.label) {
669
+ const fieldset = document.createElement("fieldset");
670
+ fieldset.className = "ssml-editor-toolbar-option-group";
671
+ const legend = document.createElement("legend");
672
+ legend.textContent = group.label;
673
+ fieldset.append(legend);
674
+ for (const option of group.options) {
675
+ fieldset.append(this.createOptionButton(insertion, option));
676
+ }
677
+ menu.append(fieldset);
678
+ } else {
679
+ for (const option of group.options) {
680
+ menu.append(this.createOptionButton(insertion, option));
681
+ }
682
+ }
683
+ }
684
+ }
685
+ return menu;
686
+ }
687
+
688
+ private getInsertionOptions(insertion: SsmlEditorInsertionDefinition): readonly SsmlEditorInsertionOption[] {
689
+ if (insertion.id !== "emotion") {
690
+ return insertion.options;
691
+ }
692
+
693
+ const model = this.model;
694
+ const selection = this.editor?.getSelection();
695
+ const voiceContext =
696
+ model && selection
697
+ ? findSsmlVoiceContext(model.getValue(), model.getOffsetAt(selection.getStartPosition()))
698
+ : undefined;
699
+ const voiceName =
700
+ voiceContext === undefined
701
+ ? this.documentState
702
+ ? getEditableRegion(this.documentState).voiceName
703
+ : undefined
704
+ : voiceContext.voiceName;
705
+ const availableStyles = new Set(
706
+ resolveExpressAsStyles(
707
+ voiceName,
708
+ insertion.options.map((option) => option.value),
709
+ ),
710
+ );
711
+ return insertion.options.filter((option) => availableStyles.has(option.value));
712
+ }
713
+
714
+ private getOptionGroups(
715
+ insertion: SsmlEditorInsertionDefinition,
716
+ options: readonly SsmlEditorInsertionOption[],
717
+ ): readonly { label: string; options: readonly SsmlEditorInsertionOption[] }[] {
718
+ if (insertion.id !== "emotion") {
719
+ return [{ label: "", options }];
720
+ }
721
+
722
+ const copy = EDITOR_COPY[this.locale];
723
+ const labels: Record<string, string> = {
724
+ emotions: copy.categoryEmotions,
725
+ scenarios: copy.categoryScenarios,
726
+ media: copy.categoryMedia,
727
+ other: copy.categoryOther,
728
+ };
729
+ const categories = ["emotions", "scenarios", "media", "other"] as const;
730
+ return categories
731
+ .map((category) => ({
732
+ label: labels[category],
733
+ options: options.filter((option) => getExpressAsStyleCategory(option.value) === category),
734
+ }))
735
+ .filter((group) => group.options.length > 0);
736
+ }
737
+
738
+ private createOptionButton(
739
+ insertion: SsmlEditorInsertionDefinition,
740
+ option: SsmlEditorInsertionOption,
741
+ ): HTMLButtonElement {
742
+ const button = document.createElement("button");
743
+ button.type = "button";
744
+ button.className = "ssml-editor-toolbar-option";
745
+ button.setAttribute("role", "menuitem");
746
+ button.title = option.descriptions?.[this.locale] ?? insertion.descriptions[this.locale];
747
+ button.textContent = option.labels[this.locale];
748
+ button.disabled = this.readonly;
749
+ button.addEventListener("click", () => {
750
+ if (!this.readonly) {
751
+ this.applyInsertion(insertion, option);
752
+ }
753
+ this.closeMenu(true);
754
+ });
755
+ button.addEventListener("mousedown", (event) => event.preventDefault());
756
+ return button;
757
+ }
758
+
759
+ private handleAction(id: string): void {
760
+ if (!this.editor) {
761
+ return;
762
+ }
763
+ if (id === "undo" || id === "redo") {
764
+ this.editor.trigger("ssml-toolbar", id, null);
765
+ this.editor.focus();
766
+ } else if (id === "clearAll") {
767
+ if (this.documentState) {
768
+ this.replaceDocument(clearSsmlDocument(this.documentState));
769
+ }
770
+ } else if (id === "format") {
771
+ if (this.documentState) {
772
+ this.replaceDocument(updateEditableText(this.documentState, formatXmlFragment(this.editor.getValue())));
773
+ } else {
774
+ this.replaceEditorValue(formatXmlFragment(this.editor.getValue()));
775
+ }
776
+ }
777
+ }
778
+
779
+ private applyInsertion(insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption): void {
780
+ const editor = this.editor;
781
+ const model = this.model;
782
+ const selection = editor?.getSelection();
783
+ if (!editor || !model || !selection) {
784
+ return;
785
+ }
786
+
787
+ const startOffset = model.getOffsetAt(selection.getStartPosition());
788
+ const endOffset = model.getOffsetAt(selection.getEndPosition());
789
+ const selectedText = selection.isEmpty() ? "" : model.getValueInRange(selection);
790
+ const result = createSsmlInsertionEdit(
791
+ model.getValue(),
792
+ startOffset,
793
+ endOffset,
794
+ insertion.createTemplate(option.value),
795
+ model.getEOL(),
796
+ selectedText,
797
+ );
798
+ editor.pushUndoStop();
799
+ const applied = editor.executeEdits("ssml-toolbar", [{ range: selection, text: result.replacement }]);
800
+ editor.pushUndoStop();
801
+ if (!applied) {
802
+ return;
803
+ }
804
+
805
+ const start = model.getPositionAt(startOffset + result.selectionOffset);
806
+ const end = model.getPositionAt(endOffset + result.selectionOffset);
807
+ editor.setSelection({
808
+ selectionStartLineNumber: start.lineNumber,
809
+ selectionStartColumn: start.column,
810
+ positionLineNumber: end.lineNumber,
811
+ positionColumn: end.column,
812
+ });
813
+ editor.focus();
814
+ }
815
+
816
+ private replaceEditorValue(value: string): void {
817
+ const editor = this.editor;
818
+ const model = editor?.getModel();
819
+ if (!editor || !model || editor.getValue() === value) {
820
+ return;
821
+ }
822
+ editor.pushUndoStop();
823
+ editor.executeEdits("ssml-editor-toolbar", [
824
+ {
825
+ range: model.getFullModelRange(),
826
+ text: value,
827
+ forceMoveMarkers: true,
828
+ },
829
+ ]);
830
+ editor.pushUndoStop();
831
+ editor.focus();
832
+ }
833
+
834
+ private replaceDocument(document: SsmlDocument): void {
835
+ const editor = this.editor;
836
+ const model = editor?.getModel();
837
+ const editableValue = getEditableText(document);
838
+ const fullValue = buildSsml(document);
839
+ this.documentState = document;
840
+ this.valueState = fullValue;
841
+
842
+ if (!editor || !model || editor.getValue() === editableValue) {
843
+ this.updateActiveButtons();
844
+ this.dispatchChange(fullValue);
845
+ return;
846
+ }
847
+
848
+ editor.pushUndoStop();
849
+ editor.executeEdits("ssml-editor-toolbar", [
850
+ {
851
+ range: model.getFullModelRange(),
852
+ text: editableValue,
853
+ forceMoveMarkers: true,
854
+ },
855
+ ]);
856
+ editor.pushUndoStop();
857
+ editor.focus();
858
+ }
859
+
860
+ private dispatchChange(value: string): void {
861
+ if (this.suppressChangeEvent) {
862
+ return;
863
+ }
864
+ this.dispatchEvent(
865
+ new CustomEvent<SsmlEditorChangeDetail>("change", {
866
+ detail: { value },
867
+ bubbles: true,
868
+ composed: true,
869
+ }),
870
+ );
871
+ }
872
+
873
+ private updateDecorations(): void {
874
+ const button = this.toolbarButtons.get("decorations");
875
+ if (button) {
876
+ const copy = EDITOR_COPY[this.locale];
877
+ button.setAttribute("aria-checked", String(this.decorationsVisible));
878
+ button.title = this.decorationsVisible ? copy.decorationsHideTitle : copy.decorationsShowTitle;
879
+ }
880
+ this.editor?.updateOptions({
881
+ inlayHints: { enabled: this.decorationsVisible ? "on" : "off" },
882
+ });
883
+ }
884
+
885
+ private renderHelp(): void {
886
+ const display = this.display;
887
+ if (!display) {
888
+ return;
889
+ }
890
+
891
+ this.helpPanel?.remove();
892
+ this.helpPanel = null;
893
+ const root = this.root;
894
+ if (root) {
895
+ root.setAttribute("aria-label", EDITOR_COPY[this.locale].editorAriaLabel);
896
+ this.updateTheme();
897
+ }
898
+ if (!this.helpOpen || this.getAttribute("show-toolbar") === "false") {
899
+ return;
900
+ }
901
+
902
+ const copy = EDITOR_COPY[this.locale];
903
+ const panel = document.createElement("section");
904
+ panel.className = "ssml-editor-help";
905
+ panel.setAttribute("aria-label", copy.helpHeading);
906
+ const heading = document.createElement("h3");
907
+ heading.textContent = copy.helpHeading;
908
+ const description = document.createElement("p");
909
+ description.textContent = copy.helpDescription;
910
+ const list = document.createElement("ul");
911
+ list.className = "ssml-editor-help-list";
912
+ for (const insertion of SSML_INSERTIONS) {
913
+ const item = document.createElement("li");
914
+ item.className = "ssml-editor-help-item";
915
+ const details = document.createElement("details");
916
+ const summary = document.createElement("summary");
917
+ summary.textContent = `${insertion.icon} ${insertion.labels[this.locale]} — ${insertion.descriptions[this.locale]}`;
918
+ const parameters = document.createElement("p");
919
+ parameters.textContent = `${copy.parameters}: ${insertion.parameterDescription[this.locale]}`;
920
+ const options = document.createElement("ul");
921
+ for (const option of insertion.options) {
922
+ const optionItem = document.createElement("li");
923
+ optionItem.textContent = `${option.labels[this.locale]}${option.descriptions?.[this.locale] ? ` — ${option.descriptions[this.locale]}` : ""}`;
924
+ options.append(optionItem);
925
+ }
926
+ details.append(summary, parameters, options);
927
+ item.append(details);
928
+ list.append(item);
929
+ }
930
+ panel.append(heading, description, list);
931
+ display.prepend(panel);
932
+ this.helpPanel = panel;
933
+ }
934
+
935
+ private updateTheme(): void {
936
+ if (this.root) {
937
+ this.root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
938
+ }
939
+ }
940
+
941
+ private updateActiveButtons(): void {
942
+ const editor = this.editor;
943
+ const model = this.model;
944
+ const selection = editor?.getSelection();
945
+ if (!editor || !model || !selection) {
946
+ return;
947
+ }
948
+
949
+ const activeTags = findActiveSsmlTags(model.getValue(), model.getOffsetAt(selection.getStartPosition()));
950
+ for (const insertion of SSML_INSERTIONS) {
951
+ const button = this.toolbarButtons.get(insertion.id);
952
+ if (button) {
953
+ button.dataset.active = String(
954
+ insertion.tagName !== undefined && activeTags.has(insertion.tagName.toLowerCase()),
955
+ );
956
+ }
957
+ }
958
+ }
959
+
960
+ private async initialize(token: number): Promise<void> {
961
+ const container = this.editorContainer;
962
+ if (!container) {
963
+ return;
964
+ }
965
+
966
+ const monacoModule = await import("monaco-editor");
967
+ if (token !== this.initializationToken || !this.isConnected) {
968
+ return;
969
+ }
970
+
971
+ const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
972
+ const editor = monacoModule.editor.create(container, {
973
+ model,
974
+ theme: this.theme,
975
+ readOnly: this.readonly,
976
+ automaticLayout: true,
977
+ minimap: { enabled: false },
978
+ wordWrap: "on",
979
+ inlayHints: { enabled: this.decorationsVisible ? "on" : "off" },
980
+ });
981
+
982
+ if (token !== this.initializationToken || !this.isConnected) {
983
+ editor.dispose();
984
+ model.dispose();
985
+ return;
986
+ }
987
+
988
+ this.monaco = monacoModule;
989
+ this.model = model;
990
+ this.editor = editor;
991
+ this.contentDisposable = editor.onDidChangeModelContent(() => {
992
+ const value = editor.getValue();
993
+ let nextValue = value;
994
+ if (this.documentState) {
995
+ this.documentState = updateEditableText(this.documentState, value);
996
+ nextValue = buildSsml(this.documentState);
997
+ }
998
+ this.valueState = nextValue;
999
+ this.updateActiveButtons();
1000
+ this.dispatchChange(nextValue);
1001
+ });
1002
+ this.cursorDisposable = editor.onDidChangeCursorPosition(() => this.updateActiveButtons());
1003
+ this.completionDisposable = registerSsmlCompletionProvider(monacoModule, {
1004
+ model,
1005
+ getOuterVoiceName: () => (this.documentState ? getEditableRegion(this.documentState).voiceName : undefined),
1006
+ });
1007
+ this.hoverDisposable = monacoModule.languages.registerHoverProvider("xml", {
1008
+ provideHover: (hoverModel, position) => {
1009
+ const target = findSsmlHoverTarget(hoverModel.getValue(), position.lineNumber, position.column);
1010
+ if (!target) {
1011
+ return undefined;
1012
+ }
1013
+
1014
+ return {
1015
+ contents: [
1016
+ {
1017
+ isTrusted: false,
1018
+ supportHtml: false,
1019
+ value: formatSsmlHover(target),
1020
+ },
1021
+ ],
1022
+ range: target.range,
1023
+ };
1024
+ },
1025
+ });
1026
+ this.updateActiveButtons();
1027
+ }
1028
+
1029
+ private readonly handleDocumentPointerDown = (event: PointerEvent): void => {
1030
+ const target = event.target;
1031
+ if (
1032
+ this.openMenu &&
1033
+ target instanceof Node &&
1034
+ !this.openMenu.contains(target) &&
1035
+ !this.openMenuTrigger?.contains(target)
1036
+ ) {
1037
+ this.closeMenu();
1038
+ }
1039
+ };
1040
+
1041
+ private readonly handleDocumentKeyDown = (event: KeyboardEvent): void => {
1042
+ if (event.key === "Escape" && this.openMenu) {
1043
+ this.closeMenu(true);
1044
+ }
1045
+ };
1046
+
1047
+ private closeMenu(restoreFocus = false): void {
1048
+ this.openMenu?.remove();
1049
+ this.openMenu = null;
1050
+ if (this.openMenuTrigger) {
1051
+ this.openMenuTrigger.setAttribute("aria-expanded", "false");
1052
+ this.openMenuTrigger.removeAttribute("aria-controls");
1053
+ if (restoreFocus) {
1054
+ this.openMenuTrigger.focus();
1055
+ }
1056
+ }
1057
+ this.openMenuTrigger = null;
1058
+ }
1059
+
1060
+ private disposeEditor(): void {
1061
+ this.closeMenu();
1062
+ this.contentDisposable?.dispose();
1063
+ this.contentDisposable = null;
1064
+ this.cursorDisposable?.dispose();
1065
+ this.cursorDisposable = null;
1066
+ this.completionDisposable?.dispose();
1067
+ this.completionDisposable = null;
1068
+ this.hoverDisposable?.dispose();
1069
+ this.hoverDisposable = null;
1070
+ this.editor?.dispose();
1071
+ this.editor = null;
1072
+ this.model?.dispose();
1073
+ this.model = null;
1074
+ this.monaco = null;
1075
+ this.root?.remove();
1076
+ this.root = null;
1077
+ this.toolbar = null;
1078
+ this.toolbarActions = null;
1079
+ this.display = null;
1080
+ this.editorContainer = null;
1081
+ this.helpPanel = null;
1082
+ this.toolbarButtons.clear();
1083
+ }
1084
+ }