@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.
- package/CHANGELOG.md +91 -0
- package/dist/index.d.mts +288 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +5423 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5385 -0
- package/dist/index.mjs.map +1 -0
- package/e2e/monaco-editor.spec.ts +126 -0
- package/package.json +57 -0
- package/src/SsmlEditor.tsx +1302 -0
- package/src/buttonVisibility.ts +36 -0
- package/src/clearSsmlDocument.ts +57 -0
- package/src/components/popovers/InsertionPopover.tsx +136 -0
- package/src/components/popovers/InsertionPopovers.tsx +47 -0
- package/src/components/popovers/ProsodyPopovers.tsx +78 -0
- package/src/components/popovers/TextPopovers.tsx +8 -0
- package/src/components/popovers/TimingPopovers.tsx +8 -0
- package/src/constants/ssmlPresets.ts +660 -0
- package/src/constants/ui.ts +11 -0
- package/src/editableSsml.ts +251 -0
- package/src/formatXml.ts +600 -0
- package/src/hooks/useSsmlEditorState.ts +704 -0
- package/src/hooks/useSsmlMonaco.ts +393 -0
- package/src/index.tsx +50 -0
- package/src/locales.ts +435 -0
- package/src/ssmlCodeAction.ts +144 -0
- package/src/ssmlCodeLens.ts +226 -0
- package/src/ssmlCompletion.ts +129 -0
- package/src/ssmlContext.ts +196 -0
- package/src/ssmlDiagnostics.ts +191 -0
- package/src/ssmlHover.ts +703 -0
- package/src/ssmlInsertion.ts +75 -0
- package/src/ssmlInsertions.ts +471 -0
- package/src/styles/editorStyles.ts +282 -0
- package/test/format-xml-edge-cases.test.ts +107 -0
- package/test/index.test.ts +597 -0
- package/test/randomized-editor-invariants.test.ts +520 -0
- package/test/ui-components.test.tsx +854 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +17 -0
- 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
|
+
}
|