@ssml-builder-js/ssml-editor-react 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/index.d.mts +16 -5
- package/dist/index.d.ts +16 -5
- package/dist/index.js +595 -68
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +593 -64
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/SsmlEditor.tsx +178 -59
- package/src/buttonVisibility.ts +1 -0
- package/src/components/VisualSsmlEditor.tsx +240 -0
- package/src/constants/azureVoiceStyleMap.generated.ts +94 -0
- package/src/constants/ssmlPresets.ts +23 -2
- package/src/formatXml.ts +1 -0
- package/src/hooks/useSsmlEditorState.ts +3 -3
- package/src/index.tsx +3 -0
- package/src/locales.ts +14 -0
- package/src/ssmlCodeLens.ts +19 -4
- package/src/ssmlCompletion.ts +20 -0
- package/src/ssmlHover.ts +68 -0
- package/src/ssmlInsertions.ts +24 -1
- package/src/styles/editorStyles.ts +8 -0
- package/test/index.test.ts +21 -2
- 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.9.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.9.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";
|
|
@@ -6,6 +6,8 @@ import { isSsmlEditorButtonVisible, type SsmlEditorButton, type SsmlEditorButton
|
|
|
6
6
|
import {
|
|
7
7
|
BREAK_TIME_DESCRIPTIONS,
|
|
8
8
|
BREAK_TIME_PRESETS,
|
|
9
|
+
AUDIO_DURATION_DESCRIPTIONS,
|
|
10
|
+
AUDIO_DURATION_PRESETS,
|
|
9
11
|
EMPHASIS_LEVEL_DESCRIPTIONS,
|
|
10
12
|
EMPHASIS_LEVEL_PRESETS,
|
|
11
13
|
EXPRESS_AS_STYLE_DESCRIPTIONS,
|
|
@@ -38,8 +40,9 @@ import { InsertionPopover } from "./components/popovers/InsertionPopover";
|
|
|
38
40
|
import { useSsmlEditorState } from "./hooks/useSsmlEditorState";
|
|
39
41
|
import { useSsmlMonaco } from "./hooks/useSsmlMonaco";
|
|
40
42
|
import { editorStyles as styles } from "./styles/editorStyles";
|
|
43
|
+
import { VisualSsmlEditor } from "./components/VisualSsmlEditor";
|
|
41
44
|
const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
|
|
42
|
-
const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence"]);
|
|
45
|
+
const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence", "mstts:audioduration"]);
|
|
43
46
|
const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
|
|
44
47
|
const TEXT_POPOVER_TAGS = new Set(["sub", "say-as", "phoneme", "w", "lang"]);
|
|
45
48
|
|
|
@@ -365,13 +368,34 @@ export const SSML_INSERTIONS = [
|
|
|
365
368
|
mode: "insert",
|
|
366
369
|
}),
|
|
367
370
|
},
|
|
371
|
+
{
|
|
372
|
+
id: "mstts:audioduration",
|
|
373
|
+
icon: "◷",
|
|
374
|
+
tagName: "mstts:audioduration",
|
|
375
|
+
selfClosing: true,
|
|
376
|
+
labels: { ja: "音声長", en: "Audio duration" },
|
|
377
|
+
descriptions: {
|
|
378
|
+
ja: "合成音声の目標時間を指定します。",
|
|
379
|
+
en: "Sets the target duration of synthesized audio.",
|
|
380
|
+
},
|
|
381
|
+
parameterDescription: {
|
|
382
|
+
ja: "目標時間を選択します。",
|
|
383
|
+
en: "Selects the target duration.",
|
|
384
|
+
},
|
|
385
|
+
options: createInsertionOptions(AUDIO_DURATION_PRESETS, AUDIO_DURATION_DESCRIPTIONS),
|
|
386
|
+
createTemplate: (value) => ({
|
|
387
|
+
prefix: `<mstts:audioduration value="${value}"/>`,
|
|
388
|
+
suffix: "",
|
|
389
|
+
mode: "insert",
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
368
392
|
] satisfies readonly SsmlInsertionDefinition[];
|
|
369
393
|
|
|
370
394
|
const DEFAULT_INSERTION_GROUPS = [
|
|
371
395
|
{
|
|
372
396
|
id: "pauses",
|
|
373
397
|
labels: { ja: "間・無音", en: "Pauses" },
|
|
374
|
-
insertionIds: ["break", "mstts:silence"],
|
|
398
|
+
insertionIds: ["break", "mstts:silence", "mstts:audioduration"],
|
|
375
399
|
},
|
|
376
400
|
{
|
|
377
401
|
id: "prosody",
|
|
@@ -614,6 +638,60 @@ const STYLE_CSS = `
|
|
|
614
638
|
> .ssml-editor-help-settings-summary::before {
|
|
615
639
|
content: "▾";
|
|
616
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
|
+
}
|
|
617
695
|
`.trim();
|
|
618
696
|
|
|
619
697
|
function injectEditorTheme(): void {
|
|
@@ -660,6 +738,8 @@ export interface SsmlEditorProps {
|
|
|
660
738
|
minHeight?: string | number;
|
|
661
739
|
/** Whether the Monaco editor is read-only. */
|
|
662
740
|
readOnly?: boolean;
|
|
741
|
+
/** Selects the XML source editor or the structured visual editor. */
|
|
742
|
+
editMode?: SsmlEditorEditMode;
|
|
663
743
|
/** Monaco theme mode. */
|
|
664
744
|
theme?: SsmlEditorTheme;
|
|
665
745
|
/** Monaco editor font size. */
|
|
@@ -700,6 +780,8 @@ export interface SsmlEditorProps {
|
|
|
700
780
|
displayStyle?: CSSProperties;
|
|
701
781
|
}
|
|
702
782
|
|
|
783
|
+
export type SsmlEditorEditMode = "code" | "visual";
|
|
784
|
+
|
|
703
785
|
export interface SelectionInfo {
|
|
704
786
|
selectedText: string;
|
|
705
787
|
characterCount: number;
|
|
@@ -791,9 +873,12 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
791
873
|
displayClassName,
|
|
792
874
|
displayStyle,
|
|
793
875
|
loadingFallback,
|
|
876
|
+
editMode: editModeProp = "code",
|
|
794
877
|
}: SsmlEditorProps,
|
|
795
878
|
ref,
|
|
796
879
|
): ReactElement {
|
|
880
|
+
const [editMode, setEditMode] = useState<SsmlEditorEditMode>(editModeProp);
|
|
881
|
+
useEffect(() => setEditMode(editModeProp), [editModeProp]);
|
|
797
882
|
const resolvedEditorOptions: SsmlEditorOptions = {
|
|
798
883
|
...settings,
|
|
799
884
|
...editorOptions,
|
|
@@ -882,6 +967,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
882
967
|
|
|
883
968
|
const {
|
|
884
969
|
editorRef,
|
|
970
|
+
draftDocument,
|
|
885
971
|
text,
|
|
886
972
|
selectionOverlay,
|
|
887
973
|
activeTags,
|
|
@@ -893,6 +979,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
893
979
|
syntaxError,
|
|
894
980
|
setSyntaxError,
|
|
895
981
|
helpPanelId,
|
|
982
|
+
commit,
|
|
896
983
|
handleTextChange,
|
|
897
984
|
handleInsert,
|
|
898
985
|
handleInsertBreak,
|
|
@@ -1187,6 +1274,27 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1187
1274
|
data-ssml-editor-toolbar-actions=""
|
|
1188
1275
|
>
|
|
1189
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>
|
|
1190
1298
|
</div>
|
|
1191
1299
|
</div>
|
|
1192
1300
|
)}
|
|
@@ -1232,65 +1340,76 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1232
1340
|
)}
|
|
1233
1341
|
</section>
|
|
1234
1342
|
)}
|
|
1235
|
-
|
|
1236
|
-
<
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
data-ssml-editor-selection-actions=""
|
|
1263
|
-
style={{
|
|
1264
|
-
...styles.selectionActions,
|
|
1265
|
-
top: selectionOverlay.position.top,
|
|
1266
|
-
left: selectionOverlay.position.left,
|
|
1267
|
-
transform:
|
|
1268
|
-
selectionOverlay.placement === "above"
|
|
1269
|
-
? "translateY(calc(-100% - 0.5rem))"
|
|
1270
|
-
: `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,
|
|
1271
1370
|
}}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
<
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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()}
|
|
1285
1391
|
>
|
|
1286
|
-
<span style={styles.
|
|
1287
|
-
|
|
1392
|
+
<span style={styles.selectionCount} aria-live="polite">
|
|
1393
|
+
{selectionOverlay.characterCount}
|
|
1394
|
+
{copy.selectionCountSuffix}
|
|
1288
1395
|
</span>
|
|
1289
|
-
{
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
+
)}
|
|
1294
1413
|
{syntaxError && (
|
|
1295
1414
|
<p style={styles.error} role="alert">
|
|
1296
1415
|
{copy.syntaxError}: {syntaxError.message}
|
package/src/buttonVisibility.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Generated by scripts/sync-azure-voices.ts. */
|
|
2
|
+
export const AZURE_VOICE_STYLE_MAP = {
|
|
3
|
+
"de-DE-ConradNeural": ["cheerful", "sad"],
|
|
4
|
+
"de-DE-KatjaNeural": ["cheerful", "sad"],
|
|
5
|
+
"en-US-AndrewNeural": ["empathetic", "relieved"],
|
|
6
|
+
"en-US-GuyNeural": [
|
|
7
|
+
"angry",
|
|
8
|
+
"cheerful",
|
|
9
|
+
"excited",
|
|
10
|
+
"friendly",
|
|
11
|
+
"hopeful",
|
|
12
|
+
"newscast",
|
|
13
|
+
"sad",
|
|
14
|
+
"shouting",
|
|
15
|
+
"terrified",
|
|
16
|
+
"unfriendly",
|
|
17
|
+
"whispering",
|
|
18
|
+
],
|
|
19
|
+
"en-US-JennyMultilingualNeural": [
|
|
20
|
+
"cheerful",
|
|
21
|
+
"empathetic",
|
|
22
|
+
"excited",
|
|
23
|
+
"friendly",
|
|
24
|
+
"hopeful",
|
|
25
|
+
"sad",
|
|
26
|
+
"shouting",
|
|
27
|
+
"terrified",
|
|
28
|
+
"unfriendly",
|
|
29
|
+
"whispering",
|
|
30
|
+
],
|
|
31
|
+
"en-US-JennyNeural": [
|
|
32
|
+
"assistant",
|
|
33
|
+
"chat",
|
|
34
|
+
"customerservice",
|
|
35
|
+
"newscast",
|
|
36
|
+
"cheerful",
|
|
37
|
+
"empathetic",
|
|
38
|
+
"excited",
|
|
39
|
+
"friendly",
|
|
40
|
+
"hopeful",
|
|
41
|
+
"sad",
|
|
42
|
+
"shouting",
|
|
43
|
+
"terrified",
|
|
44
|
+
"unfriendly",
|
|
45
|
+
"whispering",
|
|
46
|
+
],
|
|
47
|
+
"es-ES-ElviraNeural": [],
|
|
48
|
+
"fil-PH-AngeloNeural": [],
|
|
49
|
+
"fr-FR-DeniseNeural": ["cheerful", "sad"],
|
|
50
|
+
"fr-FR-HenriNeural": ["cheerful", "sad"],
|
|
51
|
+
"id-ID-GadisNeural": [],
|
|
52
|
+
"it-IT-ElsaNeural": ["cheerful", "sad"],
|
|
53
|
+
"ja-JP-KeitaNeural": ["chat"],
|
|
54
|
+
"ja-JP-MayuNeural": ["calm", "cheerful", "sad"],
|
|
55
|
+
"ja-JP-NanamiNeural": ["chat", "customerservice", "cheerful", "whispering", "sad"],
|
|
56
|
+
"ko-KR-SunHiNeural": ["cheerful", "sad"],
|
|
57
|
+
"ms-MY-YasminNeural": [],
|
|
58
|
+
"pt-BR-FranciscaNeural": ["calm"],
|
|
59
|
+
"ru-RU-SvetlanaNeural": ["cheerful", "sad", "angry", "disgruntled", "embarrassed", "fearful"],
|
|
60
|
+
"th-TH-PremwadeeNeural": [],
|
|
61
|
+
"vi-VN-HoaiMyNeural": [],
|
|
62
|
+
"zh-CN-XiaoxiaoNeural": [
|
|
63
|
+
"assistant",
|
|
64
|
+
"chat",
|
|
65
|
+
"customerservice",
|
|
66
|
+
"newscast",
|
|
67
|
+
"cheerful",
|
|
68
|
+
"empathetic",
|
|
69
|
+
"excited",
|
|
70
|
+
"friendly",
|
|
71
|
+
"hopeful",
|
|
72
|
+
"sad",
|
|
73
|
+
"terrified",
|
|
74
|
+
"whispering",
|
|
75
|
+
"poetry-reading",
|
|
76
|
+
"sports_commentary",
|
|
77
|
+
"sports_commentary_excited",
|
|
78
|
+
"story",
|
|
79
|
+
],
|
|
80
|
+
"zh-CN-YunxiNeural": [
|
|
81
|
+
"narration-relaxed",
|
|
82
|
+
"embarrassed",
|
|
83
|
+
"fearful",
|
|
84
|
+
"sad",
|
|
85
|
+
"disgruntled",
|
|
86
|
+
"serious",
|
|
87
|
+
"angry",
|
|
88
|
+
"depressed",
|
|
89
|
+
"chat",
|
|
90
|
+
"cheerful",
|
|
91
|
+
"assistant",
|
|
92
|
+
],
|
|
93
|
+
"zh-TW-HsiaoChenNeural": [],
|
|
94
|
+
} as const satisfies Readonly<Record<string, readonly string[]>>;
|