@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,1302 @@
|
|
|
1
|
+
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
|
2
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
3
|
+
import Editor from "@monaco-editor/react";
|
|
4
|
+
import type { SsmlDocument } from "@ssml-builder-js/ssml-core";
|
|
5
|
+
import { isSsmlEditorButtonVisible, type SsmlEditorButton, type SsmlEditorButtonVisibility } from "./buttonVisibility";
|
|
6
|
+
import {
|
|
7
|
+
BREAK_TIME_DESCRIPTIONS,
|
|
8
|
+
BREAK_TIME_PRESETS,
|
|
9
|
+
EMPHASIS_LEVEL_DESCRIPTIONS,
|
|
10
|
+
EMPHASIS_LEVEL_PRESETS,
|
|
11
|
+
EXPRESS_AS_STYLE_DESCRIPTIONS,
|
|
12
|
+
EXPRESS_AS_STYLE_PRESETS,
|
|
13
|
+
LANGUAGE_DESCRIPTIONS,
|
|
14
|
+
LANGUAGE_PRESETS,
|
|
15
|
+
PROSODY_PITCH_DESCRIPTIONS,
|
|
16
|
+
PROSODY_PITCH_PRESETS,
|
|
17
|
+
PROSODY_RATE_DESCRIPTIONS,
|
|
18
|
+
PROSODY_RATE_PRESETS,
|
|
19
|
+
PROSODY_VOLUME_DESCRIPTIONS,
|
|
20
|
+
PROSODY_VOLUME_PRESETS,
|
|
21
|
+
SAY_AS_DESCRIPTIONS,
|
|
22
|
+
SAY_AS_PRESETS,
|
|
23
|
+
SILENCE_VALUE_DESCRIPTIONS,
|
|
24
|
+
SILENCE_VALUE_PRESETS,
|
|
25
|
+
} from "./constants/ssmlPresets";
|
|
26
|
+
import {
|
|
27
|
+
EDITOR_COPY,
|
|
28
|
+
type EditorCopy,
|
|
29
|
+
type SsmlEditorLanguage,
|
|
30
|
+
type SsmlEditorLocale,
|
|
31
|
+
type SsmlEditorLocalizedText,
|
|
32
|
+
} from "./locales";
|
|
33
|
+
import { DEFAULT_LOCALE } from "./constants/ui";
|
|
34
|
+
import { ProsodyPopovers } from "./components/popovers/ProsodyPopovers";
|
|
35
|
+
import { TextPopovers } from "./components/popovers/TextPopovers";
|
|
36
|
+
import { TimingPopovers } from "./components/popovers/TimingPopovers";
|
|
37
|
+
import { InsertionPopover } from "./components/popovers/InsertionPopover";
|
|
38
|
+
import { useSsmlEditorState } from "./hooks/useSsmlEditorState";
|
|
39
|
+
import { useSsmlMonaco } from "./hooks/useSsmlMonaco";
|
|
40
|
+
import { editorStyles as styles } from "./styles/editorStyles";
|
|
41
|
+
const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
|
|
42
|
+
const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence"]);
|
|
43
|
+
const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
|
|
44
|
+
const TEXT_POPOVER_TAGS = new Set(["sub", "say-as", "phoneme", "w", "lang"]);
|
|
45
|
+
|
|
46
|
+
export type { SsmlEditorLanguage, SsmlEditorLocalizedText } from "./locales";
|
|
47
|
+
|
|
48
|
+
export type SsmlEditorInsertionMode = "insert" | "wrap";
|
|
49
|
+
|
|
50
|
+
export interface SsmlEditorInsertionOption {
|
|
51
|
+
value: string;
|
|
52
|
+
labels: SsmlEditorLocalizedText;
|
|
53
|
+
descriptions?: SsmlEditorLocalizedText;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SsmlEditorInsertionTemplate {
|
|
57
|
+
prefix: string;
|
|
58
|
+
suffix: string;
|
|
59
|
+
mode: SsmlEditorInsertionMode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SsmlEditorInsertionDefinition {
|
|
63
|
+
id: string;
|
|
64
|
+
icon: string;
|
|
65
|
+
tagName?: string;
|
|
66
|
+
selfClosing?: boolean;
|
|
67
|
+
labels: SsmlEditorLocalizedText;
|
|
68
|
+
titles?: SsmlEditorLocalizedText;
|
|
69
|
+
descriptions: SsmlEditorLocalizedText;
|
|
70
|
+
parameterDescription: SsmlEditorLocalizedText;
|
|
71
|
+
options: readonly SsmlEditorInsertionOption[];
|
|
72
|
+
createTemplate: (value: string) => SsmlEditorInsertionTemplate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SsmlEditorCustomInsertionDefinition {
|
|
76
|
+
id: string;
|
|
77
|
+
tagName: string;
|
|
78
|
+
attribute?: string;
|
|
79
|
+
icon?: string;
|
|
80
|
+
labels: SsmlEditorLocalizedText;
|
|
81
|
+
titles?: SsmlEditorLocalizedText;
|
|
82
|
+
descriptions?: SsmlEditorLocalizedText;
|
|
83
|
+
parameterDescription?: SsmlEditorLocalizedText;
|
|
84
|
+
options: readonly SsmlEditorInsertionOption[];
|
|
85
|
+
mode?: SsmlEditorInsertionMode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SsmlEditorCustomInsertion = SsmlEditorInsertionDefinition | SsmlEditorCustomInsertionDefinition;
|
|
89
|
+
|
|
90
|
+
export type SsmlEditorCustomInsertionCollection =
|
|
91
|
+
| readonly SsmlEditorCustomInsertion[]
|
|
92
|
+
| Readonly<Record<string, SsmlEditorCustomInsertion | undefined>>;
|
|
93
|
+
|
|
94
|
+
export interface SsmlEditorInsertionGroup {
|
|
95
|
+
id: string;
|
|
96
|
+
labels: SsmlEditorLocalizedText;
|
|
97
|
+
insertionIds: readonly string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SsmlEditorToolbarGroup {
|
|
101
|
+
id: string;
|
|
102
|
+
buttonIds: readonly string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SsmlEditorTheme = "system" | "light" | "dark";
|
|
106
|
+
export type SsmlEditorWordWrap = "off" | "on" | "wordWrapColumn" | "bounded";
|
|
107
|
+
export type SsmlEditorLineNumbers = "on" | "off" | "relative" | "interval";
|
|
108
|
+
|
|
109
|
+
export interface SsmlEditorOptions {
|
|
110
|
+
height?: string | number;
|
|
111
|
+
minHeight?: string | number;
|
|
112
|
+
readOnly?: boolean;
|
|
113
|
+
theme?: SsmlEditorTheme;
|
|
114
|
+
fontSize?: number;
|
|
115
|
+
wordWrap?: SsmlEditorWordWrap;
|
|
116
|
+
lineNumbers?: SsmlEditorLineNumbers;
|
|
117
|
+
minimap?: boolean;
|
|
118
|
+
automaticLayout?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type SsmlInsertionOption = SsmlEditorInsertionOption;
|
|
122
|
+
export type SsmlInsertionTemplate = SsmlEditorInsertionTemplate;
|
|
123
|
+
export type SsmlInsertionDefinition = SsmlEditorInsertionDefinition;
|
|
124
|
+
|
|
125
|
+
function localizedText(value: string): SsmlEditorLocalizedText {
|
|
126
|
+
return { ja: value, en: value };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function escapeXmlAttribute(value: string): string {
|
|
130
|
+
return value
|
|
131
|
+
.replace(/&/g, "&")
|
|
132
|
+
.replace(/"/g, """)
|
|
133
|
+
.replace(/</g, "<")
|
|
134
|
+
.replace(/>/g, ">")
|
|
135
|
+
.replace(/'/g, "'");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createSsmlEditorInsertionDefinition(
|
|
139
|
+
definition: SsmlEditorCustomInsertionDefinition,
|
|
140
|
+
): SsmlEditorInsertionDefinition {
|
|
141
|
+
const mode = definition.mode ?? "wrap";
|
|
142
|
+
const attribute = definition.attribute ? ` ${definition.attribute}="` : "";
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
id: definition.id,
|
|
146
|
+
icon: definition.icon ?? "+",
|
|
147
|
+
tagName: definition.tagName,
|
|
148
|
+
selfClosing: mode === "insert",
|
|
149
|
+
labels: definition.labels,
|
|
150
|
+
titles: definition.titles,
|
|
151
|
+
descriptions: definition.descriptions ?? localizedText(`Inserts the <${definition.tagName}> element.`),
|
|
152
|
+
parameterDescription:
|
|
153
|
+
definition.parameterDescription ?? localizedText(`Selects the value for the <${definition.tagName}> element.`),
|
|
154
|
+
options: definition.options,
|
|
155
|
+
createTemplate: (value) => {
|
|
156
|
+
const attributeValue = definition.attribute ? `${attribute}${escapeXmlAttribute(value)}"` : "";
|
|
157
|
+
if (mode === "wrap") {
|
|
158
|
+
return {
|
|
159
|
+
prefix: `<${definition.tagName}${attributeValue}>`,
|
|
160
|
+
suffix: `</${definition.tagName}>`,
|
|
161
|
+
mode,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
prefix: `<${definition.tagName}${attributeValue}/>`,
|
|
167
|
+
suffix: "",
|
|
168
|
+
mode,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createInsertionOptions(
|
|
175
|
+
values: readonly string[],
|
|
176
|
+
descriptions?: Readonly<Record<string, SsmlEditorLocalizedText>>,
|
|
177
|
+
): readonly SsmlInsertionOption[] {
|
|
178
|
+
return values.map((value) => ({
|
|
179
|
+
value,
|
|
180
|
+
labels: { ja: value, en: value },
|
|
181
|
+
...(descriptions?.[value] ? { descriptions: descriptions[value] } : {}),
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const SSML_INSERTIONS = [
|
|
186
|
+
{
|
|
187
|
+
id: "break",
|
|
188
|
+
icon: "⏸",
|
|
189
|
+
tagName: "break",
|
|
190
|
+
selfClosing: true,
|
|
191
|
+
labels: { ja: "間", en: "Break" },
|
|
192
|
+
descriptions: {
|
|
193
|
+
ja: "指定した時間だけ無音の間を挿入します。",
|
|
194
|
+
en: "Inserts a silent pause for the selected duration.",
|
|
195
|
+
},
|
|
196
|
+
parameterDescription: {
|
|
197
|
+
ja: "無音にする時間を選択します。",
|
|
198
|
+
en: "Selects the duration of the silent pause.",
|
|
199
|
+
},
|
|
200
|
+
options: createInsertionOptions(BREAK_TIME_PRESETS, BREAK_TIME_DESCRIPTIONS),
|
|
201
|
+
createTemplate: (value) => ({
|
|
202
|
+
prefix: `<break time="${value}"/>`,
|
|
203
|
+
suffix: "",
|
|
204
|
+
mode: "insert",
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "emphasis",
|
|
209
|
+
icon: "✦",
|
|
210
|
+
tagName: "emphasis",
|
|
211
|
+
labels: { ja: "強調", en: "Emphasis" },
|
|
212
|
+
descriptions: {
|
|
213
|
+
ja: "選択範囲の強調レベルを変更します。",
|
|
214
|
+
en: "Changes the emphasis level of the selected text.",
|
|
215
|
+
},
|
|
216
|
+
parameterDescription: {
|
|
217
|
+
ja: "選択範囲の強調レベルを選択します。",
|
|
218
|
+
en: "Selects the emphasis level for the selected text.",
|
|
219
|
+
},
|
|
220
|
+
options: createInsertionOptions(EMPHASIS_LEVEL_PRESETS, EMPHASIS_LEVEL_DESCRIPTIONS),
|
|
221
|
+
createTemplate: (value) => ({
|
|
222
|
+
prefix: `<emphasis level="${value}">`,
|
|
223
|
+
suffix: "</emphasis>",
|
|
224
|
+
mode: "wrap",
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "rate",
|
|
229
|
+
icon: "↕",
|
|
230
|
+
tagName: "prosody",
|
|
231
|
+
labels: { ja: "速度", en: "Rate" },
|
|
232
|
+
descriptions: {
|
|
233
|
+
ja: "選択範囲の読み上げ速度を変更します。",
|
|
234
|
+
en: "Changes the speech rate of the selected text.",
|
|
235
|
+
},
|
|
236
|
+
parameterDescription: {
|
|
237
|
+
ja: "選択範囲の読み上げ速度を選択します。",
|
|
238
|
+
en: "Selects the speech rate for the selected text.",
|
|
239
|
+
},
|
|
240
|
+
options: createInsertionOptions(PROSODY_RATE_PRESETS, PROSODY_RATE_DESCRIPTIONS),
|
|
241
|
+
createTemplate: (value) => ({
|
|
242
|
+
prefix: `<prosody rate="${value}">`,
|
|
243
|
+
suffix: "</prosody>",
|
|
244
|
+
mode: "wrap",
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: "pitch",
|
|
249
|
+
icon: "↗",
|
|
250
|
+
tagName: "prosody",
|
|
251
|
+
labels: { ja: "高さ", en: "Pitch" },
|
|
252
|
+
descriptions: {
|
|
253
|
+
ja: "選択範囲の声の高さを変更します。",
|
|
254
|
+
en: "Changes the pitch of the selected text.",
|
|
255
|
+
},
|
|
256
|
+
parameterDescription: {
|
|
257
|
+
ja: "選択範囲の声の高さを半音単位で選択します。",
|
|
258
|
+
en: "Selects the pitch adjustment in semitone steps.",
|
|
259
|
+
},
|
|
260
|
+
options: createInsertionOptions(PROSODY_PITCH_PRESETS, PROSODY_PITCH_DESCRIPTIONS),
|
|
261
|
+
createTemplate: (value) => ({
|
|
262
|
+
prefix: `<prosody pitch="${value}">`,
|
|
263
|
+
suffix: "</prosody>",
|
|
264
|
+
mode: "wrap",
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "volume",
|
|
269
|
+
icon: "🔊",
|
|
270
|
+
tagName: "prosody",
|
|
271
|
+
labels: { ja: "音量", en: "Volume" },
|
|
272
|
+
descriptions: {
|
|
273
|
+
ja: "選択範囲の音量を変更します。",
|
|
274
|
+
en: "Changes the volume of the selected text.",
|
|
275
|
+
},
|
|
276
|
+
parameterDescription: {
|
|
277
|
+
ja: "選択範囲の音量レベルを選択します。",
|
|
278
|
+
en: "Selects the volume level for the selected text.",
|
|
279
|
+
},
|
|
280
|
+
options: createInsertionOptions(PROSODY_VOLUME_PRESETS, PROSODY_VOLUME_DESCRIPTIONS),
|
|
281
|
+
createTemplate: (value) => ({
|
|
282
|
+
prefix: `<prosody volume="${value}">`,
|
|
283
|
+
suffix: "</prosody>",
|
|
284
|
+
mode: "wrap",
|
|
285
|
+
}),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "emotion",
|
|
289
|
+
icon: "☺",
|
|
290
|
+
tagName: "mstts:express-as",
|
|
291
|
+
labels: { ja: "感情", en: "Emotion" },
|
|
292
|
+
descriptions: {
|
|
293
|
+
ja: "選択範囲に音声の感情スタイルを適用します。",
|
|
294
|
+
en: "Applies a voice emotion style to the selected text.",
|
|
295
|
+
},
|
|
296
|
+
parameterDescription: {
|
|
297
|
+
ja: "選択範囲に適用する音声の感情スタイルを選択します。",
|
|
298
|
+
en: "Selects the voice emotion style to apply.",
|
|
299
|
+
},
|
|
300
|
+
options: createInsertionOptions(EXPRESS_AS_STYLE_PRESETS, EXPRESS_AS_STYLE_DESCRIPTIONS),
|
|
301
|
+
createTemplate: (value) => ({
|
|
302
|
+
prefix: `<mstts:express-as style="${value}">`,
|
|
303
|
+
suffix: "</mstts:express-as>",
|
|
304
|
+
mode: "wrap",
|
|
305
|
+
}),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "say-as",
|
|
309
|
+
icon: "Aa",
|
|
310
|
+
tagName: "say-as",
|
|
311
|
+
labels: { ja: "読み上げ", en: "Say as" },
|
|
312
|
+
descriptions: {
|
|
313
|
+
ja: "数字や日付などの読み上げ方を指定します。",
|
|
314
|
+
en: "Specifies how values such as numbers or dates are spoken.",
|
|
315
|
+
},
|
|
316
|
+
parameterDescription: {
|
|
317
|
+
ja: "選択範囲の読み上げ方を選択します。",
|
|
318
|
+
en: "Selects how the selected text is spoken.",
|
|
319
|
+
},
|
|
320
|
+
options: createInsertionOptions(SAY_AS_PRESETS, SAY_AS_DESCRIPTIONS),
|
|
321
|
+
createTemplate: (value) => ({
|
|
322
|
+
prefix: `<say-as interpret-as="${value}">`,
|
|
323
|
+
suffix: "</say-as>",
|
|
324
|
+
mode: "wrap",
|
|
325
|
+
}),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: "lang",
|
|
329
|
+
icon: "文",
|
|
330
|
+
tagName: "lang",
|
|
331
|
+
labels: { ja: "言語", en: "Language" },
|
|
332
|
+
descriptions: {
|
|
333
|
+
ja: "選択範囲の読み上げ言語を変更します。",
|
|
334
|
+
en: "Changes the speaking language of the selected text.",
|
|
335
|
+
},
|
|
336
|
+
parameterDescription: {
|
|
337
|
+
ja: "選択範囲に適用する BCP-47 言語タグを選択します。",
|
|
338
|
+
en: "Selects the BCP-47 language tag for the selected text.",
|
|
339
|
+
},
|
|
340
|
+
options: createInsertionOptions(LANGUAGE_PRESETS, LANGUAGE_DESCRIPTIONS),
|
|
341
|
+
createTemplate: (value) => ({
|
|
342
|
+
prefix: `<lang xml:lang="${value}">`,
|
|
343
|
+
suffix: "</lang>",
|
|
344
|
+
mode: "wrap",
|
|
345
|
+
}),
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "mstts:silence",
|
|
349
|
+
icon: "⏳",
|
|
350
|
+
tagName: "mstts:silence",
|
|
351
|
+
selfClosing: true,
|
|
352
|
+
labels: { ja: "無音", en: "Silence" },
|
|
353
|
+
descriptions: {
|
|
354
|
+
ja: "無音時間を挿入します。",
|
|
355
|
+
en: "Inserts a silence interval.",
|
|
356
|
+
},
|
|
357
|
+
parameterDescription: {
|
|
358
|
+
ja: "無音にする時間を選択します。",
|
|
359
|
+
en: "Selects the silence duration.",
|
|
360
|
+
},
|
|
361
|
+
options: createInsertionOptions(SILENCE_VALUE_PRESETS, SILENCE_VALUE_DESCRIPTIONS),
|
|
362
|
+
createTemplate: (value) => ({
|
|
363
|
+
prefix: `<mstts:silence type="Leading" value="${value}"/>`,
|
|
364
|
+
suffix: "",
|
|
365
|
+
mode: "insert",
|
|
366
|
+
}),
|
|
367
|
+
},
|
|
368
|
+
] satisfies readonly SsmlInsertionDefinition[];
|
|
369
|
+
|
|
370
|
+
const DEFAULT_INSERTION_GROUPS = [
|
|
371
|
+
{
|
|
372
|
+
id: "pauses",
|
|
373
|
+
labels: { ja: "間・無音", en: "Pauses" },
|
|
374
|
+
insertionIds: ["break", "mstts:silence"],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
id: "prosody",
|
|
378
|
+
labels: { ja: "声の調整", en: "Voice" },
|
|
379
|
+
insertionIds: ["rate", "pitch", "volume"],
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: "expression",
|
|
383
|
+
labels: { ja: "表現", en: "Expression" },
|
|
384
|
+
insertionIds: ["emphasis", "emotion"],
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "pronunciation",
|
|
388
|
+
labels: { ja: "読み上げ", en: "Pronunciation" },
|
|
389
|
+
insertionIds: ["say-as", "lang"],
|
|
390
|
+
},
|
|
391
|
+
] satisfies readonly SsmlEditorInsertionGroup[];
|
|
392
|
+
|
|
393
|
+
function createDefaultToolbarGroups(
|
|
394
|
+
insertionGroups: readonly SsmlEditorInsertionGroup[],
|
|
395
|
+
): readonly SsmlEditorToolbarGroup[] {
|
|
396
|
+
return [
|
|
397
|
+
{
|
|
398
|
+
id: "history",
|
|
399
|
+
buttonIds: ["undo", "redo"],
|
|
400
|
+
},
|
|
401
|
+
...insertionGroups.map(({ id, insertionIds }) => ({
|
|
402
|
+
id,
|
|
403
|
+
buttonIds: insertionIds,
|
|
404
|
+
})),
|
|
405
|
+
{
|
|
406
|
+
id: "document",
|
|
407
|
+
buttonIds: ["clearAll", "format", "decorations"],
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "help",
|
|
411
|
+
buttonIds: ["help"],
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getInsertionCollection(
|
|
417
|
+
collection: SsmlEditorCustomInsertionCollection | undefined,
|
|
418
|
+
): readonly SsmlEditorCustomInsertion[] {
|
|
419
|
+
if (!collection) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return Array.isArray(collection)
|
|
424
|
+
? collection
|
|
425
|
+
: Object.values(collection).filter((insertion): insertion is SsmlEditorCustomInsertion => insertion !== undefined);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getConfiguredInsertions(
|
|
429
|
+
emotionStyles: readonly string[] | undefined,
|
|
430
|
+
customInsertions: SsmlEditorCustomInsertionCollection | undefined,
|
|
431
|
+
additionalInsertions: SsmlEditorCustomInsertionCollection | undefined,
|
|
432
|
+
): readonly SsmlInsertionDefinition[] {
|
|
433
|
+
const insertions = new Map<string, SsmlInsertionDefinition>();
|
|
434
|
+
for (const insertion of SSML_INSERTIONS) {
|
|
435
|
+
if (insertion.id === "emotion" && emotionStyles !== undefined) {
|
|
436
|
+
insertions.set(insertion.id, {
|
|
437
|
+
...insertion,
|
|
438
|
+
options: createInsertionOptions(emotionStyles),
|
|
439
|
+
});
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
insertions.set(insertion.id, insertion);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const normalizeInsertion = (insertion: SsmlEditorCustomInsertion): SsmlInsertionDefinition =>
|
|
446
|
+
"createTemplate" in insertion ? insertion : createSsmlEditorInsertionDefinition(insertion);
|
|
447
|
+
|
|
448
|
+
for (const insertion of getInsertionCollection(additionalInsertions)) {
|
|
449
|
+
const normalized = normalizeInsertion(insertion);
|
|
450
|
+
if (!insertions.has(normalized.id)) {
|
|
451
|
+
insertions.set(normalized.id, normalized);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const insertion of getInsertionCollection(customInsertions)) {
|
|
456
|
+
const normalized = normalizeInsertion(insertion);
|
|
457
|
+
insertions.set(normalized.id, normalized);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return [...insertions.values()];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function orderInsertions(
|
|
464
|
+
insertions: readonly SsmlInsertionDefinition[],
|
|
465
|
+
insertionOrder: readonly string[] | undefined,
|
|
466
|
+
): readonly SsmlInsertionDefinition[] {
|
|
467
|
+
if (!insertionOrder || insertionOrder.length === 0) {
|
|
468
|
+
return insertions;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const insertionsById = new Map(insertions.map((insertion) => [insertion.id, insertion]));
|
|
472
|
+
const ordered: SsmlInsertionDefinition[] = [];
|
|
473
|
+
const included = new Set<string>();
|
|
474
|
+
for (const id of insertionOrder) {
|
|
475
|
+
const insertion = insertionsById.get(id);
|
|
476
|
+
if (insertion && !included.has(id)) {
|
|
477
|
+
ordered.push(insertion);
|
|
478
|
+
included.add(id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const insertion of insertions) {
|
|
483
|
+
if (!included.has(insertion.id)) {
|
|
484
|
+
ordered.push(insertion);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return ordered;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function orderToolbarButtons(
|
|
492
|
+
buttonIds: readonly string[],
|
|
493
|
+
toolbarOrder: readonly string[] | undefined,
|
|
494
|
+
): readonly string[] {
|
|
495
|
+
if (!toolbarOrder || toolbarOrder.length === 0) {
|
|
496
|
+
return buttonIds;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const available = new Set(buttonIds);
|
|
500
|
+
const ordered: string[] = [];
|
|
501
|
+
const included = new Set<string>();
|
|
502
|
+
for (const id of toolbarOrder) {
|
|
503
|
+
if (available.has(id) && !included.has(id)) {
|
|
504
|
+
ordered.push(id);
|
|
505
|
+
included.add(id);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
for (const id of buttonIds) {
|
|
510
|
+
if (!included.has(id)) {
|
|
511
|
+
ordered.push(id);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return ordered;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const STYLE_ID = "ssml-editor-theme";
|
|
519
|
+
const STYLE_CSS = `
|
|
520
|
+
[data-ssml-editor] {
|
|
521
|
+
--ssml-editor-color: #111827;
|
|
522
|
+
--ssml-editor-bg: #ffffff;
|
|
523
|
+
--ssml-editor-border: #d1d5db;
|
|
524
|
+
--ssml-editor-control-bg: #f9fafb;
|
|
525
|
+
--ssml-editor-control-border: #9ca3af;
|
|
526
|
+
--ssml-editor-active-bg: #dbeafe;
|
|
527
|
+
--ssml-editor-active-border: #2563eb;
|
|
528
|
+
--ssml-editor-preview-bg: #f3f4f6;
|
|
529
|
+
--ssml-editor-error: #b91c1c;
|
|
530
|
+
--ssml-editor-error-bg: #fef2f2;
|
|
531
|
+
--ssml-editor-switch-active: #2563eb;
|
|
532
|
+
--ssml-editor-pause-badge-bg: #e0e7ff;
|
|
533
|
+
--ssml-editor-pause-badge-color: #3730a3;
|
|
534
|
+
--ssml-editor-pause-badge-border: #c7d2fe;
|
|
535
|
+
--ssml-editor-prosody-badge-bg: #dcfce7;
|
|
536
|
+
--ssml-editor-prosody-badge-color: #166534;
|
|
537
|
+
--ssml-editor-prosody-badge-border: #86efac;
|
|
538
|
+
}
|
|
539
|
+
[data-ssml-editor][data-theme="dark"] {
|
|
540
|
+
--ssml-editor-color: #f9fafb;
|
|
541
|
+
--ssml-editor-bg: #1f2937;
|
|
542
|
+
--ssml-editor-border: #374151;
|
|
543
|
+
--ssml-editor-control-bg: #111827;
|
|
544
|
+
--ssml-editor-control-border: #4b5563;
|
|
545
|
+
--ssml-editor-active-bg: #1e3a8a;
|
|
546
|
+
--ssml-editor-active-border: #60a5fa;
|
|
547
|
+
--ssml-editor-preview-bg: #111827;
|
|
548
|
+
--ssml-editor-error: #fca5a5;
|
|
549
|
+
--ssml-editor-error-bg: #450a0a;
|
|
550
|
+
--ssml-editor-switch-active: #60a5fa;
|
|
551
|
+
--ssml-editor-pause-badge-bg: #312e81;
|
|
552
|
+
--ssml-editor-pause-badge-color: #c7d2fe;
|
|
553
|
+
--ssml-editor-pause-badge-border: #6366f1;
|
|
554
|
+
--ssml-editor-prosody-badge-bg: #14532d;
|
|
555
|
+
--ssml-editor-prosody-badge-color: #bbf7d0;
|
|
556
|
+
--ssml-editor-prosody-badge-border: #4ade80;
|
|
557
|
+
}
|
|
558
|
+
[data-ssml-editor] .ssml-editor-inline-badge {
|
|
559
|
+
display: inline-block;
|
|
560
|
+
margin-left: 0.35em;
|
|
561
|
+
padding: 0 0.35em;
|
|
562
|
+
border: 1px solid;
|
|
563
|
+
border-radius: 999px;
|
|
564
|
+
font-size: 0.75em;
|
|
565
|
+
font-weight: 600;
|
|
566
|
+
line-height: 1.4;
|
|
567
|
+
white-space: nowrap;
|
|
568
|
+
vertical-align: middle;
|
|
569
|
+
pointer-events: none;
|
|
570
|
+
}
|
|
571
|
+
[data-ssml-editor] .ssml-editor-inline-badge-pause {
|
|
572
|
+
color: var(--ssml-editor-pause-badge-color);
|
|
573
|
+
background-color: var(--ssml-editor-pause-badge-bg);
|
|
574
|
+
border-color: var(--ssml-editor-pause-badge-border);
|
|
575
|
+
}
|
|
576
|
+
[data-ssml-editor] .ssml-editor-inline-badge-prosody {
|
|
577
|
+
color: var(--ssml-editor-prosody-badge-color);
|
|
578
|
+
background-color: var(--ssml-editor-prosody-badge-bg);
|
|
579
|
+
border-color: var(--ssml-editor-prosody-badge-border);
|
|
580
|
+
}
|
|
581
|
+
[data-ssml-editor] .ssml-editor-switch-track[aria-checked="true"] {
|
|
582
|
+
background-color: var(--ssml-editor-switch-active);
|
|
583
|
+
}
|
|
584
|
+
[data-ssml-editor] .ssml-editor-switch-track[aria-checked="true"] .ssml-editor-switch-thumb {
|
|
585
|
+
transform: translateX(1.25rem);
|
|
586
|
+
}
|
|
587
|
+
[data-ssml-editor] .ssml-editor-switch-track:focus-visible {
|
|
588
|
+
outline: 2px solid var(--ssml-editor-switch-active);
|
|
589
|
+
outline-offset: 2px;
|
|
590
|
+
}
|
|
591
|
+
[data-ssml-editor] .ssml-editor-help-settings-summary {
|
|
592
|
+
list-style: none;
|
|
593
|
+
}
|
|
594
|
+
[data-ssml-editor] .ssml-editor-help-settings-summary::-webkit-details-marker {
|
|
595
|
+
display: none;
|
|
596
|
+
}
|
|
597
|
+
[data-ssml-editor] .ssml-editor-help-settings-summary:hover {
|
|
598
|
+
background-color: var(--ssml-editor-preview-bg);
|
|
599
|
+
}
|
|
600
|
+
[data-ssml-editor] .ssml-editor-help-settings-summary:focus-visible {
|
|
601
|
+
outline: 2px solid var(--ssml-editor-control-border);
|
|
602
|
+
outline-offset: -2px;
|
|
603
|
+
}
|
|
604
|
+
[data-ssml-editor] .ssml-editor-help-settings-summary::before {
|
|
605
|
+
content: "▸";
|
|
606
|
+
display: inline-flex;
|
|
607
|
+
align-items: center;
|
|
608
|
+
justify-content: center;
|
|
609
|
+
font-size: 1rem;
|
|
610
|
+
line-height: 1.45;
|
|
611
|
+
}
|
|
612
|
+
[data-ssml-editor]
|
|
613
|
+
.ssml-editor-help-settings-accordion[open]
|
|
614
|
+
> .ssml-editor-help-settings-summary::before {
|
|
615
|
+
content: "▾";
|
|
616
|
+
}
|
|
617
|
+
`.trim();
|
|
618
|
+
|
|
619
|
+
function injectEditorTheme(): void {
|
|
620
|
+
if (typeof document !== "undefined" && !document.getElementById(STYLE_ID)) {
|
|
621
|
+
const style = document.createElement("style");
|
|
622
|
+
style.id = STYLE_ID;
|
|
623
|
+
style.textContent = STYLE_CSS;
|
|
624
|
+
document.head.appendChild(style);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface SsmlEditorProps {
|
|
629
|
+
document: SsmlDocument;
|
|
630
|
+
onChange?: (document: SsmlDocument) => void;
|
|
631
|
+
onSsmlChange?: (xml: string) => void;
|
|
632
|
+
onSelectionChange?: (info: SelectionInfo) => void;
|
|
633
|
+
/** Called with the selected partial SSML when the selection preview action is used. The action is disabled when omitted. */
|
|
634
|
+
onPreviewSelection?: (ssml: string) => void;
|
|
635
|
+
/** UI language. Japanese is used when omitted. */
|
|
636
|
+
language?: SsmlEditorLanguage;
|
|
637
|
+
/** UI locale. Japanese is used when omitted; takes precedence over the legacy language prop. */
|
|
638
|
+
locale?: SsmlEditorLocale;
|
|
639
|
+
/** Whether the toolbar is displayed. */
|
|
640
|
+
showToolbar?: boolean;
|
|
641
|
+
/** Whether toolbar action icons are displayed. */
|
|
642
|
+
showToolbarIcons?: boolean;
|
|
643
|
+
/** Whether toolbar action text labels are displayed. */
|
|
644
|
+
showToolbarLabels?: boolean;
|
|
645
|
+
/** Whether inline SSML decorations are displayed. The toolbar switch can change this at runtime. */
|
|
646
|
+
showDecorations?: boolean;
|
|
647
|
+
/** Whether SSML CodeLens quick controls are displayed. */
|
|
648
|
+
enableCodeLens?: boolean;
|
|
649
|
+
/** Controls which editor action buttons are displayed. Unspecified buttons are shown. */
|
|
650
|
+
buttonVisibility?: SsmlEditorButtonVisibility;
|
|
651
|
+
/** Monaco editor settings. */
|
|
652
|
+
editorOptions?: SsmlEditorOptions;
|
|
653
|
+
/** Alias for editorOptions. */
|
|
654
|
+
settings?: SsmlEditorOptions;
|
|
655
|
+
/** Fallback UI displayed while Monaco is loading. */
|
|
656
|
+
loadingFallback?: ReactNode;
|
|
657
|
+
/** Height of the Monaco editor. */
|
|
658
|
+
height?: string | number;
|
|
659
|
+
/** Minimum height of the Monaco editor container. */
|
|
660
|
+
minHeight?: string | number;
|
|
661
|
+
/** Whether the Monaco editor is read-only. */
|
|
662
|
+
readOnly?: boolean;
|
|
663
|
+
/** Monaco theme mode. */
|
|
664
|
+
theme?: SsmlEditorTheme;
|
|
665
|
+
/** Monaco editor font size. */
|
|
666
|
+
fontSize?: number;
|
|
667
|
+
/** Monaco editor word wrapping mode. */
|
|
668
|
+
wordWrap?: SsmlEditorWordWrap;
|
|
669
|
+
/** Monaco editor line number mode. */
|
|
670
|
+
lineNumbers?: SsmlEditorLineNumbers;
|
|
671
|
+
/** Whether the Monaco minimap is displayed. */
|
|
672
|
+
minimap?: boolean;
|
|
673
|
+
/** Whether Monaco automatically lays out when its container changes size. */
|
|
674
|
+
automaticLayout?: boolean;
|
|
675
|
+
/** Reorders all toolbar controls. Unlisted controls follow. */
|
|
676
|
+
toolbarOrder?: readonly SsmlEditorButton[];
|
|
677
|
+
/** Adds vertical separators between configured toolbar groups. */
|
|
678
|
+
toolbarGroups?: readonly SsmlEditorToolbarGroup[];
|
|
679
|
+
/** Reorders built-in and custom insertion menus. Unlisted insertions follow. */
|
|
680
|
+
insertionOrder?: readonly string[];
|
|
681
|
+
/** Visually groups insertion menus when toolbarGroups is not supplied. */
|
|
682
|
+
insertionGroups?: readonly SsmlEditorInsertionGroup[];
|
|
683
|
+
/** Replaces built-in insertion definitions with custom definitions by ID. */
|
|
684
|
+
customInsertions?: SsmlEditorCustomInsertionCollection;
|
|
685
|
+
/** Adds insertion definitions without replacing built-in definitions. */
|
|
686
|
+
additionalInsertions?: SsmlEditorCustomInsertionCollection;
|
|
687
|
+
/** Candidate style values shown by the built-in emotion insertion. */
|
|
688
|
+
emotionStyles?: readonly string[];
|
|
689
|
+
/** Class name applied to the editor container. */
|
|
690
|
+
className?: string;
|
|
691
|
+
/** Inline styles applied to the editor container. */
|
|
692
|
+
style?: CSSProperties;
|
|
693
|
+
/** Class name applied to the toolbar. */
|
|
694
|
+
toolbarClassName?: string;
|
|
695
|
+
/** Inline styles applied to the toolbar. */
|
|
696
|
+
toolbarStyle?: CSSProperties;
|
|
697
|
+
/** Class name applied to the editor display area. */
|
|
698
|
+
displayClassName?: string;
|
|
699
|
+
/** Inline styles applied to the editor display area. */
|
|
700
|
+
displayStyle?: CSSProperties;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export interface SelectionInfo {
|
|
704
|
+
selectedText: string;
|
|
705
|
+
characterCount: number;
|
|
706
|
+
hasSelection: boolean;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export interface SsmlEditorRef {
|
|
710
|
+
getSelectedSsml(): string | null;
|
|
711
|
+
getCurrentLineSsml(): string | null;
|
|
712
|
+
getFullSsml(): string;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface ToolbarDecorationsSwitchProps {
|
|
716
|
+
copy: EditorCopy;
|
|
717
|
+
showToolbarIcons: boolean;
|
|
718
|
+
showToolbarText: boolean;
|
|
719
|
+
decorationsVisible: boolean;
|
|
720
|
+
onVisibilityChange: (visible: boolean) => void;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function ToolbarDecorationsSwitch({
|
|
724
|
+
copy,
|
|
725
|
+
showToolbarIcons,
|
|
726
|
+
showToolbarText,
|
|
727
|
+
decorationsVisible,
|
|
728
|
+
onVisibilityChange,
|
|
729
|
+
}: ToolbarDecorationsSwitchProps): ReactElement {
|
|
730
|
+
return (
|
|
731
|
+
<div style={styles.toolbarSwitch}>
|
|
732
|
+
{showToolbarIcons && (
|
|
733
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
734
|
+
☆
|
|
735
|
+
</span>
|
|
736
|
+
)}
|
|
737
|
+
{showToolbarText && <span>{copy.decorations}</span>}
|
|
738
|
+
<button
|
|
739
|
+
type="button"
|
|
740
|
+
className="ssml-editor-switch-track"
|
|
741
|
+
style={styles.toolbarSwitchTrack}
|
|
742
|
+
role="switch"
|
|
743
|
+
aria-checked={decorationsVisible}
|
|
744
|
+
aria-label={copy.decorations}
|
|
745
|
+
title={decorationsVisible ? copy.decorationsHideTitle : copy.decorationsShowTitle}
|
|
746
|
+
onClick={() => onVisibilityChange(!decorationsVisible)}
|
|
747
|
+
>
|
|
748
|
+
<span className="ssml-editor-switch-thumb" style={styles.toolbarSwitchThumb} aria-hidden="true" />
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function SsmlEditor(
|
|
755
|
+
{
|
|
756
|
+
document,
|
|
757
|
+
onChange,
|
|
758
|
+
onSsmlChange,
|
|
759
|
+
onSelectionChange,
|
|
760
|
+
onPreviewSelection,
|
|
761
|
+
language: languageProp,
|
|
762
|
+
locale: localeProp,
|
|
763
|
+
showToolbar = true,
|
|
764
|
+
showToolbarIcons = true,
|
|
765
|
+
showToolbarLabels = false,
|
|
766
|
+
showDecorations = false,
|
|
767
|
+
enableCodeLens = true,
|
|
768
|
+
buttonVisibility,
|
|
769
|
+
editorOptions,
|
|
770
|
+
settings,
|
|
771
|
+
height,
|
|
772
|
+
minHeight,
|
|
773
|
+
readOnly,
|
|
774
|
+
theme,
|
|
775
|
+
fontSize,
|
|
776
|
+
wordWrap,
|
|
777
|
+
lineNumbers,
|
|
778
|
+
minimap,
|
|
779
|
+
automaticLayout,
|
|
780
|
+
toolbarOrder,
|
|
781
|
+
toolbarGroups,
|
|
782
|
+
insertionOrder,
|
|
783
|
+
insertionGroups,
|
|
784
|
+
customInsertions,
|
|
785
|
+
additionalInsertions,
|
|
786
|
+
emotionStyles,
|
|
787
|
+
className,
|
|
788
|
+
style,
|
|
789
|
+
toolbarClassName,
|
|
790
|
+
toolbarStyle,
|
|
791
|
+
displayClassName,
|
|
792
|
+
displayStyle,
|
|
793
|
+
loadingFallback,
|
|
794
|
+
}: SsmlEditorProps,
|
|
795
|
+
ref,
|
|
796
|
+
): ReactElement {
|
|
797
|
+
const resolvedEditorOptions: SsmlEditorOptions = {
|
|
798
|
+
...settings,
|
|
799
|
+
...editorOptions,
|
|
800
|
+
...(height === undefined ? {} : { height }),
|
|
801
|
+
...(minHeight === undefined ? {} : { minHeight }),
|
|
802
|
+
...(readOnly === undefined ? {} : { readOnly }),
|
|
803
|
+
...(theme === undefined ? {} : { theme }),
|
|
804
|
+
...(fontSize === undefined ? {} : { fontSize }),
|
|
805
|
+
...(wordWrap === undefined ? {} : { wordWrap }),
|
|
806
|
+
...(lineNumbers === undefined ? {} : { lineNumbers }),
|
|
807
|
+
...(minimap === undefined ? {} : { minimap }),
|
|
808
|
+
...(automaticLayout === undefined ? {} : { automaticLayout }),
|
|
809
|
+
};
|
|
810
|
+
const language = localeProp ?? languageProp ?? DEFAULT_LOCALE;
|
|
811
|
+
const copy = EDITOR_COPY[language];
|
|
812
|
+
const showToolbarText = showToolbarLabels || !showToolbarIcons;
|
|
813
|
+
const resolvedTheme = resolvedEditorOptions.theme ?? "system";
|
|
814
|
+
const editorMinHeight = resolvedEditorOptions.minHeight ?? "8rem";
|
|
815
|
+
const editorHeight = resolvedEditorOptions.height ?? editorMinHeight;
|
|
816
|
+
const isReadOnly = resolvedEditorOptions.readOnly ?? false;
|
|
817
|
+
const toolbarButtonStyle = showToolbarText
|
|
818
|
+
? styles.toolbarButton
|
|
819
|
+
: { ...styles.toolbarButton, ...styles.toolbarIconOnly };
|
|
820
|
+
const configuredInsertions = getConfiguredInsertions(emotionStyles, customInsertions, additionalInsertions);
|
|
821
|
+
const visibleInsertions = orderInsertions(configuredInsertions, insertionOrder).filter((insertion) =>
|
|
822
|
+
isSsmlEditorButtonVisible(buttonVisibility, insertion.id),
|
|
823
|
+
);
|
|
824
|
+
const insertionById = new Map(visibleInsertions.map((insertion) => [insertion.id, insertion]));
|
|
825
|
+
const configuredInsertionGroups = insertionGroups ?? DEFAULT_INSERTION_GROUPS;
|
|
826
|
+
const groupedInsertionIds = new Set<string>();
|
|
827
|
+
const visibleInsertionGroups = configuredInsertionGroups
|
|
828
|
+
.map((group) => {
|
|
829
|
+
const groupInsertionIds = new Set(group.insertionIds);
|
|
830
|
+
const candidates =
|
|
831
|
+
insertionOrder && insertionOrder.length > 0
|
|
832
|
+
? visibleInsertions.filter((insertion) => groupInsertionIds.has(insertion.id))
|
|
833
|
+
: group.insertionIds
|
|
834
|
+
.map((id) => insertionById.get(id))
|
|
835
|
+
.filter((insertion): insertion is SsmlInsertionDefinition => insertion !== undefined);
|
|
836
|
+
const insertions = candidates.filter((insertion) => {
|
|
837
|
+
if (groupedInsertionIds.has(insertion.id)) {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
groupedInsertionIds.add(insertion.id);
|
|
841
|
+
return true;
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
return { group, insertions };
|
|
845
|
+
})
|
|
846
|
+
.filter(({ insertions }) => insertions.length > 0);
|
|
847
|
+
const ungroupedInsertions = visibleInsertions.filter((insertion) => !groupedInsertionIds.has(insertion.id));
|
|
848
|
+
const toolbarActionIds = ["undo", "redo", "clearAll", "format", "decorations", "help"] as const;
|
|
849
|
+
const defaultToolbarOrder = [
|
|
850
|
+
"undo",
|
|
851
|
+
"redo",
|
|
852
|
+
...visibleInsertionGroups.flatMap(({ insertions }) => insertions.map((insertion) => insertion.id)),
|
|
853
|
+
...ungroupedInsertions.map((insertion) => insertion.id),
|
|
854
|
+
"clearAll",
|
|
855
|
+
"format",
|
|
856
|
+
"decorations",
|
|
857
|
+
"help",
|
|
858
|
+
];
|
|
859
|
+
const visibleToolbarIds = new Set<string>([
|
|
860
|
+
...visibleInsertions.map((insertion) => insertion.id),
|
|
861
|
+
...toolbarActionIds.filter((id) => isSsmlEditorButtonVisible(buttonVisibility, id)),
|
|
862
|
+
]);
|
|
863
|
+
const toolbarItemIds = orderToolbarButtons(
|
|
864
|
+
defaultToolbarOrder.filter((id) => visibleToolbarIds.has(id)),
|
|
865
|
+
toolbarOrder,
|
|
866
|
+
);
|
|
867
|
+
const effectiveToolbarGroups = toolbarGroups ?? createDefaultToolbarGroups(configuredInsertionGroups);
|
|
868
|
+
const toolbarGroupByButtonId = new Map<string, string>();
|
|
869
|
+
for (const group of effectiveToolbarGroups) {
|
|
870
|
+
for (const buttonId of group.buttonIds) {
|
|
871
|
+
if (!toolbarGroupByButtonId.has(buttonId)) {
|
|
872
|
+
toolbarGroupByButtonId.set(buttonId, group.id);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const helpInsertions = [
|
|
877
|
+
...visibleInsertionGroups.flatMap(({ insertions }) => insertions),
|
|
878
|
+
...ungroupedInsertions,
|
|
879
|
+
].filter(
|
|
880
|
+
(insertion, index, insertions) => insertions.findIndex((candidate) => candidate.id === insertion.id) === index,
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const {
|
|
884
|
+
editorRef,
|
|
885
|
+
text,
|
|
886
|
+
selectionOverlay,
|
|
887
|
+
activeTags,
|
|
888
|
+
isDarkTheme,
|
|
889
|
+
decorationsVisible,
|
|
890
|
+
setDecorationsVisible,
|
|
891
|
+
isHelpOpen,
|
|
892
|
+
setIsHelpOpen,
|
|
893
|
+
syntaxError,
|
|
894
|
+
setSyntaxError,
|
|
895
|
+
helpPanelId,
|
|
896
|
+
handleTextChange,
|
|
897
|
+
handleInsert,
|
|
898
|
+
handleInsertBreak,
|
|
899
|
+
handleInsertProsody,
|
|
900
|
+
handleInsertText,
|
|
901
|
+
handleClear,
|
|
902
|
+
handleFormat,
|
|
903
|
+
handleUndo,
|
|
904
|
+
handleRedo,
|
|
905
|
+
previewSelection,
|
|
906
|
+
canPreviewSelection,
|
|
907
|
+
refreshSelectionOverlay,
|
|
908
|
+
updateActiveTags,
|
|
909
|
+
getSelectedSsml,
|
|
910
|
+
getCurrentLineSsml,
|
|
911
|
+
getFullSsml,
|
|
912
|
+
getOuterVoiceName,
|
|
913
|
+
handleCodeLensAction,
|
|
914
|
+
openPopoverId,
|
|
915
|
+
popoverVoiceName,
|
|
916
|
+
popoverPosition,
|
|
917
|
+
isPopoverOpen,
|
|
918
|
+
togglePopover,
|
|
919
|
+
closePopover,
|
|
920
|
+
setPopoverMenuRef,
|
|
921
|
+
} = useSsmlEditorState({
|
|
922
|
+
document,
|
|
923
|
+
resolvedTheme,
|
|
924
|
+
showDecorations,
|
|
925
|
+
onChange,
|
|
926
|
+
onSsmlChange,
|
|
927
|
+
onSelectionChange,
|
|
928
|
+
onPreviewSelection,
|
|
929
|
+
injectTheme: injectEditorTheme,
|
|
930
|
+
});
|
|
931
|
+
const { onMount } = useSsmlMonaco({
|
|
932
|
+
editorRef,
|
|
933
|
+
language,
|
|
934
|
+
text,
|
|
935
|
+
decorationsVisible,
|
|
936
|
+
getOuterVoiceName,
|
|
937
|
+
onSelectionOverlayChange: refreshSelectionOverlay,
|
|
938
|
+
onActiveTagsChange: updateActiveTags,
|
|
939
|
+
onSyntaxErrorChange: setSyntaxError,
|
|
940
|
+
enableCodeLens,
|
|
941
|
+
onOpenPopover: handleCodeLensAction,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
useImperativeHandle(
|
|
945
|
+
ref,
|
|
946
|
+
() => ({
|
|
947
|
+
getSelectedSsml,
|
|
948
|
+
getCurrentLineSsml,
|
|
949
|
+
getFullSsml,
|
|
950
|
+
}),
|
|
951
|
+
[getCurrentLineSsml, getFullSsml, getSelectedSsml],
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
const renderInsertion = (insertion: SsmlEditorInsertionDefinition): ReactElement => {
|
|
955
|
+
const isTimingPopover = insertion.tagName !== undefined && TIMING_POPOVER_TAGS.has(insertion.tagName);
|
|
956
|
+
const isProsodyPopover = insertion.tagName !== undefined && PROSODY_POPOVER_TAGS.has(insertion.tagName);
|
|
957
|
+
const isTextPopover = insertion.tagName !== undefined && TEXT_POPOVER_TAGS.has(insertion.tagName);
|
|
958
|
+
const insertionButtonStyle =
|
|
959
|
+
insertion.tagName && activeTags.has(insertion.tagName.toLowerCase())
|
|
960
|
+
? { ...toolbarButtonStyle, ...styles.toolbarButtonActive }
|
|
961
|
+
: toolbarButtonStyle;
|
|
962
|
+
const props = {
|
|
963
|
+
language,
|
|
964
|
+
isDarkTheme,
|
|
965
|
+
showToolbarIcons,
|
|
966
|
+
showToolbarText,
|
|
967
|
+
toolbarButtonStyle: insertionButtonStyle,
|
|
968
|
+
emptyOptionsMessage: copy.noAvailableOptions,
|
|
969
|
+
isReadOnly,
|
|
970
|
+
openPopoverId,
|
|
971
|
+
menuPosition: popoverPosition,
|
|
972
|
+
menuRef: setPopoverMenuRef,
|
|
973
|
+
onToggle: togglePopover,
|
|
974
|
+
onClose: closePopover,
|
|
975
|
+
onApply: isTimingPopover
|
|
976
|
+
? handleInsertBreak
|
|
977
|
+
: isProsodyPopover
|
|
978
|
+
? handleInsertProsody
|
|
979
|
+
: isTextPopover
|
|
980
|
+
? handleInsertText
|
|
981
|
+
: handleInsert,
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
if (isTimingPopover) {
|
|
985
|
+
return <TimingPopovers {...props} insertions={[insertion]} />;
|
|
986
|
+
}
|
|
987
|
+
if (isProsodyPopover) {
|
|
988
|
+
return <ProsodyPopovers {...props} insertions={[insertion]} voiceName={popoverVoiceName} />;
|
|
989
|
+
}
|
|
990
|
+
if (isTextPopover) {
|
|
991
|
+
return <TextPopovers {...props} insertions={[insertion]} />;
|
|
992
|
+
}
|
|
993
|
+
return (
|
|
994
|
+
<InsertionPopover
|
|
995
|
+
{...props}
|
|
996
|
+
insertion={insertion}
|
|
997
|
+
isOpen={isPopoverOpen(insertion.id)}
|
|
998
|
+
onToggle={(trigger) => togglePopover(insertion.id, trigger)}
|
|
999
|
+
/>
|
|
1000
|
+
);
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const toolbarItemRenderers = new Map<string, () => ReactElement>([
|
|
1004
|
+
[
|
|
1005
|
+
"undo",
|
|
1006
|
+
() => (
|
|
1007
|
+
<button
|
|
1008
|
+
key="undo"
|
|
1009
|
+
type="button"
|
|
1010
|
+
style={toolbarButtonStyle}
|
|
1011
|
+
aria-label={copy.undo}
|
|
1012
|
+
title={copy.undoTitle}
|
|
1013
|
+
disabled={isReadOnly}
|
|
1014
|
+
onClick={() => {
|
|
1015
|
+
if (!isReadOnly) {
|
|
1016
|
+
handleUndo();
|
|
1017
|
+
}
|
|
1018
|
+
}}
|
|
1019
|
+
>
|
|
1020
|
+
{showToolbarIcons && (
|
|
1021
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
1022
|
+
↩
|
|
1023
|
+
</span>
|
|
1024
|
+
)}
|
|
1025
|
+
{showToolbarText && <span>{copy.undo}</span>}
|
|
1026
|
+
</button>
|
|
1027
|
+
),
|
|
1028
|
+
],
|
|
1029
|
+
[
|
|
1030
|
+
"redo",
|
|
1031
|
+
() => (
|
|
1032
|
+
<button
|
|
1033
|
+
key="redo"
|
|
1034
|
+
type="button"
|
|
1035
|
+
style={toolbarButtonStyle}
|
|
1036
|
+
aria-label={copy.redo}
|
|
1037
|
+
title={copy.redoTitle}
|
|
1038
|
+
disabled={isReadOnly}
|
|
1039
|
+
onClick={() => {
|
|
1040
|
+
if (!isReadOnly) {
|
|
1041
|
+
handleRedo();
|
|
1042
|
+
}
|
|
1043
|
+
}}
|
|
1044
|
+
>
|
|
1045
|
+
{showToolbarIcons && (
|
|
1046
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
1047
|
+
↪
|
|
1048
|
+
</span>
|
|
1049
|
+
)}
|
|
1050
|
+
{showToolbarText && <span>{copy.redo}</span>}
|
|
1051
|
+
</button>
|
|
1052
|
+
),
|
|
1053
|
+
],
|
|
1054
|
+
[
|
|
1055
|
+
"clearAll",
|
|
1056
|
+
() => (
|
|
1057
|
+
<button
|
|
1058
|
+
key="clearAll"
|
|
1059
|
+
type="button"
|
|
1060
|
+
style={toolbarButtonStyle}
|
|
1061
|
+
aria-label={copy.clearAll}
|
|
1062
|
+
title={copy.clearAllTitle}
|
|
1063
|
+
disabled={isReadOnly}
|
|
1064
|
+
onClick={() => {
|
|
1065
|
+
if (!isReadOnly) {
|
|
1066
|
+
handleClear();
|
|
1067
|
+
}
|
|
1068
|
+
}}
|
|
1069
|
+
>
|
|
1070
|
+
{showToolbarIcons && (
|
|
1071
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
1072
|
+
×
|
|
1073
|
+
</span>
|
|
1074
|
+
)}
|
|
1075
|
+
{showToolbarText && <span>{copy.clearAll}</span>}
|
|
1076
|
+
</button>
|
|
1077
|
+
),
|
|
1078
|
+
],
|
|
1079
|
+
[
|
|
1080
|
+
"format",
|
|
1081
|
+
() => (
|
|
1082
|
+
<button
|
|
1083
|
+
key="format"
|
|
1084
|
+
type="button"
|
|
1085
|
+
style={toolbarButtonStyle}
|
|
1086
|
+
aria-label={copy.format}
|
|
1087
|
+
title={copy.formatTitle}
|
|
1088
|
+
disabled={isReadOnly}
|
|
1089
|
+
onClick={() => {
|
|
1090
|
+
if (!isReadOnly) {
|
|
1091
|
+
handleFormat();
|
|
1092
|
+
}
|
|
1093
|
+
}}
|
|
1094
|
+
>
|
|
1095
|
+
{showToolbarIcons && (
|
|
1096
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
1097
|
+
≡
|
|
1098
|
+
</span>
|
|
1099
|
+
)}
|
|
1100
|
+
{showToolbarText && <span>{copy.format}</span>}
|
|
1101
|
+
</button>
|
|
1102
|
+
),
|
|
1103
|
+
],
|
|
1104
|
+
[
|
|
1105
|
+
"decorations",
|
|
1106
|
+
() => (
|
|
1107
|
+
<ToolbarDecorationsSwitch
|
|
1108
|
+
key="decorations"
|
|
1109
|
+
copy={copy}
|
|
1110
|
+
showToolbarIcons={showToolbarIcons}
|
|
1111
|
+
showToolbarText={showToolbarText}
|
|
1112
|
+
decorationsVisible={decorationsVisible}
|
|
1113
|
+
onVisibilityChange={setDecorationsVisible}
|
|
1114
|
+
/>
|
|
1115
|
+
),
|
|
1116
|
+
],
|
|
1117
|
+
[
|
|
1118
|
+
"help",
|
|
1119
|
+
() => (
|
|
1120
|
+
<button
|
|
1121
|
+
key="help"
|
|
1122
|
+
type="button"
|
|
1123
|
+
style={toolbarButtonStyle}
|
|
1124
|
+
aria-label={copy.help}
|
|
1125
|
+
title={copy.helpTitle}
|
|
1126
|
+
aria-expanded={isHelpOpen}
|
|
1127
|
+
aria-controls={helpPanelId}
|
|
1128
|
+
onClick={() => setIsHelpOpen((open) => !open)}
|
|
1129
|
+
>
|
|
1130
|
+
{showToolbarIcons && (
|
|
1131
|
+
<span style={styles.toolbarIcon} aria-hidden="true">
|
|
1132
|
+
?
|
|
1133
|
+
</span>
|
|
1134
|
+
)}
|
|
1135
|
+
{showToolbarText && <span>{copy.help}</span>}
|
|
1136
|
+
</button>
|
|
1137
|
+
),
|
|
1138
|
+
],
|
|
1139
|
+
]);
|
|
1140
|
+
for (const insertion of visibleInsertions) {
|
|
1141
|
+
toolbarItemRenderers.set(insertion.id, () => renderInsertion(insertion));
|
|
1142
|
+
}
|
|
1143
|
+
const renderToolbarItems = (): ReactElement[] => {
|
|
1144
|
+
let previousRenderedGroupId = UNGROUPED_TOOLBAR_GROUP;
|
|
1145
|
+
let hasRenderedToolbarItem = false;
|
|
1146
|
+
const renderedItems: ReactElement[] = [];
|
|
1147
|
+
|
|
1148
|
+
for (const id of toolbarItemIds) {
|
|
1149
|
+
const render = toolbarItemRenderers.get(id);
|
|
1150
|
+
if (!render) {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const groupId = toolbarGroupByButtonId.get(id) ?? UNGROUPED_TOOLBAR_GROUP;
|
|
1155
|
+
const showSeparator = hasRenderedToolbarItem && groupId !== previousRenderedGroupId;
|
|
1156
|
+
previousRenderedGroupId = groupId;
|
|
1157
|
+
hasRenderedToolbarItem = true;
|
|
1158
|
+
renderedItems.push(
|
|
1159
|
+
<Fragment key={id}>
|
|
1160
|
+
{showSeparator && <span style={styles.toolbarSeparator} aria-hidden="true" />}
|
|
1161
|
+
{render()}
|
|
1162
|
+
</Fragment>,
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return renderedItems;
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
return (
|
|
1170
|
+
<section
|
|
1171
|
+
className={className}
|
|
1172
|
+
style={{ ...styles.container, ...style }}
|
|
1173
|
+
aria-label={copy.editorAriaLabel}
|
|
1174
|
+
data-ssml-editor=""
|
|
1175
|
+
data-theme={isDarkTheme ? "dark" : "light"}
|
|
1176
|
+
>
|
|
1177
|
+
{showToolbar && (
|
|
1178
|
+
<div
|
|
1179
|
+
className={toolbarClassName}
|
|
1180
|
+
style={{ ...styles.toolbarContainer, ...toolbarStyle }}
|
|
1181
|
+
data-ssml-editor-toolbar=""
|
|
1182
|
+
>
|
|
1183
|
+
<div
|
|
1184
|
+
style={styles.toolbarActions}
|
|
1185
|
+
role="toolbar"
|
|
1186
|
+
aria-label={copy.toolbarAriaLabel}
|
|
1187
|
+
data-ssml-editor-toolbar-actions=""
|
|
1188
|
+
>
|
|
1189
|
+
{renderToolbarItems()}
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
)}
|
|
1193
|
+
<div className={displayClassName} style={{ ...styles.display, ...displayStyle }} data-ssml-editor-display="">
|
|
1194
|
+
{isHelpOpen && isSsmlEditorButtonVisible(buttonVisibility, "help") && (
|
|
1195
|
+
<section id={helpPanelId} style={styles.helpPanel} aria-label={copy.helpHeading}>
|
|
1196
|
+
<h3 style={styles.helpHeading}>{copy.helpHeading}</h3>
|
|
1197
|
+
<p style={styles.helpDescription}>{copy.helpDescription}</p>
|
|
1198
|
+
{helpInsertions.length > 0 && (
|
|
1199
|
+
<ul style={styles.helpList}>
|
|
1200
|
+
{helpInsertions.map((insertion) => (
|
|
1201
|
+
<li key={insertion.id} style={styles.helpItem}>
|
|
1202
|
+
<details className="ssml-editor-help-settings-accordion" style={styles.helpSettingsAccordion}>
|
|
1203
|
+
<summary className="ssml-editor-help-settings-summary" style={styles.helpSettingsSummary}>
|
|
1204
|
+
<span style={styles.helpIcon} aria-hidden="true">
|
|
1205
|
+
{insertion.icon}
|
|
1206
|
+
</span>
|
|
1207
|
+
<span style={styles.helpSettingsSummaryContent}>
|
|
1208
|
+
<strong>{insertion.labels[language]}</strong>{" "}
|
|
1209
|
+
{insertion.tagName && (
|
|
1210
|
+
<>
|
|
1211
|
+
<code>{`<${insertion.tagName}${insertion.selfClosing ? "/>" : ">"}`}</code> —{" "}
|
|
1212
|
+
</>
|
|
1213
|
+
)}
|
|
1214
|
+
{insertion.descriptions[language]}
|
|
1215
|
+
</span>
|
|
1216
|
+
</summary>
|
|
1217
|
+
<p style={styles.helpSettingsDescription}>
|
|
1218
|
+
<strong>{copy.parameters}:</strong> {insertion.parameterDescription[language]}
|
|
1219
|
+
</p>
|
|
1220
|
+
<ul style={styles.helpSettingsList}>
|
|
1221
|
+
{insertion.options.map((option) => (
|
|
1222
|
+
<li key={option.value}>
|
|
1223
|
+
<strong>{option.labels[language]}</strong>
|
|
1224
|
+
{option.descriptions && <> — {option.descriptions[language]}</>}
|
|
1225
|
+
</li>
|
|
1226
|
+
))}
|
|
1227
|
+
</ul>
|
|
1228
|
+
</details>
|
|
1229
|
+
</li>
|
|
1230
|
+
))}
|
|
1231
|
+
</ul>
|
|
1232
|
+
)}
|
|
1233
|
+
</section>
|
|
1234
|
+
)}
|
|
1235
|
+
<div style={{ ...styles.editor, minHeight: editorMinHeight }}>
|
|
1236
|
+
<Editor
|
|
1237
|
+
height={editorHeight}
|
|
1238
|
+
language="xml"
|
|
1239
|
+
theme={isDarkTheme ? "vs-dark" : "light"}
|
|
1240
|
+
options={{
|
|
1241
|
+
automaticLayout: resolvedEditorOptions.automaticLayout ?? true,
|
|
1242
|
+
autoClosingBrackets: "never",
|
|
1243
|
+
fontSize: resolvedEditorOptions.fontSize,
|
|
1244
|
+
hover: { enabled: "on" },
|
|
1245
|
+
inlayHints: { enabled: decorationsVisible ? "on" : "off" },
|
|
1246
|
+
lineNumbers: resolvedEditorOptions.lineNumbers,
|
|
1247
|
+
minimap: {
|
|
1248
|
+
enabled: resolvedEditorOptions.minimap ?? true,
|
|
1249
|
+
},
|
|
1250
|
+
readOnly: isReadOnly,
|
|
1251
|
+
wordWrap: resolvedEditorOptions.wordWrap,
|
|
1252
|
+
}}
|
|
1253
|
+
value={text}
|
|
1254
|
+
loading={loadingFallback}
|
|
1255
|
+
onMount={onMount}
|
|
1256
|
+
onChange={(value) => handleTextChange(value ?? "")}
|
|
1257
|
+
/>
|
|
1258
|
+
{selectionOverlay.hasSelection && selectionOverlay.position && (
|
|
1259
|
+
<div
|
|
1260
|
+
role="toolbar"
|
|
1261
|
+
aria-label={copy.selectionActions}
|
|
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))`,
|
|
1271
|
+
}}
|
|
1272
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1273
|
+
>
|
|
1274
|
+
<span style={styles.selectionCount} aria-live="polite">
|
|
1275
|
+
{selectionOverlay.characterCount}
|
|
1276
|
+
{copy.selectionCountSuffix}
|
|
1277
|
+
</span>
|
|
1278
|
+
<span style={styles.selectionActionsDivider} aria-hidden="true" />
|
|
1279
|
+
<button
|
|
1280
|
+
type="button"
|
|
1281
|
+
style={styles.selectionActionButton}
|
|
1282
|
+
title={copy.previewSelectionTitle}
|
|
1283
|
+
disabled={!canPreviewSelection}
|
|
1284
|
+
onClick={previewSelection}
|
|
1285
|
+
>
|
|
1286
|
+
<span style={styles.selectionActionIcon} aria-hidden="true">
|
|
1287
|
+
▶
|
|
1288
|
+
</span>
|
|
1289
|
+
{copy.previewSelection}
|
|
1290
|
+
</button>
|
|
1291
|
+
</div>
|
|
1292
|
+
)}
|
|
1293
|
+
</div>
|
|
1294
|
+
{syntaxError && (
|
|
1295
|
+
<p style={styles.error} role="alert">
|
|
1296
|
+
{copy.syntaxError}: {syntaxError.message}
|
|
1297
|
+
</p>
|
|
1298
|
+
)}
|
|
1299
|
+
</div>
|
|
1300
|
+
</section>
|
|
1301
|
+
);
|
|
1302
|
+
});
|