@openleaf-editor/ui 0.1.0-beta.2 → 0.1.0-beta.4
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/README.md +85 -0
- package/dist/block-type.d.ts +5 -0
- package/dist/block-type.d.ts.map +1 -0
- package/dist/block-type.js +133 -0
- package/dist/block-type.js.map +1 -0
- package/dist/content-css.d.ts +18 -0
- package/dist/content-css.d.ts.map +1 -1
- package/dist/content-css.js +231 -16
- package/dist/content-css.js.map +1 -1
- package/dist/css.d.ts +23 -0
- package/dist/css.d.ts.map +1 -0
- package/dist/css.js +846 -0
- package/dist/css.js.map +1 -0
- package/dist/dialog.d.ts +93 -0
- package/dist/dialog.d.ts.map +1 -1
- package/dist/dialog.js +414 -86
- package/dist/dialog.js.map +1 -1
- package/dist/floating.d.ts +4 -0
- package/dist/floating.d.ts.map +1 -1
- package/dist/floating.js +50 -2
- package/dist/floating.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +29 -5
- package/dist/help.js.map +1 -1
- package/dist/i18n.d.ts +8 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +32 -7
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +7 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +6 -2
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +0 -1
- package/dist/items.d.ts.map +1 -1
- package/dist/items.js +211 -20
- package/dist/items.js.map +1 -1
- package/dist/live.d.ts +42 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +83 -0
- package/dist/live.js.map +1 -0
- package/dist/menu.d.ts +31 -2
- package/dist/menu.d.ts.map +1 -1
- package/dist/menu.js +163 -23
- package/dist/menu.js.map +1 -1
- package/dist/openleaf.css +147 -12
- package/dist/overflow.d.ts +47 -4
- package/dist/overflow.d.ts.map +1 -1
- package/dist/overflow.js +338 -83
- package/dist/overflow.js.map +1 -1
- package/dist/registry.d.ts +72 -18
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +7 -6
- package/dist/registry.js.map +1 -1
- package/dist/skins.d.ts.map +1 -1
- package/dist/skins.js +48 -14
- package/dist/skins.js.map +1 -1
- package/dist/styles.d.ts +61 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +77 -637
- package/dist/styles.js.map +1 -1
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/dist/toolbar.d.ts +71 -12
- package/dist/toolbar.d.ts.map +1 -1
- package/dist/toolbar.js +410 -166
- package/dist/toolbar.js.map +1 -1
- package/dist/upload.d.ts +32 -7
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +64 -8
- package/dist/upload.js.map +1 -1
- package/package.json +17 -3
package/dist/dialog.js
CHANGED
|
@@ -19,32 +19,99 @@
|
|
|
19
19
|
* toast -- loses the alt text they wrote when the upload fails, and makes the
|
|
20
20
|
* retry a whole new dialog.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { embedSrcFor, isSafeUrl } from '@openleaf-editor/core';
|
|
23
|
+
import { fill, t, withLocale } from './i18n.js';
|
|
24
|
+
import { ensureStyles, registerStyles } from './styles.js';
|
|
23
25
|
import { IMAGE_ACCEPT, dimension } from './upload.js';
|
|
24
26
|
import { filePickerFor, listedImageClasses, listedImages, listedLinks, } from './pickers.js';
|
|
27
|
+
/** Tokens the "open in a new window" checkbox is allowed to add or drop. */
|
|
28
|
+
const WINDOW_REL = new Set(['noopener', 'noreferrer']);
|
|
29
|
+
/**
|
|
30
|
+
* Keep author `rel` tokens; only the new-window checkbox may add or drop
|
|
31
|
+
* `noopener` / `noreferrer`. Replacing the whole attribute (issue #14's first
|
|
32
|
+
* cut, then #108) deleted `nofollow`, `sponsored`, `me`, and the rest.
|
|
33
|
+
*/
|
|
34
|
+
function mergeLinkRel(existing, newWindow) {
|
|
35
|
+
const tokens = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const token of (existing ?? '').trim().split(/\s+/)) {
|
|
38
|
+
if (!token)
|
|
39
|
+
continue;
|
|
40
|
+
const key = token.toLowerCase();
|
|
41
|
+
if (!newWindow && WINDOW_REL.has(key))
|
|
42
|
+
continue;
|
|
43
|
+
if (seen.has(key))
|
|
44
|
+
continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
tokens.push(token);
|
|
47
|
+
}
|
|
48
|
+
if (newWindow) {
|
|
49
|
+
for (const extra of ['noopener', 'noreferrer']) {
|
|
50
|
+
if (seen.has(extra))
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(extra);
|
|
53
|
+
tokens.push(extra);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return tokens.length > 0 ? tokens.join(' ') : null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Shown when an address fails `isSafeUrl` -- `javascript:`, `data:`, `vbscript:`
|
|
60
|
+
* and anything else outside the scheme allowlist.
|
|
61
|
+
*
|
|
62
|
+
* It names the editor's own limit rather than accusing the author, because the
|
|
63
|
+
* common case is a pasted tracking link or an intranet scheme, not an attack.
|
|
64
|
+
*/
|
|
65
|
+
const UNSTORABLE_ADDRESS = 'That address is not one the editor can store.';
|
|
66
|
+
/*
|
|
67
|
+
* Every colour reads the internal `--ol-*` token first and the public
|
|
68
|
+
* `--openleaf-*` name only as a fallback.
|
|
69
|
+
*
|
|
70
|
+
* That order matters because of where the dialog now lives. It used to be
|
|
71
|
+
* appended to `document.body`, outside `.ol-editor`, where none of the editor's
|
|
72
|
+
* tokens are in scope -- so `var(--openleaf-color-surface, #fff)` always took
|
|
73
|
+
* the hardcoded light fallback and `<openleaf-editor skin="midnight">` plus the
|
|
74
|
+
* Link button produced a white dialog with #1f2328 text sitting on a #0d1117
|
|
75
|
+
* editor. Mounted inside the host (see `showForm`), the skin's public tokens
|
|
76
|
+
* reach it directly.
|
|
77
|
+
*
|
|
78
|
+
* The public names alone would still not be enough, because the dark palette
|
|
79
|
+
* that `theme="dark"` installs is written in the *internal* names -- it is a set
|
|
80
|
+
* of `--ol-*` declarations whose values are `var(--openleaf-*, <dark>)`. Reading
|
|
81
|
+
* `--ol-surface` therefore resolves all four cases (light default, system dark,
|
|
82
|
+
* `theme="dark"`, and any skin) through one variable, and the `--openleaf-*`
|
|
83
|
+
* fallback still covers a dialog rendered outside a host.
|
|
84
|
+
*
|
|
85
|
+
* `.ol-error` was the one colour here that was not a token, and the one colour
|
|
86
|
+
* that has to be read: #cf222e is 5.36:1 on white but 3.53:1 on the dark
|
|
87
|
+
* surface. It is `--ol-danger` now, which resolves to #ff8182 (7.85:1) there.
|
|
88
|
+
*/
|
|
25
89
|
const DIALOG_CSS = `
|
|
26
90
|
.ol-dialog {
|
|
27
91
|
box-sizing: border-box;
|
|
28
92
|
max-width: min(28rem, calc(100vw - 2rem));
|
|
29
93
|
padding: 0;
|
|
30
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
31
|
-
border-radius: var(--openleaf-radius, 6px);
|
|
32
|
-
background: var(--openleaf-color-surface, #fff);
|
|
33
|
-
color: var(--openleaf-color-text, #1f2328);
|
|
34
|
-
font-family: var(--openleaf-font, system-ui, -apple-system, sans-serif);
|
|
35
|
-
font-size: var(--openleaf-font-size, 14px);
|
|
94
|
+
border: 1px solid var(--ol-border-strong, var(--openleaf-color-border-strong, #6e7781));
|
|
95
|
+
border-radius: var(--ol-radius, var(--openleaf-radius, 6px));
|
|
96
|
+
background: var(--ol-surface, var(--openleaf-color-surface, #fff));
|
|
97
|
+
color: var(--ol-text, var(--openleaf-color-text, #1f2328));
|
|
98
|
+
font-family: var(--ol-font, var(--openleaf-font, system-ui, -apple-system, sans-serif));
|
|
99
|
+
font-size: var(--ol-font-size, var(--openleaf-font-size, 14px));
|
|
36
100
|
}
|
|
37
101
|
.ol-dialog::backdrop { background: rgb(0 0 0 / 40%); }
|
|
38
102
|
.ol-dialog form { display: grid; gap: 12px; padding: 16px; margin: 0; }
|
|
39
103
|
.ol-dialog h2 { margin: 0; font-size: 1.1em; }
|
|
40
|
-
|
|
41
|
-
|
|
104
|
+
/* The field, not the label, is the grid: the hint sits between the two as the
|
|
105
|
+
control's DESCRIPTION rather than folding into its accessible name. */
|
|
106
|
+
.ol-dialog .ol-field { display: grid; gap: 4px; }
|
|
107
|
+
.ol-dialog label { font-weight: 500; }
|
|
108
|
+
.ol-dialog .ol-hint { font-weight: 400; font-size: .9em; opacity: .8; }
|
|
42
109
|
.ol-dialog input[type="text"], .ol-dialog input[type="url"], .ol-dialog input[type="file"],
|
|
43
110
|
.ol-dialog input[type="color"], .ol-dialog input[type="number"], .ol-dialog select {
|
|
44
111
|
box-sizing: border-box; width: 100%; padding: 6px 8px;
|
|
45
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
46
|
-
border-radius: var(--openleaf-radius, 4px);
|
|
47
|
-
background: var(--openleaf-color-surface, #fff);
|
|
112
|
+
border: 1px solid var(--ol-border-strong, var(--openleaf-color-border-strong, #6e7781));
|
|
113
|
+
border-radius: var(--ol-radius, var(--openleaf-radius, 4px));
|
|
114
|
+
background: var(--ol-surface, var(--openleaf-color-surface, #fff));
|
|
48
115
|
color: inherit; font: inherit;
|
|
49
116
|
}
|
|
50
117
|
.ol-dialog input[type="color"] { height: 2.25rem; padding: 2px; }
|
|
@@ -53,44 +120,41 @@ const DIALOG_CSS = `
|
|
|
53
120
|
.ol-dialog .ol-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
54
121
|
.ol-dialog button {
|
|
55
122
|
box-sizing: border-box; padding: 6px 12px; margin: 0;
|
|
56
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
57
|
-
border-radius: var(--openleaf-radius, 4px);
|
|
123
|
+
border: 1px solid var(--ol-border-strong, var(--openleaf-color-border-strong, #6e7781));
|
|
124
|
+
border-radius: var(--ol-radius, var(--openleaf-radius, 4px));
|
|
58
125
|
background: transparent; color: inherit; font: inherit; cursor: pointer;
|
|
59
126
|
appearance: none; -webkit-appearance: none;
|
|
60
127
|
}
|
|
61
128
|
.ol-dialog button[value="ok"] {
|
|
62
|
-
border-color: var(--openleaf-color-accent, #0550ae);
|
|
63
|
-
background: var(--openleaf-color-accent, #0550ae);
|
|
64
|
-
color: #fff;
|
|
129
|
+
border-color: var(--ol-accent, var(--openleaf-color-accent, #0550ae));
|
|
130
|
+
background: var(--ol-accent, var(--openleaf-color-accent, #0550ae));
|
|
131
|
+
color: var(--ol-surface, var(--openleaf-color-surface, #fff));
|
|
65
132
|
}
|
|
66
133
|
.ol-dialog button:focus-visible {
|
|
67
|
-
outline: 2px solid var(--openleaf-color-focus, #0969da);
|
|
134
|
+
outline: var(--ol-focus-width, 2px) solid var(--ol-focus, var(--openleaf-color-focus, #0969da));
|
|
135
|
+
outline-offset: 1px;
|
|
68
136
|
}
|
|
69
137
|
.ol-dialog button[aria-disabled="true"] { opacity: .55; cursor: default; }
|
|
70
|
-
.ol-dialog .ol-error { color: #cf222e; font-size: .9em; min-height: 1.2em; }
|
|
138
|
+
.ol-dialog .ol-error { color: var(--ol-danger, #cf222e); font-size: .9em; min-height: 1.2em; }
|
|
71
139
|
.ol-dialog .ol-progress { font-size: .9em; opacity: .8; min-height: 1.2em; }
|
|
72
140
|
`;
|
|
73
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Install the dialog sheet.
|
|
143
|
+
*
|
|
144
|
+
* Delegates to `registerStyles` rather than repeating the constructable-sheet
|
|
145
|
+
* dance, which also removes this file's `<style>`-element fallback. That
|
|
146
|
+
* fallback contradicted the invariant argued at the top of `styles.ts`: a
|
|
147
|
+
* `<style>` element is blocked by exactly the `style-src 'self'` policies that
|
|
148
|
+
* would need it, and it fails silently. `registerStyles` warns instead, once,
|
|
149
|
+
* naming the stylesheet to link.
|
|
150
|
+
*
|
|
151
|
+
* It also deduplicates per *document* by the CSS text, where the flag this used
|
|
152
|
+
* to keep was module-global -- so a second document (an iframe, a print view)
|
|
153
|
+
* previously got the dialog markup with none of its styles.
|
|
154
|
+
*/
|
|
74
155
|
export function ensureDialogStyles(doc) {
|
|
75
156
|
ensureStyles(doc);
|
|
76
|
-
|
|
77
|
-
return;
|
|
78
|
-
try {
|
|
79
|
-
if (typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype) {
|
|
80
|
-
const sheet = new CSSStyleSheet();
|
|
81
|
-
sheet.replaceSync(DIALOG_CSS);
|
|
82
|
-
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
83
|
-
dialogStylesReady = true;
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
/* fall through */
|
|
89
|
-
}
|
|
90
|
-
const style = doc.createElement('style');
|
|
91
|
-
style.textContent = DIALOG_CSS;
|
|
92
|
-
doc.head.appendChild(style);
|
|
93
|
-
dialogStylesReady = true;
|
|
157
|
+
registerStyles(DIALOG_CSS, doc);
|
|
94
158
|
}
|
|
95
159
|
/**
|
|
96
160
|
* Build and show a modal form, resolving to whatever `commit` produced or null
|
|
@@ -102,6 +166,10 @@ export function ensureDialogStyles(doc) {
|
|
|
102
166
|
* an implementation detail for it is not good enough.
|
|
103
167
|
*/
|
|
104
168
|
function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'This form has nothing to do.' })) {
|
|
169
|
+
const locale = options.locale ?? null;
|
|
170
|
+
return withLocale(locale, () => buildForm(doc, locale, title, fields, options, commit));
|
|
171
|
+
}
|
|
172
|
+
function buildForm(doc, locale, title, fields, options, commit) {
|
|
105
173
|
ensureDialogStyles(doc);
|
|
106
174
|
const previouslyFocused = doc.activeElement;
|
|
107
175
|
const dialog = doc.createElement('dialog');
|
|
@@ -109,28 +177,44 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
109
177
|
const form = doc.createElement('form');
|
|
110
178
|
form.method = 'dialog';
|
|
111
179
|
const heading = doc.createElement('h2');
|
|
112
|
-
heading.textContent = title;
|
|
113
|
-
|
|
180
|
+
heading.textContent = t(title);
|
|
181
|
+
// A counter, not a hash of the title. Two dialogs with the same title -- two
|
|
182
|
+
// editors on one page, or a prompt reopened -- produced the same id, and
|
|
183
|
+
// `aria-labelledby` then resolved to whichever came first in the document.
|
|
184
|
+
const headingId = nextDialogId('t');
|
|
114
185
|
heading.id = headingId;
|
|
115
186
|
dialog.setAttribute('aria-labelledby', headingId);
|
|
116
187
|
form.appendChild(heading);
|
|
117
188
|
if (options.note) {
|
|
118
189
|
const note = doc.createElement('div');
|
|
119
190
|
note.className = 'ol-hint';
|
|
120
|
-
note.textContent = options.note;
|
|
191
|
+
note.textContent = t(options.note);
|
|
121
192
|
form.appendChild(note);
|
|
122
193
|
}
|
|
123
194
|
const inputs = new Map();
|
|
195
|
+
/** Each control's own hint id, so the error can be added without losing it. */
|
|
196
|
+
const described = new Map();
|
|
124
197
|
for (const field of fields) {
|
|
198
|
+
const wrap = doc.createElement('div');
|
|
199
|
+
wrap.className = 'ol-field';
|
|
200
|
+
const controlId = nextDialogId('c');
|
|
125
201
|
const label = doc.createElement('label');
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
202
|
+
label.htmlFor = controlId;
|
|
203
|
+
label.textContent = t(field.label);
|
|
204
|
+
wrap.appendChild(label);
|
|
205
|
+
const describedBy = [];
|
|
129
206
|
if (field.hint) {
|
|
207
|
+
// Outside the <label>, and referenced instead of contained. As a child of
|
|
208
|
+
// the label it folded into the accessible NAME, so the address field was
|
|
209
|
+
// called "Address For example https://example.org, /about, or
|
|
210
|
+
// mailto:someone@example.org" -- which is not a name anybody can use.
|
|
211
|
+
const hintId = nextDialogId('h');
|
|
130
212
|
const hint = doc.createElement('span');
|
|
213
|
+
hint.id = hintId;
|
|
131
214
|
hint.className = 'ol-hint';
|
|
132
|
-
hint.textContent = field.hint;
|
|
133
|
-
|
|
215
|
+
hint.textContent = t(field.hint);
|
|
216
|
+
wrap.appendChild(hint);
|
|
217
|
+
describedBy.push(hintId);
|
|
134
218
|
}
|
|
135
219
|
let control;
|
|
136
220
|
if (field.options) {
|
|
@@ -139,7 +223,7 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
139
223
|
for (const option of field.options) {
|
|
140
224
|
const item = doc.createElement('option');
|
|
141
225
|
item.value = option.value;
|
|
142
|
-
item.textContent = option.label;
|
|
226
|
+
item.textContent = t(option.label);
|
|
143
227
|
if (option.value === (field.value ?? ''))
|
|
144
228
|
item.selected = true;
|
|
145
229
|
select.appendChild(item);
|
|
@@ -159,13 +243,20 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
159
243
|
}
|
|
160
244
|
control = input;
|
|
161
245
|
}
|
|
246
|
+
control.id = controlId;
|
|
162
247
|
// `required` is deliberately not set on the element. The browser's own
|
|
163
248
|
// validation bubble cannot be read by a screen reader in every engine and
|
|
164
249
|
// cannot express "one of these two fields"; the commit step reports into a
|
|
165
|
-
// live region instead.
|
|
166
|
-
|
|
250
|
+
// live region instead. `aria-required` still has to say so, though -- the
|
|
251
|
+
// omission left the field announcing nothing about being mandatory.
|
|
252
|
+
if (field.required === true)
|
|
253
|
+
control.setAttribute('aria-required', 'true');
|
|
254
|
+
if (describedBy.length > 0)
|
|
255
|
+
control.setAttribute('aria-describedby', describedBy.join(' '));
|
|
256
|
+
described.set(field.name, describedBy);
|
|
257
|
+
wrap.appendChild(control);
|
|
167
258
|
inputs.set(field.name, control);
|
|
168
|
-
form.appendChild(
|
|
259
|
+
form.appendChild(wrap);
|
|
169
260
|
}
|
|
170
261
|
// Wired after the loop, so a chooser can fill a field declared after it.
|
|
171
262
|
for (const field of fields) {
|
|
@@ -183,9 +274,11 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
183
274
|
if (options.browse) {
|
|
184
275
|
const browse = doc.createElement('button');
|
|
185
276
|
browse.type = 'button';
|
|
186
|
-
browse.textContent = options.browse.label;
|
|
277
|
+
browse.textContent = t(options.browse.label);
|
|
187
278
|
browse.addEventListener('click', () => {
|
|
188
|
-
void options.browse
|
|
279
|
+
void options.browse
|
|
280
|
+
?.fill()
|
|
281
|
+
.then((filled) => {
|
|
189
282
|
if (!filled)
|
|
190
283
|
return;
|
|
191
284
|
for (const [name, value] of Object.entries(filled)) {
|
|
@@ -197,6 +290,12 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
197
290
|
control.value = value;
|
|
198
291
|
}
|
|
199
292
|
}
|
|
293
|
+
})
|
|
294
|
+
// A picker is integrator code, so it can reject -- and it does when it
|
|
295
|
+
// hands back an address the editor will not store. Without this the
|
|
296
|
+
// failure was an unhandled rejection and the author saw nothing happen.
|
|
297
|
+
.catch((thrown) => {
|
|
298
|
+
error.textContent = messageFrom(thrown);
|
|
200
299
|
});
|
|
201
300
|
});
|
|
202
301
|
form.appendChild(browse);
|
|
@@ -211,14 +310,19 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
211
310
|
checkbox.checked = options.extraCheckbox.checked === true;
|
|
212
311
|
wrap.appendChild(checkbox);
|
|
213
312
|
const span = doc.createElement('span');
|
|
214
|
-
span.textContent = options.extraCheckbox.label;
|
|
313
|
+
span.textContent = t(options.extraCheckbox.label);
|
|
215
314
|
wrap.appendChild(span);
|
|
216
315
|
form.appendChild(wrap);
|
|
217
316
|
if (options.extraCheckbox.hint) {
|
|
317
|
+
const hintId = nextDialogId('h');
|
|
218
318
|
const hint = doc.createElement('div');
|
|
319
|
+
hint.id = hintId;
|
|
219
320
|
hint.className = 'ol-hint';
|
|
220
|
-
hint.textContent = options.extraCheckbox.hint;
|
|
321
|
+
hint.textContent = t(options.extraCheckbox.hint);
|
|
221
322
|
form.appendChild(hint);
|
|
323
|
+
// The new-window warning is the reason this hint exists; a checkbox that
|
|
324
|
+
// does not point at it is a checkbox whose warning is never read.
|
|
325
|
+
checkbox.setAttribute('aria-describedby', hintId);
|
|
222
326
|
}
|
|
223
327
|
}
|
|
224
328
|
// Progress and failure are separate regions with different urgency. An upload
|
|
@@ -230,22 +334,28 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
230
334
|
progress.setAttribute('aria-live', 'polite');
|
|
231
335
|
form.appendChild(progress);
|
|
232
336
|
const error = doc.createElement('div');
|
|
337
|
+
const errorId = nextDialogId('e');
|
|
338
|
+
error.id = errorId;
|
|
233
339
|
error.className = 'ol-error';
|
|
234
340
|
error.setAttribute('role', 'alert');
|
|
235
341
|
form.appendChild(error);
|
|
342
|
+
// Translated once, in scope. `setBusy` runs after an await, long outside any
|
|
343
|
+
// synchronous locale scope, so reading these later would give English.
|
|
344
|
+
const saveLabel = t('Save');
|
|
345
|
+
const busyLabel = t(options.busyLabel ?? 'Working…');
|
|
236
346
|
const actions = doc.createElement('div');
|
|
237
347
|
actions.className = 'ol-actions';
|
|
238
348
|
const cancel = doc.createElement('button');
|
|
239
349
|
cancel.type = 'button';
|
|
240
|
-
cancel.textContent = 'Cancel';
|
|
350
|
+
cancel.textContent = t('Cancel');
|
|
241
351
|
const ok = doc.createElement('button');
|
|
242
352
|
ok.type = 'submit';
|
|
243
353
|
ok.value = 'ok';
|
|
244
|
-
ok.textContent =
|
|
354
|
+
ok.textContent = saveLabel;
|
|
245
355
|
actions.append(cancel, ok);
|
|
246
356
|
form.appendChild(actions);
|
|
247
357
|
dialog.appendChild(form);
|
|
248
|
-
doc.
|
|
358
|
+
dialogParent(doc, options.host).appendChild(dialog);
|
|
249
359
|
return new Promise((resolve) => {
|
|
250
360
|
let busy = false;
|
|
251
361
|
const finish = (result) => {
|
|
@@ -263,8 +373,29 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
263
373
|
// screen reader user loses track of where they are mid-upload.
|
|
264
374
|
button.setAttribute('aria-disabled', value ? 'true' : 'false');
|
|
265
375
|
}
|
|
266
|
-
ok.textContent = value ?
|
|
267
|
-
progress.textContent = value ?
|
|
376
|
+
ok.textContent = value ? busyLabel : saveLabel;
|
|
377
|
+
progress.textContent = value ? busyLabel : '';
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Show a failure, and put a screen reader user on the field it is about.
|
|
381
|
+
*
|
|
382
|
+
* `role="alert"` announces the text; `aria-invalid` plus the description is
|
|
383
|
+
* what makes the field itself say what is wrong when they arrive on it.
|
|
384
|
+
*/
|
|
385
|
+
const showError = (failure) => {
|
|
386
|
+
error.textContent = failure.error;
|
|
387
|
+
for (const [name, control] of inputs) {
|
|
388
|
+
const own = described.get(name) ?? [];
|
|
389
|
+
const invalid = failure.field !== undefined && failure.field === name;
|
|
390
|
+
control.setAttribute('aria-invalid', invalid ? 'true' : 'false');
|
|
391
|
+
const ids = invalid ? [...own, errorId] : own;
|
|
392
|
+
if (ids.length > 0)
|
|
393
|
+
control.setAttribute('aria-describedby', ids.join(' '));
|
|
394
|
+
else
|
|
395
|
+
control.removeAttribute('aria-describedby');
|
|
396
|
+
}
|
|
397
|
+
const target = failure.field ? inputs.get(failure.field) : undefined;
|
|
398
|
+
(target ?? firstControl())?.focus();
|
|
268
399
|
};
|
|
269
400
|
cancel.addEventListener('click', () => {
|
|
270
401
|
// Cancelling mid-upload abandons the result rather than the request: there
|
|
@@ -280,6 +411,13 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
280
411
|
});
|
|
281
412
|
form.addEventListener('submit', (event) => {
|
|
282
413
|
event.preventDefault();
|
|
414
|
+
// And stop it propagating, which `preventDefault` alone does not. Mounted
|
|
415
|
+
// inside the editor the dialog is usually inside the page's own <form>,
|
|
416
|
+
// and `submit` bubbles: without this, saving a link would reach the host
|
|
417
|
+
// page's submit listeners. In this repo that is the session plugin's,
|
|
418
|
+
// which treats a submit as "the document has been saved" and deletes the
|
|
419
|
+
// autosave draft -- from a dialog that saved nothing to the server.
|
|
420
|
+
event.stopPropagation();
|
|
283
421
|
if (busy)
|
|
284
422
|
return;
|
|
285
423
|
const values = {};
|
|
@@ -295,16 +433,17 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
295
433
|
error.textContent = '';
|
|
296
434
|
let outcome;
|
|
297
435
|
try {
|
|
298
|
-
|
|
436
|
+
// In scope: the messages a commit produces are the ones a screen reader
|
|
437
|
+
// reads, so they are translated in the editor's language, not the page's.
|
|
438
|
+
outcome = withLocale(locale, () => commit(values, files));
|
|
299
439
|
}
|
|
300
440
|
catch (thrown) {
|
|
301
|
-
error
|
|
441
|
+
showError({ error: messageFrom(thrown) });
|
|
302
442
|
return;
|
|
303
443
|
}
|
|
304
444
|
if (!(outcome instanceof Promise)) {
|
|
305
445
|
if ('error' in outcome) {
|
|
306
|
-
|
|
307
|
-
focusFirst();
|
|
446
|
+
showError(outcome);
|
|
308
447
|
return;
|
|
309
448
|
}
|
|
310
449
|
finish(outcome.value);
|
|
@@ -315,38 +454,56 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
315
454
|
.then((settled) => {
|
|
316
455
|
setBusy(false);
|
|
317
456
|
if ('error' in settled) {
|
|
318
|
-
|
|
319
|
-
focusFirst();
|
|
457
|
+
showError(settled);
|
|
320
458
|
return;
|
|
321
459
|
}
|
|
322
460
|
finish(settled.value);
|
|
323
461
|
})
|
|
324
462
|
.catch((thrown) => {
|
|
325
463
|
setBusy(false);
|
|
326
|
-
error
|
|
464
|
+
showError({ error: messageFrom(thrown) });
|
|
327
465
|
});
|
|
328
466
|
});
|
|
329
|
-
const
|
|
330
|
-
const first = fields[0] ? inputs.get(fields[0].name) : undefined;
|
|
331
|
-
first?.focus();
|
|
332
|
-
};
|
|
467
|
+
const firstControl = () => fields[0] ? inputs.get(fields[0].name) : undefined;
|
|
333
468
|
dialog.showModal();
|
|
334
|
-
|
|
335
|
-
|
|
469
|
+
const first = firstControl();
|
|
470
|
+
first?.focus();
|
|
336
471
|
if (first instanceof HTMLInputElement && first.type !== 'file')
|
|
337
472
|
first.select();
|
|
338
473
|
});
|
|
339
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Where a modal should be mounted.
|
|
477
|
+
*
|
|
478
|
+
* Inside the editor host, so the skin's tokens reach it -- `showModal()` puts
|
|
479
|
+
* the element in the top layer regardless of where it sits in the tree, so
|
|
480
|
+
* nesting costs nothing in stacking or clipping and buys the whole palette.
|
|
481
|
+
* `document.body` remains the fallback for a caller with no host (the unit
|
|
482
|
+
* tests, and any integrator calling `promptFields` directly).
|
|
483
|
+
*
|
|
484
|
+
* The host is verified to be in the same document, because a dialog appended
|
|
485
|
+
* across documents would be adopted out of the one whose stylesheet was just
|
|
486
|
+
* installed.
|
|
487
|
+
*/
|
|
488
|
+
function dialogParent(doc, host) {
|
|
489
|
+
return host && host.ownerDocument === doc ? host : doc.body;
|
|
490
|
+
}
|
|
340
491
|
/** A failure message worth showing an author, from whatever was thrown. */
|
|
341
492
|
function messageFrom(thrown) {
|
|
342
493
|
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
|
343
|
-
return message === '' ? 'Something went wrong.' : message;
|
|
494
|
+
return message === '' ? t('Something went wrong.') : message;
|
|
344
495
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
496
|
+
/**
|
|
497
|
+
* Unique ids for one dialog's parts.
|
|
498
|
+
*
|
|
499
|
+
* Previously a hash of the title, so two dialogs with the same title -- two
|
|
500
|
+
* editors on a page, or the same prompt reopened -- shared ids and every
|
|
501
|
+
* `aria-labelledby` resolved to whichever was first in the document.
|
|
502
|
+
*/
|
|
503
|
+
let idCounter = 0;
|
|
504
|
+
function nextDialogId(kind) {
|
|
505
|
+
idCounter += 1;
|
|
506
|
+
return `ol-dlg-${kind}${idCounter}`;
|
|
350
507
|
}
|
|
351
508
|
/**
|
|
352
509
|
* A generic modal form, used by table property dialogs and anything else that
|
|
@@ -358,6 +515,7 @@ export function promptFields(doc, title, fields, options, commit) {
|
|
|
358
515
|
export async function promptForLink(doc, existing, host) {
|
|
359
516
|
const listed = listedLinks();
|
|
360
517
|
const picker = host ? filePickerFor(host) : null;
|
|
518
|
+
const locale = host?.getAttribute('lang') ?? null;
|
|
361
519
|
const fields = [
|
|
362
520
|
...(listed.length > 0
|
|
363
521
|
? [
|
|
@@ -386,6 +544,8 @@ export async function promptForLink(doc, existing, host) {
|
|
|
386
544
|
},
|
|
387
545
|
];
|
|
388
546
|
return showForm(doc, existing?.href ? 'Edit link' : 'Insert link', fields, {
|
|
547
|
+
locale,
|
|
548
|
+
...(host ? { host } : {}),
|
|
389
549
|
extraCheckbox: {
|
|
390
550
|
name: 'newWindow',
|
|
391
551
|
label: 'Open in a new window',
|
|
@@ -401,6 +561,9 @@ export async function promptForLink(doc, existing, host) {
|
|
|
401
561
|
const picked = await picker({ kind: 'file', host });
|
|
402
562
|
if (!picked)
|
|
403
563
|
return null;
|
|
564
|
+
if (!isSafeUrl(picked.url)) {
|
|
565
|
+
throw new Error('The file picker returned an address the editor will not store.');
|
|
566
|
+
}
|
|
404
567
|
return { href: picked.url, title: picked.title ?? '' };
|
|
405
568
|
},
|
|
406
569
|
},
|
|
@@ -411,14 +574,20 @@ export async function promptForLink(doc, existing, host) {
|
|
|
411
574
|
// is no second place a destination can hide.
|
|
412
575
|
const href = values['href'] || '';
|
|
413
576
|
if (!href)
|
|
414
|
-
return { error: 'Enter an address for the link.' };
|
|
577
|
+
return { error: t('Enter an address for the link.'), field: 'href' };
|
|
578
|
+
// `setLink` declines this too, but a command that declines closes the
|
|
579
|
+
// dialog and does nothing visible. Reporting here keeps the dialog open
|
|
580
|
+
// with the address still in the field, so the author can see and fix it.
|
|
581
|
+
if (!isSafeUrl(href))
|
|
582
|
+
return { error: t(UNSTORABLE_ADDRESS), field: 'href' };
|
|
415
583
|
const newWindow = values['newWindow'] === 'on';
|
|
416
584
|
return {
|
|
417
585
|
value: {
|
|
418
586
|
href,
|
|
419
587
|
title: values['title'] || null,
|
|
420
588
|
target: newWindow ? '_blank' : null,
|
|
421
|
-
rel: newWindow
|
|
589
|
+
rel: mergeLinkRel(existing?.rel, newWindow),
|
|
590
|
+
id: existing?.id ?? null,
|
|
422
591
|
},
|
|
423
592
|
};
|
|
424
593
|
});
|
|
@@ -428,6 +597,11 @@ export async function promptForImage(doc, options = {}) {
|
|
|
428
597
|
const listed = listedImages();
|
|
429
598
|
const classes = listedImageClasses();
|
|
430
599
|
const picker = host ? filePickerFor(host) : null;
|
|
600
|
+
const locale = host?.getAttribute('lang') ?? null;
|
|
601
|
+
// Interpolated strings are translated here rather than in the form builder,
|
|
602
|
+
// because a template plus a value is one string the catalog has to own -- and
|
|
603
|
+
// this is the only place that has the value.
|
|
604
|
+
const inLocale = (source, values = {}) => withLocale(locale, () => fill(t(source), values));
|
|
431
605
|
const describe = {
|
|
432
606
|
name: 'alt',
|
|
433
607
|
label: 'Alternative text',
|
|
@@ -459,7 +633,9 @@ export async function promptForImage(doc, options = {}) {
|
|
|
459
633
|
label: 'CSS classes',
|
|
460
634
|
type: 'text',
|
|
461
635
|
value: existing?.className ?? '',
|
|
462
|
-
hint: classes.length > 0
|
|
636
|
+
hint: classes.length > 0
|
|
637
|
+
? inLocale('Suggested: {classes}', { classes: classes.join(', ') })
|
|
638
|
+
: 'Optional class names, separated by spaces.',
|
|
463
639
|
},
|
|
464
640
|
{
|
|
465
641
|
name: 'caption',
|
|
@@ -515,12 +691,15 @@ export async function promptForImage(doc, options = {}) {
|
|
|
515
691
|
: null,
|
|
516
692
|
caption: values['caption'] ? values['caption'] : null,
|
|
517
693
|
});
|
|
518
|
-
return showForm(doc, file ? 'Describe this image' : 'Insert image', fields, {
|
|
694
|
+
return showForm(doc, file ? 'Describe this image' : existing ? 'Edit image' : 'Insert image', fields, {
|
|
695
|
+
locale,
|
|
696
|
+
...(host ? { host } : {}),
|
|
519
697
|
extraCheckbox: {
|
|
520
698
|
name: 'decorative',
|
|
521
699
|
label: 'This image is decorative and needs no description',
|
|
700
|
+
checked: existing?.alt === '',
|
|
522
701
|
},
|
|
523
|
-
...(file ? { note:
|
|
702
|
+
...(file ? { note: inLocale('Ready to upload: {file}', { file: file.name }) } : {}),
|
|
524
703
|
busyLabel: 'Uploading…',
|
|
525
704
|
...(picker && host
|
|
526
705
|
? {
|
|
@@ -530,6 +709,9 @@ export async function promptForImage(doc, options = {}) {
|
|
|
530
709
|
const picked = await picker({ kind: 'image', host });
|
|
531
710
|
if (!picked)
|
|
532
711
|
return null;
|
|
712
|
+
if (!isSafeUrl(picked.url)) {
|
|
713
|
+
throw new Error('The image picker returned an address the editor will not store.');
|
|
714
|
+
}
|
|
533
715
|
return { src: picked.url, alt: picked.alt ?? '', title: picked.title ?? '' };
|
|
534
716
|
},
|
|
535
717
|
},
|
|
@@ -539,18 +721,164 @@ export async function promptForImage(doc, options = {}) {
|
|
|
539
721
|
const chosen = file ?? files['file'];
|
|
540
722
|
const src = values['src'] || '';
|
|
541
723
|
if (!chosen && !src) {
|
|
542
|
-
return {
|
|
724
|
+
return {
|
|
725
|
+
error: upload
|
|
726
|
+
? t('Choose a file or enter an image address.')
|
|
727
|
+
: t('Enter an image address.'),
|
|
728
|
+
field: upload && files['file'] !== undefined ? 'file' : 'src',
|
|
729
|
+
};
|
|
543
730
|
}
|
|
731
|
+
// Only a typed or picked address needs checking here; a file goes through
|
|
732
|
+
// `runUploader`, which already refuses what the editor will not store.
|
|
733
|
+
if (src !== '' && !isSafeUrl(src))
|
|
734
|
+
return { error: UNSTORABLE_ADDRESS };
|
|
544
735
|
if (!values['alt'] && values['decorative'] !== 'on') {
|
|
545
|
-
return { error: 'Add alternative text, or tick the decorative box.' };
|
|
736
|
+
return { error: t('Add alternative text, or tick the decorative box.'), field: 'alt' };
|
|
546
737
|
}
|
|
547
738
|
const alt = values['decorative'] === 'on' ? '' : (values['alt'] ?? '');
|
|
548
739
|
if (!chosen || !upload) {
|
|
549
|
-
|
|
740
|
+
// Keep dimensions the dialog does not expose. Uploading a new file
|
|
741
|
+
// replaces them with what the uploader measured; a typed-address save
|
|
742
|
+
// must not zero them out or a prefilled edit drops width and height.
|
|
743
|
+
return {
|
|
744
|
+
value: finish(src, alt, existing?.width ?? null, existing?.height ?? null, values),
|
|
745
|
+
};
|
|
550
746
|
}
|
|
551
747
|
return upload(chosen).then((result) => ({
|
|
552
748
|
value: finish(result.src, alt, dimension(result.width), dimension(result.height), values),
|
|
553
749
|
}));
|
|
554
750
|
});
|
|
555
751
|
}
|
|
752
|
+
/** How many empty `<source>` rows the dialog offers for adding new encodings. */
|
|
753
|
+
const SPARE_SOURCE_ROWS = 2;
|
|
754
|
+
/**
|
|
755
|
+
* The insert-media dialog.
|
|
756
|
+
*
|
|
757
|
+
* Two spare alternative-source rows, because that is the shape the format is
|
|
758
|
+
* actually used in: a `<video>` with a `.webm` and an `.mp4` covers every
|
|
759
|
+
* current browser, and offering a single alternative makes the common case the
|
|
760
|
+
* one the author has to work around. Not a growable list with add and remove
|
|
761
|
+
* controls, which would owe keyboard semantics and a reordering story.
|
|
762
|
+
*
|
|
763
|
+
* But the rows are spare capacity *on top of* what the player already has, never
|
|
764
|
+
* a cap on it. A fixed two rows silently deleted the third and later encodings
|
|
765
|
+
* of any player that had them, on a save the author made to change the title --
|
|
766
|
+
* because what this form returns is what replaces the stored sources. Every
|
|
767
|
+
* existing source gets a row, so the form cannot lose one it was never shown.
|
|
768
|
+
*
|
|
769
|
+
* The type hints are separate fields rather than guessed from the extension. A
|
|
770
|
+
* guess is wrong for exactly the addresses that need it -- a signed URL, or a
|
|
771
|
+
* stream with no extension at all -- and being wrong here means the browser
|
|
772
|
+
* downloads a format it cannot play before finding out.
|
|
773
|
+
*/
|
|
774
|
+
export async function promptForMedia(doc, options = {}) {
|
|
775
|
+
const { host, existing } = options;
|
|
776
|
+
const locale = host?.getAttribute('lang') ?? null;
|
|
777
|
+
const existingSources = existing?.sources ?? [];
|
|
778
|
+
const editing = existing !== undefined;
|
|
779
|
+
const fields = [
|
|
780
|
+
{
|
|
781
|
+
name: 'src',
|
|
782
|
+
label: 'Address',
|
|
783
|
+
type: 'text',
|
|
784
|
+
value: existing?.src ?? '',
|
|
785
|
+
hint: 'The video or audio file to play. Leave blank if you only give alternatives below.',
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: 'title',
|
|
789
|
+
label: 'Title',
|
|
790
|
+
type: 'text',
|
|
791
|
+
value: existing?.title ?? '',
|
|
792
|
+
hint: 'Shown as a tooltip. Optional.',
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: 'poster',
|
|
796
|
+
label: 'Poster image',
|
|
797
|
+
type: 'text',
|
|
798
|
+
value: existing?.poster ?? '',
|
|
799
|
+
hint: 'Shown before the video plays. Ignored for audio.',
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: 'width',
|
|
803
|
+
label: 'Width',
|
|
804
|
+
type: 'text',
|
|
805
|
+
value: existing?.width ?? '',
|
|
806
|
+
hint: 'Pixels, or a percentage. Leave blank to use the file’s own size.',
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: 'height',
|
|
810
|
+
label: 'Height',
|
|
811
|
+
type: 'text',
|
|
812
|
+
value: existing?.height ?? '',
|
|
813
|
+
},
|
|
814
|
+
];
|
|
815
|
+
const sourceRows = existingSources.length + SPARE_SOURCE_ROWS;
|
|
816
|
+
for (let index = 0; index < sourceRows; index += 1) {
|
|
817
|
+
const source = existingSources[index];
|
|
818
|
+
fields.push({
|
|
819
|
+
name: `alt${index}`,
|
|
820
|
+
label: index === 0 ? 'Alternative address' : `Alternative address ${index + 1}`,
|
|
821
|
+
type: 'text',
|
|
822
|
+
value: source?.src ?? '',
|
|
823
|
+
...(index === 0
|
|
824
|
+
? { hint: 'Another encoding of the same media, for browsers that cannot play the first.' }
|
|
825
|
+
: {}),
|
|
826
|
+
}, {
|
|
827
|
+
name: `altType${index}`,
|
|
828
|
+
label: index === 0 ? 'Alternative type' : `Alternative type ${index + 1}`,
|
|
829
|
+
type: 'text',
|
|
830
|
+
value: source?.type ?? '',
|
|
831
|
+
...(index === 0 ? { hint: 'For example video/webm. Optional, but saves a wasted download.' } : {}),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return showForm(doc, editing ? 'Edit media' : 'Insert media', fields, {
|
|
835
|
+
locale,
|
|
836
|
+
...(host ? { host } : {}),
|
|
837
|
+
}, (values) => {
|
|
838
|
+
const src = (values['src'] ?? '').trim();
|
|
839
|
+
const sources = [];
|
|
840
|
+
for (let index = 0; index < sourceRows; index += 1) {
|
|
841
|
+
const address = (values[`alt${index}`] ?? '').trim();
|
|
842
|
+
if (address === '')
|
|
843
|
+
continue;
|
|
844
|
+
if (!isSafeUrl(address)) {
|
|
845
|
+
return { error: t(UNSTORABLE_ADDRESS), field: `alt${index}` };
|
|
846
|
+
}
|
|
847
|
+
const type = (values[`altType${index}`] ?? '').trim();
|
|
848
|
+
sources.push({ src: address, type: type === '' ? null : type });
|
|
849
|
+
}
|
|
850
|
+
// One or the other, which is the schema's own rule: a player with neither
|
|
851
|
+
// an address nor a source has nothing to play, and core declines it. Said
|
|
852
|
+
// here so the author reads it in the dialog instead of watching the
|
|
853
|
+
// insertion silently do nothing.
|
|
854
|
+
if (src === '' && sources.length === 0) {
|
|
855
|
+
return { error: t('Enter an address, or at least one alternative.'), field: 'src' };
|
|
856
|
+
}
|
|
857
|
+
if (src !== '' && !isSafeUrl(src))
|
|
858
|
+
return { error: t(UNSTORABLE_ADDRESS), field: 'src' };
|
|
859
|
+
// An embed is held to the allowlist here rather than after the dialog has
|
|
860
|
+
// gone. `embedSrcFor` accepts a watch page and converts it, so this
|
|
861
|
+
// rejects only what genuinely cannot be embedded.
|
|
862
|
+
if (existing?.kind === 'iframe' && embedSrcFor(src) === null) {
|
|
863
|
+
return {
|
|
864
|
+
error: t('That is not an address this editor can embed.'),
|
|
865
|
+
field: 'src',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const poster = (values['poster'] ?? '').trim();
|
|
869
|
+
if (poster !== '' && !isSafeUrl(poster)) {
|
|
870
|
+
return { error: t(UNSTORABLE_ADDRESS), field: 'poster' };
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
value: {
|
|
874
|
+
src,
|
|
875
|
+
title: values['title'] ? values['title'] : null,
|
|
876
|
+
poster: poster === '' ? null : poster,
|
|
877
|
+
width: values['width'] ? values['width'] : null,
|
|
878
|
+
height: values['height'] ? values['height'] : null,
|
|
879
|
+
sources,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
});
|
|
883
|
+
}
|
|
556
884
|
//# sourceMappingURL=dialog.js.map
|