@ssml-builder-js/ssml-editor-react 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/index.d.mts +288 -0
  3. package/dist/index.d.ts +288 -0
  4. package/dist/index.js +5423 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +5385 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/e2e/monaco-editor.spec.ts +126 -0
  9. package/package.json +57 -0
  10. package/src/SsmlEditor.tsx +1302 -0
  11. package/src/buttonVisibility.ts +36 -0
  12. package/src/clearSsmlDocument.ts +57 -0
  13. package/src/components/popovers/InsertionPopover.tsx +136 -0
  14. package/src/components/popovers/InsertionPopovers.tsx +47 -0
  15. package/src/components/popovers/ProsodyPopovers.tsx +78 -0
  16. package/src/components/popovers/TextPopovers.tsx +8 -0
  17. package/src/components/popovers/TimingPopovers.tsx +8 -0
  18. package/src/constants/ssmlPresets.ts +660 -0
  19. package/src/constants/ui.ts +11 -0
  20. package/src/editableSsml.ts +251 -0
  21. package/src/formatXml.ts +600 -0
  22. package/src/hooks/useSsmlEditorState.ts +704 -0
  23. package/src/hooks/useSsmlMonaco.ts +393 -0
  24. package/src/index.tsx +50 -0
  25. package/src/locales.ts +435 -0
  26. package/src/ssmlCodeAction.ts +144 -0
  27. package/src/ssmlCodeLens.ts +226 -0
  28. package/src/ssmlCompletion.ts +129 -0
  29. package/src/ssmlContext.ts +196 -0
  30. package/src/ssmlDiagnostics.ts +191 -0
  31. package/src/ssmlHover.ts +703 -0
  32. package/src/ssmlInsertion.ts +75 -0
  33. package/src/ssmlInsertions.ts +471 -0
  34. package/src/styles/editorStyles.ts +282 -0
  35. package/test/format-xml-edge-cases.test.ts +107 -0
  36. package/test/index.test.ts +597 -0
  37. package/test/randomized-editor-invariants.test.ts +520 -0
  38. package/test/ui-components.test.tsx +854 -0
  39. package/tsconfig.json +10 -0
  40. package/tsup.config.ts +17 -0
  41. package/vitest.config.mjs +10 -0
@@ -0,0 +1,36 @@
1
+ export type SsmlEditorInsertionButton =
2
+ | "break"
3
+ | "emphasis"
4
+ | "rate"
5
+ | "pitch"
6
+ | "volume"
7
+ | "emotion"
8
+ | "say-as"
9
+ | "phoneme"
10
+ | "audio"
11
+ | "sub"
12
+ | "lang"
13
+ | "mark"
14
+ | "bookmark"
15
+ | "mstts:silence"
16
+ | "mstts:viseme"
17
+ | (string & {});
18
+
19
+ export type SsmlEditorButton =
20
+ | "help"
21
+ | SsmlEditorInsertionButton
22
+ | "undo"
23
+ | "redo"
24
+ | "clearAll"
25
+ | "format"
26
+ | "decorations"
27
+ | (string & {});
28
+
29
+ export type SsmlEditorButtonVisibility = Readonly<Partial<Record<string, boolean>>>;
30
+
31
+ export function isSsmlEditorButtonVisible(
32
+ buttonVisibility: SsmlEditorButtonVisibility | undefined,
33
+ button: SsmlEditorButton | string,
34
+ ): boolean {
35
+ return buttonVisibility?.[button] !== false;
36
+ }
@@ -0,0 +1,57 @@
1
+ import type { SsmlDocument, SsmlNode } from "@ssml-builder-js/ssml-core";
2
+
3
+ function getDocumentChildren(document: SsmlDocument): SsmlNode[] {
4
+ return document.children ?? (document.content === undefined ? [] : [document.content]);
5
+ }
6
+
7
+ function appendNode(nodes: SsmlNode[], node: SsmlNode): void {
8
+ if (typeof node === "string") {
9
+ const lastNode = nodes[nodes.length - 1];
10
+ if (typeof lastNode === "string") {
11
+ nodes[nodes.length - 1] = lastNode + node;
12
+ } else if (node !== "") {
13
+ nodes.push(node);
14
+ }
15
+ return;
16
+ }
17
+
18
+ nodes.push(node);
19
+ }
20
+
21
+ function clearNodes(nodes: SsmlNode[]): SsmlNode[] {
22
+ const clearedNodes: SsmlNode[] = [];
23
+ for (const node of nodes) {
24
+ if (typeof node === "string") {
25
+ appendNode(clearedNodes, node);
26
+ continue;
27
+ }
28
+
29
+ if (node.type === "text") {
30
+ appendNode(clearedNodes, node.value);
31
+ continue;
32
+ }
33
+
34
+ const children = clearNodes(node.children ?? []);
35
+ if (node.type === "voice") {
36
+ appendNode(clearedNodes, { ...node, children });
37
+ continue;
38
+ }
39
+
40
+ for (const child of children) {
41
+ appendNode(clearedNodes, child);
42
+ }
43
+ }
44
+
45
+ return clearedNodes;
46
+ }
47
+
48
+ export function clearSsmlDocument(document: SsmlDocument): SsmlDocument {
49
+ const nextDocument: SsmlDocument = {
50
+ ...document,
51
+ children: clearNodes(getDocumentChildren(document)),
52
+ };
53
+ if (nextDocument.content !== undefined) {
54
+ delete nextDocument.content;
55
+ }
56
+ return nextDocument;
57
+ }
@@ -0,0 +1,136 @@
1
+ import type { CSSProperties, ReactElement, Ref } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { SsmlEditorInsertionDefinition, SsmlEditorInsertionOption } from "../../SsmlEditor";
4
+ import { editorStyles as styles } from "../../styles/editorStyles";
5
+
6
+ export interface InsertionOptionGroup {
7
+ label: string;
8
+ options: readonly SsmlEditorInsertionOption[];
9
+ }
10
+
11
+ export interface InsertionPopoverProps {
12
+ insertion: SsmlEditorInsertionDefinition;
13
+ language: "ja" | "en";
14
+ isDarkTheme: boolean;
15
+ showToolbarIcons: boolean;
16
+ showToolbarText: boolean;
17
+ toolbarButtonStyle: CSSProperties;
18
+ emptyOptionsMessage: string;
19
+ isReadOnly: boolean;
20
+ isOpen: boolean;
21
+ menuPosition: { top: number; left: number } | null;
22
+ menuRef: Ref<HTMLDivElement>;
23
+ onToggle: (trigger: HTMLButtonElement) => void;
24
+ onClose: () => void;
25
+ onApply: (insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption) => void;
26
+ optionGroups?: readonly InsertionOptionGroup[];
27
+ }
28
+
29
+ function getInsertionTitle(insertion: SsmlEditorInsertionDefinition, language: "ja" | "en"): string {
30
+ if (insertion.titles) {
31
+ return insertion.titles[language];
32
+ }
33
+
34
+ const tag = insertion.tagName ? ` <${insertion.tagName}${insertion.selfClosing ? "/>" : ">"}` : "";
35
+ return `${insertion.labels[language]}${tag} — ${insertion.descriptions[language]}`;
36
+ }
37
+
38
+ export function InsertionPopover({
39
+ insertion,
40
+ language,
41
+ isDarkTheme,
42
+ showToolbarIcons,
43
+ showToolbarText,
44
+ toolbarButtonStyle,
45
+ emptyOptionsMessage,
46
+ isReadOnly,
47
+ isOpen,
48
+ menuPosition,
49
+ menuRef,
50
+ onToggle,
51
+ onClose,
52
+ onApply,
53
+ optionGroups,
54
+ }: InsertionPopoverProps): ReactElement {
55
+ const hasOptions = optionGroups
56
+ ? optionGroups.some((group) => group.options.length > 0)
57
+ : insertion.options.length > 0;
58
+ const renderOption = (option: SsmlEditorInsertionOption): ReactElement => (
59
+ <button
60
+ key={option.value}
61
+ type="button"
62
+ role="menuitem"
63
+ style={styles.toolbarOption}
64
+ title={option.descriptions?.[language] ?? insertion.descriptions[language]}
65
+ disabled={isReadOnly}
66
+ onMouseDown={(event) => event.preventDefault()}
67
+ onClick={() => {
68
+ if (!isReadOnly) {
69
+ onApply(insertion, option);
70
+ }
71
+ onClose();
72
+ }}
73
+ >
74
+ {option.labels[language]}
75
+ </button>
76
+ );
77
+ const menu =
78
+ isOpen && menuPosition && typeof document !== "undefined" ? (
79
+ <div
80
+ ref={menuRef}
81
+ id={`ssml-editor-popover-${insertion.id}`}
82
+ data-ssml-editor=""
83
+ data-theme={isDarkTheme ? "dark" : "light"}
84
+ style={{ ...styles.toolbarMenu, top: menuPosition.top, left: menuPosition.left }}
85
+ role="menu"
86
+ aria-label={insertion.labels[language]}
87
+ >
88
+ {!hasOptions ? (
89
+ <div role="presentation">
90
+ <select disabled value="" style={styles.toolbarEmptySelect}>
91
+ <option value="" disabled>
92
+ {emptyOptionsMessage}
93
+ </option>
94
+ </select>
95
+ </div>
96
+ ) : optionGroups ? (
97
+ optionGroups.map((group) => (
98
+ <fieldset key={group.label} style={styles.toolbarOptionGroup}>
99
+ <legend style={styles.toolbarOptionGroupLabel}>{group.label}</legend>
100
+ {group.options.map(renderOption)}
101
+ </fieldset>
102
+ ))
103
+ ) : (
104
+ insertion.options.map(renderOption)
105
+ )}
106
+ </div>
107
+ ) : null;
108
+
109
+ return (
110
+ <>
111
+ <div style={styles.toolbarDropdown}>
112
+ <button
113
+ type="button"
114
+ style={toolbarButtonStyle}
115
+ title={getInsertionTitle(insertion, language)}
116
+ aria-label={insertion.labels[language]}
117
+ aria-haspopup="menu"
118
+ aria-expanded={isOpen}
119
+ aria-controls={isOpen ? `ssml-editor-popover-${insertion.id}` : undefined}
120
+ onClick={(event) => onToggle(event.currentTarget)}
121
+ >
122
+ {showToolbarIcons && (
123
+ <span style={styles.toolbarIcon} aria-hidden="true">
124
+ {insertion.icon}
125
+ </span>
126
+ )}
127
+ {showToolbarText && <span>{insertion.labels[language]}</span>}
128
+ <span style={styles.toolbarChevron} aria-hidden="true">
129
+
130
+ </span>
131
+ </button>
132
+ </div>
133
+ {menu && createPortal(menu, document.body)}
134
+ </>
135
+ );
136
+ }
@@ -0,0 +1,47 @@
1
+ import type { CSSProperties, ReactElement, Ref } from "react";
2
+ import type { SsmlEditorInsertionDefinition, SsmlEditorInsertionOption } from "../../SsmlEditor";
3
+ import { InsertionPopover, type InsertionOptionGroup } from "./InsertionPopover";
4
+
5
+ export interface InsertionPopoversProps {
6
+ insertions: readonly SsmlEditorInsertionDefinition[];
7
+ language: "ja" | "en";
8
+ isDarkTheme: boolean;
9
+ showToolbarIcons: boolean;
10
+ showToolbarText: boolean;
11
+ toolbarButtonStyle: CSSProperties;
12
+ emptyOptionsMessage: string;
13
+ isReadOnly: boolean;
14
+ openPopoverId: string | null;
15
+ menuPosition: { top: number; left: number } | null;
16
+ menuRef: Ref<HTMLDivElement>;
17
+ onToggle: (id: string, trigger: HTMLButtonElement) => void;
18
+ onClose: () => void;
19
+ onApply: (insertion: SsmlEditorInsertionDefinition, option: SsmlEditorInsertionOption) => void;
20
+ emptyOptionsMessages?: Readonly<Record<string, string>>;
21
+ optionGroups?: Readonly<Record<string, readonly InsertionOptionGroup[]>>;
22
+ }
23
+
24
+ export function InsertionPopovers({
25
+ insertions,
26
+ openPopoverId,
27
+ onToggle,
28
+ emptyOptionsMessages,
29
+ optionGroups,
30
+ ...props
31
+ }: InsertionPopoversProps): ReactElement {
32
+ return (
33
+ <>
34
+ {insertions.map((insertion) => (
35
+ <InsertionPopover
36
+ key={insertion.id}
37
+ {...props}
38
+ insertion={insertion}
39
+ emptyOptionsMessage={emptyOptionsMessages?.[insertion.id] ?? props.emptyOptionsMessage}
40
+ optionGroups={optionGroups?.[insertion.id]}
41
+ isOpen={openPopoverId === insertion.id}
42
+ onToggle={(trigger) => onToggle(insertion.id, trigger)}
43
+ />
44
+ ))}
45
+ </>
46
+ );
47
+ }
@@ -0,0 +1,78 @@
1
+ import type { ReactElement } from "react";
2
+ import {
3
+ getExpressAsStyleCategory,
4
+ resolveExpressAsStyles,
5
+ type ExpressAsStyleCategory,
6
+ } from "../../constants/ssmlPresets";
7
+ import { EDITOR_COPY, type EditorCopy } from "../../locales";
8
+ import { InsertionPopovers, type InsertionPopoversProps } from "./InsertionPopovers";
9
+ import type { InsertionOptionGroup } from "./InsertionPopover";
10
+
11
+ export interface ProsodyPopoversProps extends InsertionPopoversProps {
12
+ voiceName?: string;
13
+ }
14
+
15
+ const CATEGORY_LABEL_KEYS = {
16
+ emotions: "categoryEmotions",
17
+ scenarios: "categoryScenarios",
18
+ media: "categoryMedia",
19
+ other: "categoryOther",
20
+ } as const satisfies Record<ExpressAsStyleCategory, keyof EditorCopy>;
21
+
22
+ const CATEGORY_ORDER: readonly ExpressAsStyleCategory[] = ["emotions", "scenarios", "media", "other"];
23
+
24
+ function isExpressAsInsertion(insertion: ProsodyPopoversProps["insertions"][number]): boolean {
25
+ return insertion.id === "emotion" && insertion.tagName?.toLowerCase() === "mstts:express-as";
26
+ }
27
+
28
+ function createOptionGroups(
29
+ options: ProsodyPopoversProps["insertions"][number]["options"],
30
+ language: ProsodyPopoversProps["language"],
31
+ ): readonly InsertionOptionGroup[] {
32
+ const t = (key: keyof EditorCopy): string => EDITOR_COPY[language][key];
33
+
34
+ return CATEGORY_ORDER.map((category) => ({
35
+ label: t(CATEGORY_LABEL_KEYS[category]),
36
+ options: options.filter((option) => getExpressAsStyleCategory(option.value) === category),
37
+ })).filter((group) => group.options.length > 0);
38
+ }
39
+
40
+ export function ProsodyPopovers({ insertions, voiceName, language, ...props }: ProsodyPopoversProps): ReactElement {
41
+ const emptyOptionsMessages: Record<string, string> = {};
42
+ const filteredInsertions = insertions.map((insertion) => {
43
+ if (!isExpressAsInsertion(insertion)) {
44
+ return insertion;
45
+ }
46
+
47
+ const availableStyles = new Set(
48
+ resolveExpressAsStyles(
49
+ voiceName,
50
+ insertion.options.map((option) => option.value),
51
+ ),
52
+ );
53
+ if (availableStyles.size === 0) {
54
+ emptyOptionsMessages[insertion.id] = EDITOR_COPY[language].styleNotSupported;
55
+ }
56
+ const availableOptions = insertion.options.filter((option) => availableStyles.has(option.value));
57
+
58
+ return {
59
+ ...insertion,
60
+ options: availableOptions,
61
+ };
62
+ });
63
+ const optionGroups = Object.fromEntries(
64
+ filteredInsertions
65
+ .filter(isExpressAsInsertion)
66
+ .map((insertion) => [insertion.id, createOptionGroups(insertion.options, language)]),
67
+ );
68
+
69
+ return (
70
+ <InsertionPopovers
71
+ {...props}
72
+ emptyOptionsMessages={emptyOptionsMessages}
73
+ language={language}
74
+ insertions={filteredInsertions}
75
+ optionGroups={optionGroups}
76
+ />
77
+ );
78
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactElement } from "react";
2
+ import { InsertionPopovers, type InsertionPopoversProps } from "./InsertionPopovers";
3
+
4
+ export type TextPopoversProps = InsertionPopoversProps;
5
+
6
+ export function TextPopovers(props: TextPopoversProps): ReactElement {
7
+ return <InsertionPopovers {...props} />;
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactElement } from "react";
2
+ import { InsertionPopovers, type InsertionPopoversProps } from "./InsertionPopovers";
3
+
4
+ export type TimingPopoversProps = InsertionPopoversProps;
5
+
6
+ export function TimingPopovers(props: TimingPopoversProps): ReactElement {
7
+ return <InsertionPopovers {...props} />;
8
+ }