@ssml-builder-js/ssml-editor-react 2.8.1 → 2.10.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 +22 -0
- package/dist/index.d.mts +13 -2
- package/dist/index.d.ts +13 -2
- package/dist/index.js +414 -62
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +412 -58
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/SsmlEditor.tsx +153 -57
- package/src/components/VisualSsmlEditor.tsx +240 -0
- package/src/constants/ssmlPresets.ts +5 -0
- package/src/index.tsx +3 -0
- package/src/ssmlCompletion.ts +16 -0
- package/src/ssmlHover.ts +57 -0
- package/src/styles/editorStyles.ts +8 -0
- package/test/ui-components.test.tsx +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssml-builder-js/ssml-editor-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "React-based SSML editor component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@ssml-builder-js/ssml-core": "^2.
|
|
55
|
+
"@ssml-builder-js/ssml-core": "^2.10.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/SsmlEditor.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
|
1
|
+
import { Fragment, forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
|
2
2
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
3
3
|
import Editor from "@monaco-editor/react";
|
|
4
4
|
import type { SsmlDocument } from "@ssml-builder-js/ssml-core";
|
|
@@ -40,6 +40,7 @@ import { InsertionPopover } from "./components/popovers/InsertionPopover";
|
|
|
40
40
|
import { useSsmlEditorState } from "./hooks/useSsmlEditorState";
|
|
41
41
|
import { useSsmlMonaco } from "./hooks/useSsmlMonaco";
|
|
42
42
|
import { editorStyles as styles } from "./styles/editorStyles";
|
|
43
|
+
import { VisualSsmlEditor } from "./components/VisualSsmlEditor";
|
|
43
44
|
const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
|
|
44
45
|
const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence", "mstts:audioduration"]);
|
|
45
46
|
const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
|
|
@@ -637,6 +638,60 @@ const STYLE_CSS = `
|
|
|
637
638
|
> .ssml-editor-help-settings-summary::before {
|
|
638
639
|
content: "▾";
|
|
639
640
|
}
|
|
641
|
+
[data-ssml-editor] .ssml-editor-visual-layout {
|
|
642
|
+
display: grid;
|
|
643
|
+
grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
|
|
644
|
+
gap: 1rem;
|
|
645
|
+
}
|
|
646
|
+
[data-ssml-editor] .ssml-editor-visual-tree,
|
|
647
|
+
[data-ssml-editor] .ssml-editor-visual-form {
|
|
648
|
+
display: grid;
|
|
649
|
+
gap: 0.5rem;
|
|
650
|
+
align-content: start;
|
|
651
|
+
}
|
|
652
|
+
[data-ssml-editor] .ssml-editor-visual-tree ul {
|
|
653
|
+
margin: 0;
|
|
654
|
+
padding-left: 1.25rem;
|
|
655
|
+
}
|
|
656
|
+
[data-ssml-editor] .ssml-editor-visual-tree button,
|
|
657
|
+
[data-ssml-editor] .ssml-editor-visual-breadcrumb button,
|
|
658
|
+
[data-ssml-editor] .ssml-editor-visual-actions button {
|
|
659
|
+
padding: 0.35rem 0.5rem;
|
|
660
|
+
border: 1px solid var(--ssml-editor-control-border);
|
|
661
|
+
border-radius: 0.25rem;
|
|
662
|
+
color: var(--ssml-editor-color);
|
|
663
|
+
background: var(--ssml-editor-control-bg);
|
|
664
|
+
cursor: pointer;
|
|
665
|
+
}
|
|
666
|
+
[data-ssml-editor] .ssml-editor-visual-tree button[aria-current] {
|
|
667
|
+
border-color: var(--ssml-editor-active-border);
|
|
668
|
+
background: var(--ssml-editor-active-bg);
|
|
669
|
+
}
|
|
670
|
+
[data-ssml-editor] .ssml-editor-visual-breadcrumb,
|
|
671
|
+
[data-ssml-editor] .ssml-editor-visual-actions {
|
|
672
|
+
display: flex;
|
|
673
|
+
flex-wrap: wrap;
|
|
674
|
+
gap: 0.4rem;
|
|
675
|
+
align-items: center;
|
|
676
|
+
}
|
|
677
|
+
[data-ssml-editor] .ssml-editor-visual-form textarea {
|
|
678
|
+
box-sizing: border-box;
|
|
679
|
+
width: 100%;
|
|
680
|
+
min-height: 6rem;
|
|
681
|
+
padding: 0.5rem;
|
|
682
|
+
color: var(--ssml-editor-color);
|
|
683
|
+
background: var(--ssml-editor-control-bg);
|
|
684
|
+
border: 1px solid var(--ssml-editor-control-border);
|
|
685
|
+
border-radius: 0.25rem;
|
|
686
|
+
font: inherit;
|
|
687
|
+
}
|
|
688
|
+
[data-ssml-editor] .ssml-editor-visual-errors {
|
|
689
|
+
padding: 0.5rem;
|
|
690
|
+
color: var(--ssml-editor-error);
|
|
691
|
+
background: var(--ssml-editor-error-bg);
|
|
692
|
+
border: 1px solid var(--ssml-editor-error);
|
|
693
|
+
border-radius: 0.25rem;
|
|
694
|
+
}
|
|
640
695
|
`.trim();
|
|
641
696
|
|
|
642
697
|
function injectEditorTheme(): void {
|
|
@@ -683,6 +738,8 @@ export interface SsmlEditorProps {
|
|
|
683
738
|
minHeight?: string | number;
|
|
684
739
|
/** Whether the Monaco editor is read-only. */
|
|
685
740
|
readOnly?: boolean;
|
|
741
|
+
/** Selects the XML source editor or the structured visual editor. */
|
|
742
|
+
editMode?: SsmlEditorEditMode;
|
|
686
743
|
/** Monaco theme mode. */
|
|
687
744
|
theme?: SsmlEditorTheme;
|
|
688
745
|
/** Monaco editor font size. */
|
|
@@ -723,6 +780,8 @@ export interface SsmlEditorProps {
|
|
|
723
780
|
displayStyle?: CSSProperties;
|
|
724
781
|
}
|
|
725
782
|
|
|
783
|
+
export type SsmlEditorEditMode = "code" | "visual";
|
|
784
|
+
|
|
726
785
|
export interface SelectionInfo {
|
|
727
786
|
selectedText: string;
|
|
728
787
|
characterCount: number;
|
|
@@ -814,9 +873,12 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
814
873
|
displayClassName,
|
|
815
874
|
displayStyle,
|
|
816
875
|
loadingFallback,
|
|
876
|
+
editMode: editModeProp = "code",
|
|
817
877
|
}: SsmlEditorProps,
|
|
818
878
|
ref,
|
|
819
879
|
): ReactElement {
|
|
880
|
+
const [editMode, setEditMode] = useState<SsmlEditorEditMode>(editModeProp);
|
|
881
|
+
useEffect(() => setEditMode(editModeProp), [editModeProp]);
|
|
820
882
|
const resolvedEditorOptions: SsmlEditorOptions = {
|
|
821
883
|
...settings,
|
|
822
884
|
...editorOptions,
|
|
@@ -905,6 +967,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
905
967
|
|
|
906
968
|
const {
|
|
907
969
|
editorRef,
|
|
970
|
+
draftDocument,
|
|
908
971
|
text,
|
|
909
972
|
selectionOverlay,
|
|
910
973
|
activeTags,
|
|
@@ -916,6 +979,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
916
979
|
syntaxError,
|
|
917
980
|
setSyntaxError,
|
|
918
981
|
helpPanelId,
|
|
982
|
+
commit,
|
|
919
983
|
handleTextChange,
|
|
920
984
|
handleInsert,
|
|
921
985
|
handleInsertBreak,
|
|
@@ -1210,6 +1274,27 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1210
1274
|
data-ssml-editor-toolbar-actions=""
|
|
1211
1275
|
>
|
|
1212
1276
|
{renderToolbarItems()}
|
|
1277
|
+
<span style={styles.toolbarSeparator} aria-hidden="true" />
|
|
1278
|
+
<button
|
|
1279
|
+
type="button"
|
|
1280
|
+
style={
|
|
1281
|
+
editMode === "visual" ? { ...toolbarButtonStyle, ...styles.toolbarButtonActive } : toolbarButtonStyle
|
|
1282
|
+
}
|
|
1283
|
+
aria-pressed={editMode === "visual"}
|
|
1284
|
+
onClick={() => setEditMode("visual")}
|
|
1285
|
+
>
|
|
1286
|
+
Visual
|
|
1287
|
+
</button>
|
|
1288
|
+
<button
|
|
1289
|
+
type="button"
|
|
1290
|
+
style={
|
|
1291
|
+
editMode === "code" ? { ...toolbarButtonStyle, ...styles.toolbarButtonActive } : toolbarButtonStyle
|
|
1292
|
+
}
|
|
1293
|
+
aria-pressed={editMode === "code"}
|
|
1294
|
+
onClick={() => setEditMode("code")}
|
|
1295
|
+
>
|
|
1296
|
+
Code
|
|
1297
|
+
</button>
|
|
1213
1298
|
</div>
|
|
1214
1299
|
</div>
|
|
1215
1300
|
)}
|
|
@@ -1255,65 +1340,76 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1255
1340
|
)}
|
|
1256
1341
|
</section>
|
|
1257
1342
|
)}
|
|
1258
|
-
|
|
1259
|
-
<
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
data-ssml-editor-selection-actions=""
|
|
1286
|
-
style={{
|
|
1287
|
-
...styles.selectionActions,
|
|
1288
|
-
top: selectionOverlay.position.top,
|
|
1289
|
-
left: selectionOverlay.position.left,
|
|
1290
|
-
transform:
|
|
1291
|
-
selectionOverlay.placement === "above"
|
|
1292
|
-
? "translateY(calc(-100% - 0.5rem))"
|
|
1293
|
-
: `translateY(calc(${selectionOverlay.position.height}px + 0.5rem))`,
|
|
1343
|
+
{editMode === "visual" ? (
|
|
1344
|
+
<div style={styles.visual}>
|
|
1345
|
+
<VisualSsmlEditor
|
|
1346
|
+
document={draftDocument}
|
|
1347
|
+
readOnly={isReadOnly}
|
|
1348
|
+
onChange={commit}
|
|
1349
|
+
onPreviewSelection={onPreviewSelection}
|
|
1350
|
+
/>
|
|
1351
|
+
</div>
|
|
1352
|
+
) : (
|
|
1353
|
+
<div style={{ ...styles.editor, minHeight: editorMinHeight }}>
|
|
1354
|
+
<Editor
|
|
1355
|
+
height={editorHeight}
|
|
1356
|
+
language="xml"
|
|
1357
|
+
theme={isDarkTheme ? "vs-dark" : "light"}
|
|
1358
|
+
options={{
|
|
1359
|
+
automaticLayout: resolvedEditorOptions.automaticLayout ?? true,
|
|
1360
|
+
autoClosingBrackets: "never",
|
|
1361
|
+
fontSize: resolvedEditorOptions.fontSize,
|
|
1362
|
+
hover: { enabled: "on" },
|
|
1363
|
+
inlayHints: { enabled: decorationsVisible ? "on" : "off" },
|
|
1364
|
+
lineNumbers: resolvedEditorOptions.lineNumbers,
|
|
1365
|
+
minimap: {
|
|
1366
|
+
enabled: resolvedEditorOptions.minimap ?? true,
|
|
1367
|
+
},
|
|
1368
|
+
readOnly: isReadOnly,
|
|
1369
|
+
wordWrap: resolvedEditorOptions.wordWrap,
|
|
1294
1370
|
}}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
<
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1371
|
+
value={text}
|
|
1372
|
+
loading={loadingFallback}
|
|
1373
|
+
onMount={onMount}
|
|
1374
|
+
onChange={(value) => handleTextChange(value ?? "")}
|
|
1375
|
+
/>
|
|
1376
|
+
{selectionOverlay.hasSelection && selectionOverlay.position && (
|
|
1377
|
+
<div
|
|
1378
|
+
role="toolbar"
|
|
1379
|
+
aria-label={copy.selectionActions}
|
|
1380
|
+
data-ssml-editor-selection-actions=""
|
|
1381
|
+
style={{
|
|
1382
|
+
...styles.selectionActions,
|
|
1383
|
+
top: selectionOverlay.position.top,
|
|
1384
|
+
left: selectionOverlay.position.left,
|
|
1385
|
+
transform:
|
|
1386
|
+
selectionOverlay.placement === "above"
|
|
1387
|
+
? "translateY(calc(-100% - 0.5rem))"
|
|
1388
|
+
: `translateY(calc(${selectionOverlay.position.height}px + 0.5rem))`,
|
|
1389
|
+
}}
|
|
1390
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1308
1391
|
>
|
|
1309
|
-
<span style={styles.
|
|
1310
|
-
|
|
1392
|
+
<span style={styles.selectionCount} aria-live="polite">
|
|
1393
|
+
{selectionOverlay.characterCount}
|
|
1394
|
+
{copy.selectionCountSuffix}
|
|
1311
1395
|
</span>
|
|
1312
|
-
{
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1396
|
+
<span style={styles.selectionActionsDivider} aria-hidden="true" />
|
|
1397
|
+
<button
|
|
1398
|
+
type="button"
|
|
1399
|
+
style={styles.selectionActionButton}
|
|
1400
|
+
title={copy.previewSelectionTitle}
|
|
1401
|
+
disabled={!canPreviewSelection}
|
|
1402
|
+
onClick={previewSelection}
|
|
1403
|
+
>
|
|
1404
|
+
<span style={styles.selectionActionIcon} aria-hidden="true">
|
|
1405
|
+
▶
|
|
1406
|
+
</span>
|
|
1407
|
+
{copy.previewSelection}
|
|
1408
|
+
</button>
|
|
1409
|
+
</div>
|
|
1410
|
+
)}
|
|
1411
|
+
</div>
|
|
1412
|
+
)}
|
|
1317
1413
|
{syntaxError && (
|
|
1318
1414
|
<p style={styles.error} role="alert">
|
|
1319
1415
|
{copy.syntaxError}: {syntaxError.message}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import {
|
|
4
|
+
buildSsml,
|
|
5
|
+
validateAzureSsml,
|
|
6
|
+
type SsmlDocument,
|
|
7
|
+
type SsmlElement,
|
|
8
|
+
type SsmlNode,
|
|
9
|
+
} from "@ssml-builder-js/ssml-core";
|
|
10
|
+
|
|
11
|
+
export interface VisualSsmlEditorProps {
|
|
12
|
+
document: SsmlDocument;
|
|
13
|
+
readOnly?: boolean;
|
|
14
|
+
onChange?: (document: SsmlDocument) => void;
|
|
15
|
+
onPreviewSelection?: (ssml: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TextLeaf {
|
|
19
|
+
path: number[];
|
|
20
|
+
value: string;
|
|
21
|
+
ancestors: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isElement(node: SsmlNode): node is SsmlElement {
|
|
25
|
+
return typeof node !== "string" && node.type !== "text";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function elementLabel(element: SsmlElement): string {
|
|
29
|
+
return element.type === "custom" || element.type === "element" ? element.name : element.type;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collectTextLeaves(nodes: SsmlNode[], path: number[] = [], ancestors: string[] = []): TextLeaf[] {
|
|
33
|
+
return nodes.flatMap((node, index) => {
|
|
34
|
+
const currentPath = [...path, index];
|
|
35
|
+
if (typeof node === "string") return [{ ancestors, path: currentPath, value: node }];
|
|
36
|
+
if (node.type === "text") return [{ ancestors, path: currentPath, value: node.value }];
|
|
37
|
+
return collectTextLeaves(node.children ?? [], currentPath, [...ancestors, elementLabel(node)]);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateNodesAtPath(
|
|
42
|
+
nodes: SsmlNode[],
|
|
43
|
+
path: number[],
|
|
44
|
+
update: (node: SsmlNode) => SsmlNode | SsmlNode[],
|
|
45
|
+
): SsmlNode[] {
|
|
46
|
+
if (path.length === 0) return nodes;
|
|
47
|
+
const [index, ...rest] = path;
|
|
48
|
+
return nodes.flatMap((node, nodeIndex) => {
|
|
49
|
+
if (nodeIndex !== index) return [node];
|
|
50
|
+
if (rest.length === 0) {
|
|
51
|
+
const next = update(node);
|
|
52
|
+
return Array.isArray(next) ? next : [next];
|
|
53
|
+
}
|
|
54
|
+
if (!isElement(node)) return [node];
|
|
55
|
+
return [{ ...node, children: updateNodesAtPath(node.children ?? [], rest, update) }];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function updateTextAtPath(document: SsmlDocument, path: number[], value: string): SsmlDocument {
|
|
60
|
+
return { ...document, children: updateNodesAtPath(document.children ?? [], path, () => value) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function wrapTextAtPath(
|
|
64
|
+
document: SsmlDocument,
|
|
65
|
+
path: number[],
|
|
66
|
+
start: number,
|
|
67
|
+
end: number,
|
|
68
|
+
tag: SsmlElement["type"],
|
|
69
|
+
attributes: Record<string, string>,
|
|
70
|
+
): SsmlDocument {
|
|
71
|
+
return {
|
|
72
|
+
...document,
|
|
73
|
+
children: updateNodesAtPath(document.children ?? [], path, (node) => {
|
|
74
|
+
const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
|
|
75
|
+
if (!value || start === end) return node;
|
|
76
|
+
if (tag === "break") {
|
|
77
|
+
return [
|
|
78
|
+
value.slice(0, start),
|
|
79
|
+
{ type: tag, attributes, children: [] } as SsmlElement,
|
|
80
|
+
value.slice(start),
|
|
81
|
+
].filter((part) => (typeof part === "string" ? part.length > 0 : true));
|
|
82
|
+
}
|
|
83
|
+
const selected = value.slice(start, end);
|
|
84
|
+
const wrapped = { type: tag, attributes, children: [selected] } as SsmlElement;
|
|
85
|
+
return [value.slice(0, start), wrapped, value.slice(end)].filter((part) =>
|
|
86
|
+
typeof part === "string" ? part.length > 0 : true,
|
|
87
|
+
);
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function TreeNode({
|
|
93
|
+
node,
|
|
94
|
+
path,
|
|
95
|
+
selectedPath,
|
|
96
|
+
onSelect,
|
|
97
|
+
}: {
|
|
98
|
+
node: SsmlNode;
|
|
99
|
+
path: number[];
|
|
100
|
+
selectedPath: number[] | null;
|
|
101
|
+
onSelect: (path: number[]) => void;
|
|
102
|
+
}): ReactElement | null {
|
|
103
|
+
if (!isElement(node)) return null;
|
|
104
|
+
const name = elementLabel(node);
|
|
105
|
+
const isSelected = selectedPath?.join(".") === path.join(".");
|
|
106
|
+
return (
|
|
107
|
+
<li>
|
|
108
|
+
<button type="button" aria-current={isSelected ? "true" : undefined} onClick={() => onSelect(path)}>
|
|
109
|
+
{`<${name}>`}
|
|
110
|
+
</button>
|
|
111
|
+
{(node.children ?? []).length > 0 && (
|
|
112
|
+
<ul>
|
|
113
|
+
{node.children?.map((child, index) => (
|
|
114
|
+
<TreeNode
|
|
115
|
+
key={`${path.join(".")}/${isElement(child) ? elementLabel(child) : JSON.stringify(child)}`}
|
|
116
|
+
node={child}
|
|
117
|
+
path={[...path, index]}
|
|
118
|
+
selectedPath={selectedPath}
|
|
119
|
+
onSelect={onSelect}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
)}
|
|
124
|
+
</li>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function VisualSsmlEditor({
|
|
129
|
+
document,
|
|
130
|
+
readOnly = false,
|
|
131
|
+
onChange,
|
|
132
|
+
onPreviewSelection,
|
|
133
|
+
}: VisualSsmlEditorProps): ReactElement {
|
|
134
|
+
const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
|
|
135
|
+
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
136
|
+
const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
|
|
137
|
+
const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
|
|
138
|
+
const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
|
|
139
|
+
const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
|
|
140
|
+
|
|
141
|
+
const applyWrapper = (tag: SsmlElement["type"], attributes: Record<string, string>): void => {
|
|
142
|
+
if (readOnly || !selectedLeaf) return;
|
|
143
|
+
const start = selection.start === selection.end ? 0 : selection.start;
|
|
144
|
+
const end = selection.start === selection.end ? selectedLeaf.value.length : selection.end;
|
|
145
|
+
commit(wrapTextAtPath(document, selectedLeaf.path, start, end, tag, attributes));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="ssml-editor-visual" data-ssml-editor-visual="">
|
|
150
|
+
<fieldset className="ssml-editor-visual-breadcrumb">
|
|
151
|
+
<legend>SSML structure breadcrumb</legend>
|
|
152
|
+
<span><speak></span>
|
|
153
|
+
{selectedLeaf?.ancestors.map((ancestor, index) => (
|
|
154
|
+
<span key={selectedLeaf?.ancestors.slice(0, index + 1).join("/")}> / <{ancestor}></span>
|
|
155
|
+
))}
|
|
156
|
+
{selectedPath && (
|
|
157
|
+
<button type="button" onClick={() => setSelectedPath(null)}>
|
|
158
|
+
Clear parent
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
</fieldset>
|
|
162
|
+
{diagnostics.length > 0 && (
|
|
163
|
+
<div role="alert" aria-invalid="true" className="ssml-editor-visual-errors">
|
|
164
|
+
{diagnostics.map((diagnostic) => (
|
|
165
|
+
<div key={`${diagnostic.code}-${diagnostic.message}`}>{diagnostic.message}</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
<div className="ssml-editor-visual-layout">
|
|
170
|
+
<nav aria-label="SSML structure tree" className="ssml-editor-visual-tree">
|
|
171
|
+
<strong>Structure</strong>
|
|
172
|
+
<ul>
|
|
173
|
+
{document.children?.map((node, index) => (
|
|
174
|
+
<TreeNode
|
|
175
|
+
key={isElement(node) ? elementLabel(node) : JSON.stringify(node)}
|
|
176
|
+
node={node}
|
|
177
|
+
path={[index]}
|
|
178
|
+
selectedPath={selectedPath}
|
|
179
|
+
onSelect={setSelectedPath}
|
|
180
|
+
/>
|
|
181
|
+
))}
|
|
182
|
+
</ul>
|
|
183
|
+
</nav>
|
|
184
|
+
<div className="ssml-editor-visual-form">
|
|
185
|
+
{selectedLeaf ? (
|
|
186
|
+
<>
|
|
187
|
+
<label>
|
|
188
|
+
Text
|
|
189
|
+
<textarea
|
|
190
|
+
value={selectedLeaf.value}
|
|
191
|
+
readOnly={readOnly}
|
|
192
|
+
onSelect={(event) =>
|
|
193
|
+
setSelection({ start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd })
|
|
194
|
+
}
|
|
195
|
+
onChange={(event) => commit(updateTextAtPath(document, selectedLeaf.path, event.target.value))}
|
|
196
|
+
/>
|
|
197
|
+
</label>
|
|
198
|
+
<fieldset className="ssml-editor-visual-actions">
|
|
199
|
+
<legend>Apply SSML formatting</legend>
|
|
200
|
+
<button type="button" disabled={readOnly} onClick={() => applyWrapper("prosody", { rate: "slow" })}>
|
|
201
|
+
Rate
|
|
202
|
+
</button>
|
|
203
|
+
<button type="button" disabled={readOnly} onClick={() => applyWrapper("prosody", { pitch: "high" })}>
|
|
204
|
+
Pitch
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
disabled={readOnly}
|
|
209
|
+
onClick={() => applyWrapper("mstts:express-as", { style: "cheerful" })}
|
|
210
|
+
>
|
|
211
|
+
Emotion
|
|
212
|
+
</button>
|
|
213
|
+
<button type="button" disabled={readOnly} onClick={() => applyWrapper("break", { time: "500ms" })}>
|
|
214
|
+
Pause
|
|
215
|
+
</button>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
disabled={readOnly}
|
|
219
|
+
onClick={() => applyWrapper("phoneme", { alphabet: "ipa", ph: selectedLeaf.value })}
|
|
220
|
+
>
|
|
221
|
+
Pronunciation
|
|
222
|
+
</button>
|
|
223
|
+
{onPreviewSelection && (
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => onPreviewSelection(buildSsml({ ...document, children: [selectedLeaf.value] }))}
|
|
227
|
+
>
|
|
228
|
+
Preview selection
|
|
229
|
+
</button>
|
|
230
|
+
)}
|
|
231
|
+
</fieldset>
|
|
232
|
+
</>
|
|
233
|
+
) : (
|
|
234
|
+
<p>Select an element or text node to edit it.</p>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -322,6 +322,11 @@ export const SSML_ATTRIBUTE_PRESETS = {
|
|
|
322
322
|
"mstts:audioduration": {
|
|
323
323
|
value: AUDIO_DURATION_PRESETS,
|
|
324
324
|
},
|
|
325
|
+
"mstts:backgroundaudio": {
|
|
326
|
+
volume: PROSODY_VOLUME_PRESETS,
|
|
327
|
+
fadein: AUDIO_DURATION_PRESETS,
|
|
328
|
+
fadeout: AUDIO_DURATION_PRESETS,
|
|
329
|
+
},
|
|
325
330
|
silence: {
|
|
326
331
|
type: SILENCE_TYPE_PRESETS,
|
|
327
332
|
value: SILENCE_VALUE_PRESETS,
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { SsmlEditor } from "./SsmlEditor";
|
|
6
|
+
export { VisualSsmlEditor } from "./components/VisualSsmlEditor";
|
|
7
|
+
export type { VisualSsmlEditorProps } from "./components/VisualSsmlEditor";
|
|
6
8
|
export type {
|
|
7
9
|
SsmlEditorButton,
|
|
8
10
|
SsmlEditorButtonVisibility,
|
|
@@ -21,6 +23,7 @@ export type {
|
|
|
21
23
|
SsmlEditorOptions,
|
|
22
24
|
SsmlEditorRef,
|
|
23
25
|
SsmlEditorProps,
|
|
26
|
+
SsmlEditorEditMode,
|
|
24
27
|
SsmlEditorToolbarGroup,
|
|
25
28
|
SelectionInfo,
|
|
26
29
|
SsmlEditorTheme,
|
package/src/ssmlCompletion.ts
CHANGED
|
@@ -46,6 +46,22 @@ const SSML_COMPLETION_SNIPPETS = [
|
|
|
46
46
|
label: "mstts:audioduration",
|
|
47
47
|
insertText: '<mstts:audioduration value="10s" />',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
label: "mstts:dialog",
|
|
51
|
+
insertText: `<mstts:dialog>\n <mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>\n</mstts:dialog>`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
label: "mstts:turn",
|
|
55
|
+
insertText: `<mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>`,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "mstts:backgroundaudio",
|
|
59
|
+
insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:-3dB}" />`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: "mstts:ttsembedding",
|
|
63
|
+
insertText: `<mstts:ttsembedding>\${1:text}</mstts:ttsembedding>`,
|
|
64
|
+
},
|
|
49
65
|
{
|
|
50
66
|
label: "sub",
|
|
51
67
|
insertText: `<sub alias="\${1:読み}">\${2:漢字}</sub>`,
|
package/src/ssmlHover.ts
CHANGED
|
@@ -339,6 +339,63 @@ const SSML_TAG_DEFINITIONS: readonly SsmlTagDefinition[] = [
|
|
|
339
339
|
},
|
|
340
340
|
],
|
|
341
341
|
},
|
|
342
|
+
{
|
|
343
|
+
name: "mstts:dialog",
|
|
344
|
+
description: "Groups multiple Azure dialog turns that can use different voices.",
|
|
345
|
+
parameters: [],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "mstts:turn",
|
|
349
|
+
description: "Adds one dialog turn using the required Azure voice name.",
|
|
350
|
+
parameters: [
|
|
351
|
+
{
|
|
352
|
+
name: "voice",
|
|
353
|
+
description: "The Azure voice used for this turn, such as `en-US-JennyNeural`.",
|
|
354
|
+
example: "en-US-JennyNeural",
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "mstts:backgroundaudio",
|
|
360
|
+
description: "Plays background audio while speech is synthesized.",
|
|
361
|
+
parameters: [
|
|
362
|
+
{
|
|
363
|
+
name: "src",
|
|
364
|
+
description: "An absolute HTTP(S) URL for the background audio file.",
|
|
365
|
+
example: "https://example.com/music.mp3",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "volume",
|
|
369
|
+
description: "The background audio volume, for example `-3dB` or `medium`.",
|
|
370
|
+
example: "-3dB",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "fadein",
|
|
374
|
+
description: "The fade-in duration, for example `1s`.",
|
|
375
|
+
example: "1s",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: "fadeout",
|
|
379
|
+
description: "The fade-out duration, for example `500ms`.",
|
|
380
|
+
example: "500ms",
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: "mstts:ttsembedding",
|
|
386
|
+
description: "Embeds custom voice or speaker profile metadata for Azure Speech.",
|
|
387
|
+
parameters: [],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "mstts:embedding",
|
|
391
|
+
description: "Specifies embedding metadata for custom voice scenarios.",
|
|
392
|
+
parameters: [],
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: "mstts:voiceconversion",
|
|
396
|
+
description: "Specifies voice conversion metadata for custom voice scenarios.",
|
|
397
|
+
parameters: [],
|
|
398
|
+
},
|
|
342
399
|
] as const;
|
|
343
400
|
|
|
344
401
|
export { SSML_TAG_DEFINITIONS };
|
|
@@ -82,6 +82,14 @@ export const editorStyles: Record<string, CSSProperties> = {
|
|
|
82
82
|
display: "grid",
|
|
83
83
|
gap: "0.5rem",
|
|
84
84
|
},
|
|
85
|
+
visual: {
|
|
86
|
+
display: "grid",
|
|
87
|
+
gap: "0.75rem",
|
|
88
|
+
minHeight: "8rem",
|
|
89
|
+
padding: "0.75rem",
|
|
90
|
+
border: "1px solid var(--ssml-editor-control-border)",
|
|
91
|
+
borderRadius: "0.25rem",
|
|
92
|
+
},
|
|
85
93
|
toolbarIconOnly: {
|
|
86
94
|
justifyContent: "center",
|
|
87
95
|
minWidth: "2.25rem",
|