@ssml-builder-js/ssml-editor-react 2.11.0 → 2.13.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 +17 -0
- package/dist/index.d.mts +61 -11
- package/dist/index.d.ts +61 -11
- package/dist/index.js +505 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +504 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/SsmlEditor.tsx +31 -1
- package/src/components/VisualSsmlEditor.tsx +372 -24
- package/src/index.tsx +9 -2
- package/src/locales.ts +226 -0
- package/test/ui-components.test.tsx +78 -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.13.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.13.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/SsmlEditor.tsx
CHANGED
|
@@ -40,7 +40,12 @@ 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 {
|
|
43
|
+
import {
|
|
44
|
+
VisualSsmlEditor,
|
|
45
|
+
type VisualInspectorRenderer,
|
|
46
|
+
type VisualVoiceCatalogEntry,
|
|
47
|
+
type VoiceSelectorRenderProps,
|
|
48
|
+
} from "./components/VisualSsmlEditor";
|
|
44
49
|
const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
|
|
45
50
|
const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence", "mstts:audioduration"]);
|
|
46
51
|
const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
|
|
@@ -764,6 +769,18 @@ export interface SsmlEditorProps {
|
|
|
764
769
|
customInsertions?: SsmlEditorCustomInsertionCollection;
|
|
765
770
|
/** Adds insertion definitions without replacing built-in definitions. */
|
|
766
771
|
additionalInsertions?: SsmlEditorCustomInsertionCollection;
|
|
772
|
+
/** Replaces the visual inspector for an element type or serialized tag name. */
|
|
773
|
+
customInspectors?: Readonly<Record<string, VisualInspectorRenderer>>;
|
|
774
|
+
/** Replaces the built-in voice catalog selector in visual mode. */
|
|
775
|
+
renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
|
|
776
|
+
/** Voice metadata used by the visual selector. */
|
|
777
|
+
voiceCatalog?: readonly VisualVoiceCatalogEntry[];
|
|
778
|
+
/** Optional locale filter for the visual voice selector. */
|
|
779
|
+
voiceLocale?: string;
|
|
780
|
+
/** Optional region filter for the visual voice selector. */
|
|
781
|
+
voiceRegion?: string;
|
|
782
|
+
/** Optional style filter for the visual voice selector. */
|
|
783
|
+
voiceStyle?: string;
|
|
767
784
|
/** Candidate style values shown by the built-in emotion insertion. */
|
|
768
785
|
emotionStyles?: readonly string[];
|
|
769
786
|
/** Class name applied to the editor container. */
|
|
@@ -865,6 +882,12 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
865
882
|
insertionGroups,
|
|
866
883
|
customInsertions,
|
|
867
884
|
additionalInsertions,
|
|
885
|
+
customInspectors,
|
|
886
|
+
renderVoiceSelector,
|
|
887
|
+
voiceCatalog,
|
|
888
|
+
voiceLocale,
|
|
889
|
+
voiceRegion,
|
|
890
|
+
voiceStyle,
|
|
868
891
|
emotionStyles,
|
|
869
892
|
className,
|
|
870
893
|
style,
|
|
@@ -1347,6 +1370,13 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1347
1370
|
readOnly={isReadOnly}
|
|
1348
1371
|
onChange={commit}
|
|
1349
1372
|
onPreviewSelection={onPreviewSelection}
|
|
1373
|
+
locale={localeProp ?? languageProp}
|
|
1374
|
+
customInspectors={customInspectors}
|
|
1375
|
+
renderVoiceSelector={renderVoiceSelector}
|
|
1376
|
+
voiceCatalog={voiceCatalog}
|
|
1377
|
+
voiceLocale={voiceLocale}
|
|
1378
|
+
voiceRegion={voiceRegion}
|
|
1379
|
+
voiceStyle={voiceStyle}
|
|
1350
1380
|
/>
|
|
1351
1381
|
</div>
|
|
1352
1382
|
) : (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
|
-
import type { ReactElement } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
4
|
buildSsml,
|
|
5
5
|
validateAzureSsml,
|
|
@@ -7,12 +7,48 @@ import {
|
|
|
7
7
|
type SsmlElement,
|
|
8
8
|
type SsmlNode,
|
|
9
9
|
} from "@ssml-builder-js/ssml-core";
|
|
10
|
+
import { SSML_ELEMENT_COPY, type SsmlEditorLocale } from "../locales";
|
|
11
|
+
|
|
12
|
+
export interface VisualVoiceCatalogEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
locale: string;
|
|
15
|
+
region?: string;
|
|
16
|
+
styles?: readonly string[];
|
|
17
|
+
preview?: boolean;
|
|
18
|
+
status?: "ga" | "preview" | "deprecated";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VoiceSelectorRenderProps {
|
|
22
|
+
value: string;
|
|
23
|
+
voices: readonly VisualVoiceCatalogEntry[];
|
|
24
|
+
readOnly: boolean;
|
|
25
|
+
locale: SsmlEditorLocale;
|
|
26
|
+
onChange: (voice: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VisualInspectorRenderProps {
|
|
30
|
+
document: SsmlDocument;
|
|
31
|
+
element: SsmlElement;
|
|
32
|
+
path: number[];
|
|
33
|
+
readOnly: boolean;
|
|
34
|
+
onChange: (document: SsmlDocument) => void;
|
|
35
|
+
locale: SsmlEditorLocale;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type VisualInspectorRenderer = (props: VisualInspectorRenderProps) => ReactNode;
|
|
10
39
|
|
|
11
40
|
export interface VisualSsmlEditorProps {
|
|
12
41
|
document: SsmlDocument;
|
|
13
42
|
readOnly?: boolean;
|
|
14
43
|
onChange?: (document: SsmlDocument) => void;
|
|
15
44
|
onPreviewSelection?: (ssml: string) => void;
|
|
45
|
+
locale?: SsmlEditorLocale;
|
|
46
|
+
customInspectors?: Readonly<Record<string, VisualInspectorRenderer>>;
|
|
47
|
+
renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
|
|
48
|
+
voiceCatalog?: readonly VisualVoiceCatalogEntry[];
|
|
49
|
+
voiceLocale?: string;
|
|
50
|
+
voiceRegion?: string;
|
|
51
|
+
voiceStyle?: string;
|
|
16
52
|
}
|
|
17
53
|
|
|
18
54
|
interface TextLeaf {
|
|
@@ -93,7 +129,7 @@ function updateElementText(element: SsmlElement, value: string): SsmlElement {
|
|
|
93
129
|
function updateOptionalElementProperty(
|
|
94
130
|
document: SsmlDocument,
|
|
95
131
|
path: number[],
|
|
96
|
-
property:
|
|
132
|
+
property: string,
|
|
97
133
|
value: string,
|
|
98
134
|
): SsmlDocument {
|
|
99
135
|
return updateElementAtPath(document, path, (element) => {
|
|
@@ -104,6 +140,269 @@ function updateOptionalElementProperty(
|
|
|
104
140
|
});
|
|
105
141
|
}
|
|
106
142
|
|
|
143
|
+
interface VisualElementField {
|
|
144
|
+
key: string;
|
|
145
|
+
label: string;
|
|
146
|
+
multiline?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const VISUAL_ELEMENT_FIELDS: Readonly<Record<string, readonly VisualElementField[]>> = {
|
|
150
|
+
voice: [
|
|
151
|
+
{ key: "name", label: "Voice name" },
|
|
152
|
+
{ key: "effect", label: "Effect" },
|
|
153
|
+
],
|
|
154
|
+
prosody: [
|
|
155
|
+
{ key: "rate", label: "Rate" },
|
|
156
|
+
{ key: "pitch", label: "Pitch" },
|
|
157
|
+
{ key: "volume", label: "Volume" },
|
|
158
|
+
{ key: "contour", label: "Contour" },
|
|
159
|
+
{ key: "range", label: "Range" },
|
|
160
|
+
],
|
|
161
|
+
"express-as": [
|
|
162
|
+
{ key: "style", label: "Style" },
|
|
163
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
164
|
+
{ key: "role", label: "Role" },
|
|
165
|
+
],
|
|
166
|
+
expressAs: [
|
|
167
|
+
{ key: "style", label: "Style" },
|
|
168
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
169
|
+
{ key: "role", label: "Role" },
|
|
170
|
+
],
|
|
171
|
+
"mstts:express-as": [
|
|
172
|
+
{ key: "style", label: "Style" },
|
|
173
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
174
|
+
{ key: "role", label: "Role" },
|
|
175
|
+
],
|
|
176
|
+
"say-as": [
|
|
177
|
+
{ key: "interpretAs", label: "Interpret as" },
|
|
178
|
+
{ key: "format", label: "Format" },
|
|
179
|
+
{ key: "detail", label: "Detail" },
|
|
180
|
+
],
|
|
181
|
+
sayAs: [
|
|
182
|
+
{ key: "interpretAs", label: "Interpret as" },
|
|
183
|
+
{ key: "format", label: "Format" },
|
|
184
|
+
{ key: "detail", label: "Detail" },
|
|
185
|
+
],
|
|
186
|
+
phoneme: [
|
|
187
|
+
{ key: "alphabet", label: "Alphabet" },
|
|
188
|
+
{ key: "ph", label: "Pronunciation" },
|
|
189
|
+
],
|
|
190
|
+
emphasis: [{ key: "level", label: "Level" }],
|
|
191
|
+
audio: [
|
|
192
|
+
{ key: "src", label: "Source URL" },
|
|
193
|
+
{ key: "desc", label: "Description" },
|
|
194
|
+
{ key: "clipBegin", label: "Clip begin" },
|
|
195
|
+
{ key: "clipEnd", label: "Clip end" },
|
|
196
|
+
{ key: "speed", label: "Speed" },
|
|
197
|
+
{ key: "repeatCount", label: "Repeat count" },
|
|
198
|
+
{ key: "repeatDuration", label: "Repeat duration" },
|
|
199
|
+
{ key: "soundLevel", label: "Sound level" },
|
|
200
|
+
],
|
|
201
|
+
mark: [{ key: "name", label: "Mark name" }],
|
|
202
|
+
bookmark: [{ key: "mark", label: "Bookmark name" }],
|
|
203
|
+
sub: [{ key: "alias", label: "Alias" }],
|
|
204
|
+
lang: [{ key: "lang", label: "Language" }],
|
|
205
|
+
lexicon: [{ key: "uri", label: "Lexicon URI" }],
|
|
206
|
+
"mstts:silence": [
|
|
207
|
+
{ key: "typeValue", label: "Silence type" },
|
|
208
|
+
{ key: "value", label: "Value" },
|
|
209
|
+
],
|
|
210
|
+
silence: [
|
|
211
|
+
{ key: "typeValue", label: "Silence type" },
|
|
212
|
+
{ key: "value", label: "Value" },
|
|
213
|
+
],
|
|
214
|
+
"mstts:audioduration": [{ key: "value", label: "Duration" }],
|
|
215
|
+
"mstts:ttsembedding": [{ key: "speakerProfileId", label: "Speaker profile ID" }],
|
|
216
|
+
"mstts:embedding": [
|
|
217
|
+
{ key: "id", label: "Embedding ID" },
|
|
218
|
+
{ key: "speakerProfileId", label: "Speaker profile ID" },
|
|
219
|
+
],
|
|
220
|
+
"mstts:voiceconversion": [
|
|
221
|
+
{ key: "url", label: "Source URL" },
|
|
222
|
+
{ key: "profile", label: "Profile" },
|
|
223
|
+
{ key: "speakerProfileId", label: "Speaker profile ID" },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
function getElementFields(element: SsmlElement): readonly VisualElementField[] {
|
|
228
|
+
return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function localizedElementLabel(element: SsmlElement, locale: SsmlEditorLocale): string {
|
|
232
|
+
return SSML_ELEMENT_COPY[locale][elementLabel(element)]?.label ?? elementLabel(element);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function filteredVoices(
|
|
236
|
+
voiceCatalog: readonly VisualVoiceCatalogEntry[] | undefined,
|
|
237
|
+
locale: string | undefined,
|
|
238
|
+
region: string | undefined,
|
|
239
|
+
style: string | undefined,
|
|
240
|
+
): readonly VisualVoiceCatalogEntry[] {
|
|
241
|
+
return (voiceCatalog ?? []).filter((voice) => {
|
|
242
|
+
if (locale && voice.locale.toLowerCase() !== locale.toLowerCase()) return false;
|
|
243
|
+
if (region && voice.region && voice.region.toLowerCase() !== region.toLowerCase()) return false;
|
|
244
|
+
if (style && !voice.styles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase())) return false;
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function DefaultVoiceSelector({ value, voices, readOnly, locale, onChange }: VoiceSelectorRenderProps): ReactElement {
|
|
250
|
+
return (
|
|
251
|
+
<select
|
|
252
|
+
aria-label={locale === "ja" ? "音声" : "Voice"}
|
|
253
|
+
value={value}
|
|
254
|
+
disabled={readOnly}
|
|
255
|
+
onChange={(event) => onChange(event.target.value)}
|
|
256
|
+
>
|
|
257
|
+
<option value="">{locale === "ja" ? "音声を選択" : "Select a voice"}</option>
|
|
258
|
+
{voices.map((voice) => (
|
|
259
|
+
<option key={voice.name} value={voice.name}>
|
|
260
|
+
{voice.name}
|
|
261
|
+
{voice.preview || voice.status === "preview" ? " (preview)" : ""}
|
|
262
|
+
</option>
|
|
263
|
+
))}
|
|
264
|
+
</select>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function VisualElementInspector({
|
|
269
|
+
document,
|
|
270
|
+
element,
|
|
271
|
+
path,
|
|
272
|
+
readOnly,
|
|
273
|
+
commit,
|
|
274
|
+
locale,
|
|
275
|
+
customInspector,
|
|
276
|
+
renderVoiceSelector,
|
|
277
|
+
voiceCatalog,
|
|
278
|
+
voiceLocale,
|
|
279
|
+
voiceRegion,
|
|
280
|
+
voiceStyle,
|
|
281
|
+
}: {
|
|
282
|
+
document: SsmlDocument;
|
|
283
|
+
element: SsmlElement;
|
|
284
|
+
path: number[];
|
|
285
|
+
readOnly: boolean;
|
|
286
|
+
commit: (document: SsmlDocument) => void;
|
|
287
|
+
locale: SsmlEditorLocale;
|
|
288
|
+
customInspector?: VisualInspectorRenderer;
|
|
289
|
+
renderVoiceSelector?: (props: VoiceSelectorRenderProps) => ReactNode;
|
|
290
|
+
voiceCatalog?: readonly VisualVoiceCatalogEntry[];
|
|
291
|
+
voiceLocale?: string;
|
|
292
|
+
voiceRegion?: string;
|
|
293
|
+
voiceStyle?: string;
|
|
294
|
+
}): ReactElement {
|
|
295
|
+
if (customInspector) {
|
|
296
|
+
return <>{customInspector({ document, element, path, readOnly, onChange: commit, locale })}</>;
|
|
297
|
+
}
|
|
298
|
+
const fields = getElementFields(element);
|
|
299
|
+
const availableVoices = filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle);
|
|
300
|
+
const renderSelector = renderVoiceSelector ?? DefaultVoiceSelector;
|
|
301
|
+
return (
|
|
302
|
+
<fieldset>
|
|
303
|
+
<legend>{`<${elementLabel(element)}>`}</legend>
|
|
304
|
+
{fields.length === 0 ? (
|
|
305
|
+
<p>This element is preserved in the visual tree. Edit its attributes in Code mode.</p>
|
|
306
|
+
) : (
|
|
307
|
+
fields.map((field) => {
|
|
308
|
+
const value = (element as SsmlElement & Record<string, unknown>)[field.key];
|
|
309
|
+
const inputValue = value === undefined ? "" : String(value);
|
|
310
|
+
const inputId = `ssml-visual-${field.key}`;
|
|
311
|
+
return (
|
|
312
|
+
<label key={field.key} htmlFor={inputId}>
|
|
313
|
+
{locale === "ja"
|
|
314
|
+
? ((
|
|
315
|
+
{
|
|
316
|
+
name: "名前",
|
|
317
|
+
effect: "効果",
|
|
318
|
+
rate: "速度",
|
|
319
|
+
pitch: "ピッチ",
|
|
320
|
+
volume: "音量",
|
|
321
|
+
contour: "ピッチ曲線",
|
|
322
|
+
range: "範囲",
|
|
323
|
+
style: "スタイル",
|
|
324
|
+
styleDegree: "スタイル強度",
|
|
325
|
+
role: "役割",
|
|
326
|
+
interpretAs: "解釈",
|
|
327
|
+
alphabet: "アルファベット",
|
|
328
|
+
ph: "発音",
|
|
329
|
+
level: "強調レベル",
|
|
330
|
+
src: "音声 URL",
|
|
331
|
+
desc: "説明",
|
|
332
|
+
clipBegin: "開始位置",
|
|
333
|
+
clipEnd: "終了位置",
|
|
334
|
+
speed: "速度",
|
|
335
|
+
repeatCount: "繰り返し回数",
|
|
336
|
+
repeatDuration: "繰り返し時間",
|
|
337
|
+
soundLevel: "音量レベル",
|
|
338
|
+
mark: "ブックマーク名",
|
|
339
|
+
alias: "別名",
|
|
340
|
+
lang: "言語",
|
|
341
|
+
uri: "辞書 URI",
|
|
342
|
+
typeValue: "無音の種類",
|
|
343
|
+
value: "値",
|
|
344
|
+
id: "埋め込み ID",
|
|
345
|
+
speakerProfileId: "話者プロファイル ID",
|
|
346
|
+
url: "音声変換 URL",
|
|
347
|
+
profile: "プロファイル",
|
|
348
|
+
} as Record<string, string>
|
|
349
|
+
)[field.key] ?? field.label)
|
|
350
|
+
: field.label}
|
|
351
|
+
{field.key === "name" && elementLabel(element) === "voice" && voiceCatalog ? (
|
|
352
|
+
renderSelector({
|
|
353
|
+
value: inputValue,
|
|
354
|
+
voices: availableVoices,
|
|
355
|
+
readOnly,
|
|
356
|
+
locale,
|
|
357
|
+
onChange: (value) => commit(updateOptionalElementProperty(document, path, field.key, value)),
|
|
358
|
+
})
|
|
359
|
+
) : field.multiline ? (
|
|
360
|
+
<textarea
|
|
361
|
+
id={inputId}
|
|
362
|
+
value={inputValue}
|
|
363
|
+
readOnly={readOnly}
|
|
364
|
+
onChange={(event) =>
|
|
365
|
+
commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
|
|
366
|
+
}
|
|
367
|
+
/>
|
|
368
|
+
) : (
|
|
369
|
+
<input
|
|
370
|
+
id={inputId}
|
|
371
|
+
value={inputValue}
|
|
372
|
+
readOnly={readOnly}
|
|
373
|
+
onChange={(event) =>
|
|
374
|
+
commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
|
|
375
|
+
}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
378
|
+
</label>
|
|
379
|
+
);
|
|
380
|
+
})
|
|
381
|
+
)}
|
|
382
|
+
</fieldset>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getElementAncestors(nodes: SsmlNode[], path: number[], ancestors: SsmlElement[] = []): SsmlElement[] {
|
|
387
|
+
const [index, ...rest] = path;
|
|
388
|
+
const node = nodes[index];
|
|
389
|
+
if (!node || !isElement(node)) return ancestors;
|
|
390
|
+
if (rest.length === 0) return ancestors;
|
|
391
|
+
return getElementAncestors(node.children ?? [], rest, [...ancestors, node]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function buildVisualPreview(document: SsmlDocument, leaf: TextLeaf, selection: { start: number; end: number }): string {
|
|
395
|
+
const start = Math.max(0, Math.min(selection.start, leaf.value.length));
|
|
396
|
+
const end = Math.max(start, Math.min(selection.end, leaf.value.length));
|
|
397
|
+
const value = start === end ? leaf.value : leaf.value.slice(start, end);
|
|
398
|
+
const ancestors = getElementAncestors(document.children ?? [], leaf.path);
|
|
399
|
+
const children = ancestors.reduceRight<SsmlNode[]>(
|
|
400
|
+
(current, ancestor) => [{ ...ancestor, children: current }],
|
|
401
|
+
[value],
|
|
402
|
+
);
|
|
403
|
+
return buildSsml({ ...document, children });
|
|
404
|
+
}
|
|
405
|
+
|
|
107
406
|
function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
|
|
108
407
|
return updateElementAtPath(document, path, (element) => ({
|
|
109
408
|
...element,
|
|
@@ -184,12 +483,23 @@ export function VisualSsmlEditor({
|
|
|
184
483
|
readOnly = false,
|
|
185
484
|
onChange,
|
|
186
485
|
onPreviewSelection,
|
|
486
|
+
locale = "en",
|
|
487
|
+
customInspectors,
|
|
488
|
+
renderVoiceSelector,
|
|
489
|
+
voiceCatalog,
|
|
490
|
+
voiceLocale,
|
|
491
|
+
voiceRegion,
|
|
492
|
+
voiceStyle,
|
|
187
493
|
}: VisualSsmlEditorProps): ReactElement {
|
|
188
494
|
const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
|
|
189
495
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
190
496
|
const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
|
|
191
497
|
const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
|
|
192
498
|
const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
|
|
499
|
+
const selectedCustomInspector =
|
|
500
|
+
selectedElement && isElement(selectedElement)
|
|
501
|
+
? (customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type])
|
|
502
|
+
: undefined;
|
|
193
503
|
const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
|
|
194
504
|
const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
|
|
195
505
|
|
|
@@ -237,33 +547,54 @@ export function VisualSsmlEditor({
|
|
|
237
547
|
</ul>
|
|
238
548
|
</nav>
|
|
239
549
|
<div className="ssml-editor-visual-form">
|
|
240
|
-
{selectedElement && isElement(selectedElement)
|
|
550
|
+
{selectedCustomInspector && selectedElement && isElement(selectedElement) ? (
|
|
551
|
+
selectedCustomInspector({
|
|
552
|
+
document,
|
|
553
|
+
element: selectedElement,
|
|
554
|
+
path: selectedPath ?? [],
|
|
555
|
+
readOnly,
|
|
556
|
+
onChange: commit,
|
|
557
|
+
locale,
|
|
558
|
+
})
|
|
559
|
+
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
|
|
241
560
|
<fieldset>
|
|
242
|
-
<legend>
|
|
243
|
-
<p>
|
|
561
|
+
<legend>{localizedElementLabel(selectedElement, locale)}</legend>
|
|
562
|
+
<p>{SSML_ELEMENT_COPY[locale][elementLabel(selectedElement)]?.description}</p>
|
|
244
563
|
<button
|
|
245
564
|
type="button"
|
|
246
565
|
disabled={readOnly}
|
|
247
566
|
onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
|
|
248
567
|
>
|
|
249
|
-
Add turn
|
|
568
|
+
{locale === "ja" ? "ターンを追加" : "Add turn"}
|
|
250
569
|
</button>
|
|
251
570
|
</fieldset>
|
|
252
571
|
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
|
|
253
572
|
<fieldset>
|
|
254
|
-
<legend>
|
|
255
|
-
<label>
|
|
256
|
-
Voice
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
573
|
+
<legend>{localizedElementLabel(selectedElement, locale)}</legend>
|
|
574
|
+
<label htmlFor="ssml-visual-turn-voice">
|
|
575
|
+
{locale === "ja" ? "音声" : "Voice"}
|
|
576
|
+
{voiceCatalog ? (
|
|
577
|
+
(renderVoiceSelector ?? DefaultVoiceSelector)({
|
|
578
|
+
value: selectedElement.voice ?? "",
|
|
579
|
+
voices: filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle),
|
|
580
|
+
readOnly,
|
|
581
|
+
locale,
|
|
582
|
+
onChange: (value) =>
|
|
583
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", value)),
|
|
584
|
+
})
|
|
585
|
+
) : (
|
|
586
|
+
<input
|
|
587
|
+
id="ssml-visual-turn-voice"
|
|
588
|
+
value={selectedElement.voice ?? ""}
|
|
589
|
+
readOnly={readOnly}
|
|
590
|
+
onChange={(event) =>
|
|
591
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
|
|
592
|
+
}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
264
595
|
</label>
|
|
265
596
|
<label>
|
|
266
|
-
Speaker
|
|
597
|
+
{locale === "ja" ? "話者" : "Speaker"}
|
|
267
598
|
<input
|
|
268
599
|
value={selectedElement.speaker ?? ""}
|
|
269
600
|
readOnly={readOnly}
|
|
@@ -273,7 +604,7 @@ export function VisualSsmlEditor({
|
|
|
273
604
|
/>
|
|
274
605
|
</label>
|
|
275
606
|
<label>
|
|
276
|
-
Turn text
|
|
607
|
+
{locale === "ja" ? "発話テキスト" : "Turn text"}
|
|
277
608
|
<textarea
|
|
278
609
|
value={
|
|
279
610
|
selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
|
|
@@ -292,9 +623,9 @@ export function VisualSsmlEditor({
|
|
|
292
623
|
</fieldset>
|
|
293
624
|
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
|
|
294
625
|
<fieldset>
|
|
295
|
-
<legend>
|
|
626
|
+
<legend>{localizedElementLabel(selectedElement, locale)}</legend>
|
|
296
627
|
<label>
|
|
297
|
-
Source URL
|
|
628
|
+
{locale === "ja" ? "音声 URL" : "Source URL"}
|
|
298
629
|
<input
|
|
299
630
|
value={selectedElement.src ?? ""}
|
|
300
631
|
readOnly={readOnly}
|
|
@@ -304,7 +635,7 @@ export function VisualSsmlEditor({
|
|
|
304
635
|
/>
|
|
305
636
|
</label>
|
|
306
637
|
<label>
|
|
307
|
-
Volume
|
|
638
|
+
{locale === "ja" ? "音量" : "Volume"}
|
|
308
639
|
<input
|
|
309
640
|
value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
|
|
310
641
|
readOnly={readOnly}
|
|
@@ -314,7 +645,7 @@ export function VisualSsmlEditor({
|
|
|
314
645
|
/>
|
|
315
646
|
</label>
|
|
316
647
|
<label>
|
|
317
|
-
Fade in
|
|
648
|
+
{locale === "ja" ? "フェードイン" : "Fade in"}
|
|
318
649
|
<input
|
|
319
650
|
value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
|
|
320
651
|
readOnly={readOnly}
|
|
@@ -324,7 +655,7 @@ export function VisualSsmlEditor({
|
|
|
324
655
|
/>
|
|
325
656
|
</label>
|
|
326
657
|
<label>
|
|
327
|
-
Fade out
|
|
658
|
+
{locale === "ja" ? "フェードアウト" : "Fade out"}
|
|
328
659
|
<input
|
|
329
660
|
value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
|
|
330
661
|
readOnly={readOnly}
|
|
@@ -334,6 +665,23 @@ export function VisualSsmlEditor({
|
|
|
334
665
|
/>
|
|
335
666
|
</label>
|
|
336
667
|
</fieldset>
|
|
668
|
+
) : selectedElement && isElement(selectedElement) ? (
|
|
669
|
+
<VisualElementInspector
|
|
670
|
+
document={document}
|
|
671
|
+
element={selectedElement}
|
|
672
|
+
path={selectedPath ?? []}
|
|
673
|
+
readOnly={readOnly}
|
|
674
|
+
commit={commit}
|
|
675
|
+
locale={locale}
|
|
676
|
+
customInspector={
|
|
677
|
+
customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type]
|
|
678
|
+
}
|
|
679
|
+
renderVoiceSelector={renderVoiceSelector}
|
|
680
|
+
voiceCatalog={voiceCatalog}
|
|
681
|
+
voiceLocale={voiceLocale}
|
|
682
|
+
voiceRegion={voiceRegion}
|
|
683
|
+
voiceStyle={voiceStyle}
|
|
684
|
+
/>
|
|
337
685
|
) : selectedLeaf ? (
|
|
338
686
|
<>
|
|
339
687
|
<label>
|
|
@@ -375,7 +723,7 @@ export function VisualSsmlEditor({
|
|
|
375
723
|
{onPreviewSelection && (
|
|
376
724
|
<button
|
|
377
725
|
type="button"
|
|
378
|
-
onClick={() => onPreviewSelection(
|
|
726
|
+
onClick={() => onPreviewSelection(buildVisualPreview(document, selectedLeaf, selection))}
|
|
379
727
|
>
|
|
380
728
|
Preview selection
|
|
381
729
|
</button>
|
package/src/index.tsx
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
export { SsmlEditor } from "./SsmlEditor";
|
|
6
6
|
export { VisualSsmlEditor } from "./components/VisualSsmlEditor";
|
|
7
|
-
export type {
|
|
7
|
+
export type {
|
|
8
|
+
VisualInspectorRenderProps,
|
|
9
|
+
VisualInspectorRenderer,
|
|
10
|
+
VisualSsmlEditorProps,
|
|
11
|
+
VisualVoiceCatalogEntry,
|
|
12
|
+
VoiceSelectorRenderProps,
|
|
13
|
+
} from "./components/VisualSsmlEditor";
|
|
8
14
|
export type {
|
|
9
15
|
SsmlEditorButton,
|
|
10
16
|
SsmlEditorButtonVisibility,
|
|
@@ -32,7 +38,7 @@ export type {
|
|
|
32
38
|
SsmlInsertionOption,
|
|
33
39
|
SsmlInsertionTemplate,
|
|
34
40
|
} from "./SsmlEditor";
|
|
35
|
-
export { EDITOR_COPY, INLINE_BADGE_COPY, SSML_HOVER_COPY } from "./locales";
|
|
41
|
+
export { EDITOR_COPY, INLINE_BADGE_COPY, SSML_ELEMENT_COPY, SSML_HOVER_COPY } from "./locales";
|
|
36
42
|
export type {
|
|
37
43
|
EditorCopy,
|
|
38
44
|
InlineBadgeCopy,
|
|
@@ -42,6 +48,7 @@ export type {
|
|
|
42
48
|
SsmlHoverLocale,
|
|
43
49
|
SsmlHoverParameterCopy,
|
|
44
50
|
SsmlHoverTagCopy,
|
|
51
|
+
SsmlElementLocaleCopy,
|
|
45
52
|
} from "./locales";
|
|
46
53
|
export {
|
|
47
54
|
createSsmlEditorInsertionDefinition,
|