@openleaf-editor/ui 0.1.0-beta.1 → 0.1.0-beta.3
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 +31 -0
- package/dist/content-css.d.ts.map +1 -0
- package/dist/content-css.js +272 -0
- package/dist/content-css.js.map +1 -0
- package/dist/css.d.ts +23 -0
- package/dist/css.d.ts.map +1 -0
- package/dist/css.js +813 -0
- package/dist/css.js.map +1 -0
- package/dist/dialog.d.ts +158 -18
- package/dist/dialog.d.ts.map +1 -1
- package/dist/dialog.js +605 -130
- package/dist/dialog.js.map +1 -1
- package/dist/floating.d.ts +29 -0
- package/dist/floating.d.ts.map +1 -0
- package/dist/floating.js +130 -0
- package/dist/floating.js.map +1 -0
- package/dist/help.d.ts +9 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +97 -0
- package/dist/help.js.map +1 -0
- package/dist/i18n.d.ts +39 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +114 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +7 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +10 -2
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +2 -1
- package/dist/items.d.ts.map +1 -1
- package/dist/items.js +263 -17
- 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 +85 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +500 -0
- package/dist/menu.js.map +1 -0
- package/dist/openleaf.css +325 -7
- package/dist/overflow.d.ts +44 -0
- package/dist/overflow.d.ts.map +1 -0
- package/dist/overflow.js +323 -0
- package/dist/overflow.js.map +1 -0
- package/dist/pickers.d.ts +43 -0
- package/dist/pickers.d.ts.map +1 -0
- package/dist/pickers.js +52 -0
- package/dist/pickers.js.map +1 -0
- 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 -421
- 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 +91 -12
- package/dist/toolbar.d.ts.map +1 -1
- package/dist/toolbar.js +459 -127
- package/dist/toolbar.js.map +1 -1
- package/dist/upload.d.ts +11 -0
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +15 -1
- package/dist/upload.js.map +1 -1
- package/package.json +17 -3
package/dist/dialog.js
CHANGED
|
@@ -19,75 +19,142 @@
|
|
|
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';
|
|
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
|
+
*/
|
|
24
89
|
const DIALOG_CSS = `
|
|
25
90
|
.ol-dialog {
|
|
26
91
|
box-sizing: border-box;
|
|
27
92
|
max-width: min(28rem, calc(100vw - 2rem));
|
|
28
93
|
padding: 0;
|
|
29
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
30
|
-
border-radius: var(--openleaf-radius, 6px);
|
|
31
|
-
background: var(--openleaf-color-surface, #fff);
|
|
32
|
-
color: var(--openleaf-color-text, #1f2328);
|
|
33
|
-
font-family: var(--openleaf-font, system-ui, -apple-system, sans-serif);
|
|
34
|
-
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));
|
|
35
100
|
}
|
|
36
101
|
.ol-dialog::backdrop { background: rgb(0 0 0 / 40%); }
|
|
37
102
|
.ol-dialog form { display: grid; gap: 12px; padding: 16px; margin: 0; }
|
|
38
103
|
.ol-dialog h2 { margin: 0; font-size: 1.1em; }
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.ol-dialog
|
|
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; }
|
|
109
|
+
.ol-dialog input[type="text"], .ol-dialog input[type="url"], .ol-dialog input[type="file"],
|
|
110
|
+
.ol-dialog input[type="color"], .ol-dialog input[type="number"], .ol-dialog select {
|
|
42
111
|
box-sizing: border-box; width: 100%; padding: 6px 8px;
|
|
43
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
44
|
-
border-radius: var(--openleaf-radius, 4px);
|
|
45
|
-
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));
|
|
46
115
|
color: inherit; font: inherit;
|
|
47
116
|
}
|
|
117
|
+
.ol-dialog input[type="color"] { height: 2.25rem; padding: 2px; }
|
|
48
118
|
.ol-dialog .ol-check { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
|
49
119
|
.ol-dialog .ol-check input { margin: 0; }
|
|
50
120
|
.ol-dialog .ol-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
51
121
|
.ol-dialog button {
|
|
52
122
|
box-sizing: border-box; padding: 6px 12px; margin: 0;
|
|
53
|
-
border: 1px solid var(--openleaf-color-border, #
|
|
54
|
-
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));
|
|
55
125
|
background: transparent; color: inherit; font: inherit; cursor: pointer;
|
|
56
126
|
appearance: none; -webkit-appearance: none;
|
|
57
127
|
}
|
|
58
128
|
.ol-dialog button[value="ok"] {
|
|
59
|
-
border-color: var(--openleaf-color-accent, #0550ae);
|
|
60
|
-
background: var(--openleaf-color-accent, #0550ae);
|
|
61
|
-
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));
|
|
62
132
|
}
|
|
63
133
|
.ol-dialog button:focus-visible {
|
|
64
|
-
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;
|
|
65
136
|
}
|
|
66
137
|
.ol-dialog button[aria-disabled="true"] { opacity: .55; cursor: default; }
|
|
67
|
-
.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; }
|
|
68
139
|
.ol-dialog .ol-progress { font-size: .9em; opacity: .8; min-height: 1.2em; }
|
|
69
140
|
`;
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
*/
|
|
155
|
+
export function ensureDialogStyles(doc) {
|
|
72
156
|
ensureStyles(doc);
|
|
73
|
-
|
|
74
|
-
return;
|
|
75
|
-
try {
|
|
76
|
-
if (typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype) {
|
|
77
|
-
const sheet = new CSSStyleSheet();
|
|
78
|
-
sheet.replaceSync(DIALOG_CSS);
|
|
79
|
-
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
80
|
-
dialogStylesReady = true;
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
/* fall through */
|
|
86
|
-
}
|
|
87
|
-
const style = doc.createElement('style');
|
|
88
|
-
style.textContent = DIALOG_CSS;
|
|
89
|
-
doc.head.appendChild(style);
|
|
90
|
-
dialogStylesReady = true;
|
|
157
|
+
registerStyles(DIALOG_CSS, doc);
|
|
91
158
|
}
|
|
92
159
|
/**
|
|
93
160
|
* Build and show a modal form, resolving to whatever `commit` produced or null
|
|
@@ -99,6 +166,10 @@ function ensureDialogStyles(doc) {
|
|
|
99
166
|
* an implementation detail for it is not good enough.
|
|
100
167
|
*/
|
|
101
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) {
|
|
102
173
|
ensureDialogStyles(doc);
|
|
103
174
|
const previouslyFocused = doc.activeElement;
|
|
104
175
|
const dialog = doc.createElement('dialog');
|
|
@@ -106,46 +177,128 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
106
177
|
const form = doc.createElement('form');
|
|
107
178
|
form.method = 'dialog';
|
|
108
179
|
const heading = doc.createElement('h2');
|
|
109
|
-
heading.textContent = title;
|
|
110
|
-
|
|
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');
|
|
111
185
|
heading.id = headingId;
|
|
112
186
|
dialog.setAttribute('aria-labelledby', headingId);
|
|
113
187
|
form.appendChild(heading);
|
|
114
188
|
if (options.note) {
|
|
115
189
|
const note = doc.createElement('div');
|
|
116
190
|
note.className = 'ol-hint';
|
|
117
|
-
note.textContent = options.note;
|
|
191
|
+
note.textContent = t(options.note);
|
|
118
192
|
form.appendChild(note);
|
|
119
193
|
}
|
|
120
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();
|
|
121
197
|
for (const field of fields) {
|
|
198
|
+
const wrap = doc.createElement('div');
|
|
199
|
+
wrap.className = 'ol-field';
|
|
200
|
+
const controlId = nextDialogId('c');
|
|
122
201
|
const label = doc.createElement('label');
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
202
|
+
label.htmlFor = controlId;
|
|
203
|
+
label.textContent = t(field.label);
|
|
204
|
+
wrap.appendChild(label);
|
|
205
|
+
const describedBy = [];
|
|
126
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');
|
|
127
212
|
const hint = doc.createElement('span');
|
|
213
|
+
hint.id = hintId;
|
|
128
214
|
hint.className = 'ol-hint';
|
|
129
|
-
hint.textContent = field.hint;
|
|
130
|
-
|
|
215
|
+
hint.textContent = t(field.hint);
|
|
216
|
+
wrap.appendChild(hint);
|
|
217
|
+
describedBy.push(hintId);
|
|
131
218
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
219
|
+
let control;
|
|
220
|
+
if (field.options) {
|
|
221
|
+
const select = doc.createElement('select');
|
|
222
|
+
select.name = field.name;
|
|
223
|
+
for (const option of field.options) {
|
|
224
|
+
const item = doc.createElement('option');
|
|
225
|
+
item.value = option.value;
|
|
226
|
+
item.textContent = t(option.label);
|
|
227
|
+
if (option.value === (field.value ?? ''))
|
|
228
|
+
item.selected = true;
|
|
229
|
+
select.appendChild(item);
|
|
230
|
+
}
|
|
231
|
+
control = select;
|
|
138
232
|
}
|
|
139
233
|
else {
|
|
140
|
-
input
|
|
234
|
+
const input = doc.createElement('input');
|
|
235
|
+
input.type = field.type ?? 'text';
|
|
236
|
+
input.name = field.name;
|
|
237
|
+
if (field.type === 'file') {
|
|
238
|
+
if (field.accept)
|
|
239
|
+
input.accept = field.accept;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
input.value = field.value ?? '';
|
|
243
|
+
}
|
|
244
|
+
control = input;
|
|
141
245
|
}
|
|
246
|
+
control.id = controlId;
|
|
142
247
|
// `required` is deliberately not set on the element. The browser's own
|
|
143
248
|
// validation bubble cannot be read by a screen reader in every engine and
|
|
144
249
|
// cannot express "one of these two fields"; the commit step reports into a
|
|
145
|
-
// live region instead.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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);
|
|
258
|
+
inputs.set(field.name, control);
|
|
259
|
+
form.appendChild(wrap);
|
|
260
|
+
}
|
|
261
|
+
// Wired after the loop, so a chooser can fill a field declared after it.
|
|
262
|
+
for (const field of fields) {
|
|
263
|
+
if (!field.fills)
|
|
264
|
+
continue;
|
|
265
|
+
const source = inputs.get(field.name);
|
|
266
|
+
const target = inputs.get(field.fills);
|
|
267
|
+
if (!source || !target)
|
|
268
|
+
continue;
|
|
269
|
+
source.addEventListener('change', () => {
|
|
270
|
+
if (source.value !== '')
|
|
271
|
+
target.value = source.value;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (options.browse) {
|
|
275
|
+
const browse = doc.createElement('button');
|
|
276
|
+
browse.type = 'button';
|
|
277
|
+
browse.textContent = t(options.browse.label);
|
|
278
|
+
browse.addEventListener('click', () => {
|
|
279
|
+
void options.browse
|
|
280
|
+
?.fill()
|
|
281
|
+
.then((filled) => {
|
|
282
|
+
if (!filled)
|
|
283
|
+
return;
|
|
284
|
+
for (const [name, value] of Object.entries(filled)) {
|
|
285
|
+
const control = inputs.get(name);
|
|
286
|
+
if (control && control instanceof HTMLInputElement && control.type !== 'file') {
|
|
287
|
+
control.value = value;
|
|
288
|
+
}
|
|
289
|
+
else if (control) {
|
|
290
|
+
control.value = value;
|
|
291
|
+
}
|
|
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);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
form.appendChild(browse);
|
|
149
302
|
}
|
|
150
303
|
let checkbox = null;
|
|
151
304
|
if (options.extraCheckbox) {
|
|
@@ -157,14 +310,19 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
157
310
|
checkbox.checked = options.extraCheckbox.checked === true;
|
|
158
311
|
wrap.appendChild(checkbox);
|
|
159
312
|
const span = doc.createElement('span');
|
|
160
|
-
span.textContent = options.extraCheckbox.label;
|
|
313
|
+
span.textContent = t(options.extraCheckbox.label);
|
|
161
314
|
wrap.appendChild(span);
|
|
162
315
|
form.appendChild(wrap);
|
|
163
316
|
if (options.extraCheckbox.hint) {
|
|
317
|
+
const hintId = nextDialogId('h');
|
|
164
318
|
const hint = doc.createElement('div');
|
|
319
|
+
hint.id = hintId;
|
|
165
320
|
hint.className = 'ol-hint';
|
|
166
|
-
hint.textContent = options.extraCheckbox.hint;
|
|
321
|
+
hint.textContent = t(options.extraCheckbox.hint);
|
|
167
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);
|
|
168
326
|
}
|
|
169
327
|
}
|
|
170
328
|
// Progress and failure are separate regions with different urgency. An upload
|
|
@@ -176,22 +334,28 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
176
334
|
progress.setAttribute('aria-live', 'polite');
|
|
177
335
|
form.appendChild(progress);
|
|
178
336
|
const error = doc.createElement('div');
|
|
337
|
+
const errorId = nextDialogId('e');
|
|
338
|
+
error.id = errorId;
|
|
179
339
|
error.className = 'ol-error';
|
|
180
340
|
error.setAttribute('role', 'alert');
|
|
181
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…');
|
|
182
346
|
const actions = doc.createElement('div');
|
|
183
347
|
actions.className = 'ol-actions';
|
|
184
348
|
const cancel = doc.createElement('button');
|
|
185
349
|
cancel.type = 'button';
|
|
186
|
-
cancel.textContent = 'Cancel';
|
|
350
|
+
cancel.textContent = t('Cancel');
|
|
187
351
|
const ok = doc.createElement('button');
|
|
188
352
|
ok.type = 'submit';
|
|
189
353
|
ok.value = 'ok';
|
|
190
|
-
ok.textContent =
|
|
354
|
+
ok.textContent = saveLabel;
|
|
191
355
|
actions.append(cancel, ok);
|
|
192
356
|
form.appendChild(actions);
|
|
193
357
|
dialog.appendChild(form);
|
|
194
|
-
doc.
|
|
358
|
+
dialogParent(doc, options.host).appendChild(dialog);
|
|
195
359
|
return new Promise((resolve) => {
|
|
196
360
|
let busy = false;
|
|
197
361
|
const finish = (result) => {
|
|
@@ -209,8 +373,29 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
209
373
|
// screen reader user loses track of where they are mid-upload.
|
|
210
374
|
button.setAttribute('aria-disabled', value ? 'true' : 'false');
|
|
211
375
|
}
|
|
212
|
-
ok.textContent = value ?
|
|
213
|
-
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();
|
|
214
399
|
};
|
|
215
400
|
cancel.addEventListener('click', () => {
|
|
216
401
|
// Cancelling mid-upload abandons the result rather than the request: there
|
|
@@ -226,12 +411,19 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
226
411
|
});
|
|
227
412
|
form.addEventListener('submit', (event) => {
|
|
228
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();
|
|
229
421
|
if (busy)
|
|
230
422
|
return;
|
|
231
423
|
const values = {};
|
|
232
424
|
const files = {};
|
|
233
425
|
for (const [name, input] of inputs) {
|
|
234
|
-
if (input.type === 'file')
|
|
426
|
+
if (input instanceof HTMLInputElement && input.type === 'file')
|
|
235
427
|
files[name] = input.files?.[0];
|
|
236
428
|
else
|
|
237
429
|
values[name] = input.value.trim();
|
|
@@ -241,16 +433,17 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
241
433
|
error.textContent = '';
|
|
242
434
|
let outcome;
|
|
243
435
|
try {
|
|
244
|
-
|
|
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));
|
|
245
439
|
}
|
|
246
440
|
catch (thrown) {
|
|
247
|
-
error
|
|
441
|
+
showError({ error: messageFrom(thrown) });
|
|
248
442
|
return;
|
|
249
443
|
}
|
|
250
444
|
if (!(outcome instanceof Promise)) {
|
|
251
445
|
if ('error' in outcome) {
|
|
252
|
-
|
|
253
|
-
focusFirst();
|
|
446
|
+
showError(outcome);
|
|
254
447
|
return;
|
|
255
448
|
}
|
|
256
449
|
finish(outcome.value);
|
|
@@ -261,41 +454,79 @@ function showForm(doc, title, fields, options = {}, commit = () => ({ error: 'Th
|
|
|
261
454
|
.then((settled) => {
|
|
262
455
|
setBusy(false);
|
|
263
456
|
if ('error' in settled) {
|
|
264
|
-
|
|
265
|
-
focusFirst();
|
|
457
|
+
showError(settled);
|
|
266
458
|
return;
|
|
267
459
|
}
|
|
268
460
|
finish(settled.value);
|
|
269
461
|
})
|
|
270
462
|
.catch((thrown) => {
|
|
271
463
|
setBusy(false);
|
|
272
|
-
error
|
|
464
|
+
showError({ error: messageFrom(thrown) });
|
|
273
465
|
});
|
|
274
466
|
});
|
|
275
|
-
const
|
|
276
|
-
const first = fields[0] ? inputs.get(fields[0].name) : undefined;
|
|
277
|
-
first?.focus();
|
|
278
|
-
};
|
|
467
|
+
const firstControl = () => fields[0] ? inputs.get(fields[0].name) : undefined;
|
|
279
468
|
dialog.showModal();
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (first && first.type !== 'file')
|
|
469
|
+
const first = firstControl();
|
|
470
|
+
first?.focus();
|
|
471
|
+
if (first instanceof HTMLInputElement && first.type !== 'file')
|
|
283
472
|
first.select();
|
|
284
473
|
});
|
|
285
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
|
+
}
|
|
286
491
|
/** A failure message worth showing an author, from whatever was thrown. */
|
|
287
492
|
function messageFrom(thrown) {
|
|
288
493
|
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
|
289
|
-
return message === '' ? 'Something went wrong.' : message;
|
|
494
|
+
return message === '' ? t('Something went wrong.') : message;
|
|
495
|
+
}
|
|
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}`;
|
|
290
507
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
508
|
+
/**
|
|
509
|
+
* A generic modal form, used by table property dialogs and anything else that
|
|
510
|
+
* is a handful of labelled fields rather than a specialised prompt.
|
|
511
|
+
*/
|
|
512
|
+
export function promptFields(doc, title, fields, options, commit) {
|
|
513
|
+
return showForm(doc, title, fields, options, commit);
|
|
296
514
|
}
|
|
297
|
-
export async function promptForLink(doc, existing) {
|
|
298
|
-
|
|
515
|
+
export async function promptForLink(doc, existing, host) {
|
|
516
|
+
const listed = listedLinks();
|
|
517
|
+
const picker = host ? filePickerFor(host) : null;
|
|
518
|
+
const locale = host?.getAttribute('lang') ?? null;
|
|
519
|
+
const fields = [
|
|
520
|
+
...(listed.length > 0
|
|
521
|
+
? [
|
|
522
|
+
{
|
|
523
|
+
name: 'listed',
|
|
524
|
+
label: 'Choose a page',
|
|
525
|
+
options: [{ value: '', label: 'Type an address instead' }, ...listed.map((item) => ({ value: item.value, label: item.title }))],
|
|
526
|
+
fills: 'href',
|
|
527
|
+
},
|
|
528
|
+
]
|
|
529
|
+
: []),
|
|
299
530
|
{
|
|
300
531
|
name: 'href',
|
|
301
532
|
label: 'Address',
|
|
@@ -304,7 +535,17 @@ export async function promptForLink(doc, existing) {
|
|
|
304
535
|
required: true,
|
|
305
536
|
hint: 'For example https://example.org, /about, or mailto:someone@example.org',
|
|
306
537
|
},
|
|
307
|
-
|
|
538
|
+
{
|
|
539
|
+
name: 'title',
|
|
540
|
+
label: 'Title',
|
|
541
|
+
type: 'text',
|
|
542
|
+
value: existing?.title ?? '',
|
|
543
|
+
hint: 'Shown as a tooltip. Optional.',
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
return showForm(doc, existing?.href ? 'Edit link' : 'Insert link', fields, {
|
|
547
|
+
locale,
|
|
548
|
+
...(host ? { host } : {}),
|
|
308
549
|
extraCheckbox: {
|
|
309
550
|
name: 'newWindow',
|
|
310
551
|
label: 'Open in a new window',
|
|
@@ -312,44 +553,111 @@ export async function promptForLink(doc, existing) {
|
|
|
312
553
|
hint: 'Opening in a new window without warning can disorient people using ' +
|
|
313
554
|
'screen readers or magnification. Leave this off unless you have a reason.',
|
|
314
555
|
},
|
|
556
|
+
...(picker && host
|
|
557
|
+
? {
|
|
558
|
+
browse: {
|
|
559
|
+
label: 'Browse files',
|
|
560
|
+
fill: async () => {
|
|
561
|
+
const picked = await picker({ kind: 'file', host });
|
|
562
|
+
if (!picked)
|
|
563
|
+
return null;
|
|
564
|
+
if (!isSafeUrl(picked.url)) {
|
|
565
|
+
throw new Error('The file picker returned an address the editor will not store.');
|
|
566
|
+
}
|
|
567
|
+
return { href: picked.url, title: picked.title ?? '' };
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
: {}),
|
|
315
572
|
}, (values) => {
|
|
316
|
-
|
|
573
|
+
// The address field alone: choosing from the list writes into it, so there
|
|
574
|
+
// is no second place a destination can hide.
|
|
575
|
+
const href = values['href'] || '';
|
|
317
576
|
if (!href)
|
|
318
|
-
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' };
|
|
319
583
|
const newWindow = values['newWindow'] === 'on';
|
|
320
584
|
return {
|
|
321
585
|
value: {
|
|
322
586
|
href,
|
|
587
|
+
title: values['title'] || null,
|
|
323
588
|
target: newWindow ? '_blank' : null,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
rel: newWindow ? 'noopener noreferrer' : null,
|
|
589
|
+
rel: mergeLinkRel(existing?.rel, newWindow),
|
|
590
|
+
id: existing?.id ?? null,
|
|
327
591
|
},
|
|
328
592
|
};
|
|
329
593
|
});
|
|
330
594
|
}
|
|
331
|
-
/**
|
|
332
|
-
* Ask for an image.
|
|
333
|
-
*
|
|
334
|
-
* The alternative text is asked for in the same dialog as the file, and this is
|
|
335
|
-
* the reason the upload happens on submit rather than on selection: one dialog
|
|
336
|
-
* means one decision point, and there is no path through it that inserts an
|
|
337
|
-
* image nobody described. A drop that uploaded immediately and asked afterwards
|
|
338
|
-
* would leave an undescribed image in the document every time somebody cancelled
|
|
339
|
-
* -- and OpenLeaf has no image-editing dialog yet, so "afterwards" would mean
|
|
340
|
-
* never.
|
|
341
|
-
*/
|
|
342
595
|
export async function promptForImage(doc, options = {}) {
|
|
343
|
-
const { file, upload } = options;
|
|
596
|
+
const { file, upload, host, existing } = options;
|
|
597
|
+
const listed = listedImages();
|
|
598
|
+
const classes = listedImageClasses();
|
|
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));
|
|
344
605
|
const describe = {
|
|
345
606
|
name: 'alt',
|
|
346
607
|
label: 'Alternative text',
|
|
347
608
|
type: 'text',
|
|
609
|
+
value: existing?.alt ?? '',
|
|
348
610
|
hint: 'Describe what the image shows, for people who cannot see it.',
|
|
349
611
|
};
|
|
612
|
+
const extras = [
|
|
613
|
+
{
|
|
614
|
+
name: 'title',
|
|
615
|
+
label: 'Title',
|
|
616
|
+
type: 'text',
|
|
617
|
+
value: existing?.title ?? '',
|
|
618
|
+
hint: 'Shown as a tooltip. Optional.',
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: 'align',
|
|
622
|
+
label: 'Alignment',
|
|
623
|
+
options: [
|
|
624
|
+
{ value: '', label: 'None' },
|
|
625
|
+
{ value: 'left', label: 'Float left' },
|
|
626
|
+
{ value: 'center', label: 'Centre' },
|
|
627
|
+
{ value: 'right', label: 'Float right' },
|
|
628
|
+
],
|
|
629
|
+
value: existing?.align ?? '',
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
name: 'className',
|
|
633
|
+
label: 'CSS classes',
|
|
634
|
+
type: 'text',
|
|
635
|
+
value: existing?.className ?? '',
|
|
636
|
+
hint: classes.length > 0
|
|
637
|
+
? inLocale('Suggested: {classes}', { classes: classes.join(', ') })
|
|
638
|
+
: 'Optional class names, separated by spaces.',
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: 'caption',
|
|
642
|
+
label: 'Caption',
|
|
643
|
+
type: 'text',
|
|
644
|
+
value: existing?.caption ?? '',
|
|
645
|
+
hint: 'Wraps the image in a figure. Leave blank for no caption.',
|
|
646
|
+
},
|
|
647
|
+
];
|
|
350
648
|
const fields = file
|
|
351
|
-
? [describe]
|
|
649
|
+
? [describe, ...extras]
|
|
352
650
|
: [
|
|
651
|
+
...(listed.length > 0
|
|
652
|
+
? [
|
|
653
|
+
{
|
|
654
|
+
name: 'listed',
|
|
655
|
+
label: 'Choose an image',
|
|
656
|
+
options: [{ value: '', label: 'Type an address instead' }, ...listed.map((item) => ({ value: item.value, label: item.title }))],
|
|
657
|
+
fills: 'src',
|
|
658
|
+
},
|
|
659
|
+
]
|
|
660
|
+
: []),
|
|
353
661
|
...(upload
|
|
354
662
|
? [
|
|
355
663
|
{
|
|
@@ -365,45 +673,212 @@ export async function promptForImage(doc, options = {}) {
|
|
|
365
673
|
name: 'src',
|
|
366
674
|
label: upload ? 'Or paste an image address' : 'Image address',
|
|
367
675
|
type: 'text',
|
|
676
|
+
value: existing?.src ?? '',
|
|
368
677
|
required: !upload,
|
|
369
678
|
},
|
|
370
679
|
describe,
|
|
680
|
+
...extras,
|
|
371
681
|
];
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
682
|
+
const finish = (src, alt, width, height, values) => ({
|
|
683
|
+
src,
|
|
684
|
+
alt,
|
|
685
|
+
title: values['title'] || null,
|
|
686
|
+
width,
|
|
687
|
+
height,
|
|
688
|
+
className: values['className'] || null,
|
|
689
|
+
align: values['align'] === 'left' || values['align'] === 'right' || values['align'] === 'center'
|
|
690
|
+
? values['align']
|
|
691
|
+
: null,
|
|
692
|
+
caption: values['caption'] ? values['caption'] : null,
|
|
693
|
+
});
|
|
694
|
+
return showForm(doc, file ? 'Describe this image' : existing ? 'Edit image' : 'Insert image', fields, {
|
|
695
|
+
locale,
|
|
696
|
+
...(host ? { host } : {}),
|
|
697
|
+
extraCheckbox: {
|
|
698
|
+
name: 'decorative',
|
|
699
|
+
label: 'This image is decorative and needs no description',
|
|
700
|
+
checked: existing?.alt === '',
|
|
701
|
+
},
|
|
702
|
+
...(file ? { note: inLocale('Ready to upload: {file}', { file: file.name }) } : {}),
|
|
379
703
|
busyLabel: 'Uploading…',
|
|
704
|
+
...(picker && host
|
|
705
|
+
? {
|
|
706
|
+
browse: {
|
|
707
|
+
label: 'Browse files',
|
|
708
|
+
fill: async () => {
|
|
709
|
+
const picked = await picker({ kind: 'image', host });
|
|
710
|
+
if (!picked)
|
|
711
|
+
return null;
|
|
712
|
+
if (!isSafeUrl(picked.url)) {
|
|
713
|
+
throw new Error('The image picker returned an address the editor will not store.');
|
|
714
|
+
}
|
|
715
|
+
return { src: picked.url, alt: picked.alt ?? '', title: picked.title ?? '' };
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
: {}),
|
|
380
720
|
}, (values, files) => {
|
|
381
721
|
const chosen = file ?? files['file'];
|
|
382
|
-
const src = values['src']
|
|
722
|
+
const src = values['src'] || '';
|
|
383
723
|
if (!chosen && !src) {
|
|
384
|
-
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
|
+
};
|
|
385
730
|
}
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
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 };
|
|
390
735
|
if (!values['alt'] && values['decorative'] !== 'on') {
|
|
391
|
-
return { error: 'Add alternative text, or tick the decorative box.' };
|
|
736
|
+
return { error: t('Add alternative text, or tick the decorative box.'), field: 'alt' };
|
|
392
737
|
}
|
|
393
738
|
const alt = values['decorative'] === 'on' ? '' : (values['alt'] ?? '');
|
|
394
739
|
if (!chosen || !upload) {
|
|
395
|
-
|
|
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
|
+
};
|
|
396
746
|
}
|
|
397
747
|
return upload(chosen).then((result) => ({
|
|
748
|
+
value: finish(result.src, alt, dimension(result.width), dimension(result.height), values),
|
|
749
|
+
}));
|
|
750
|
+
});
|
|
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 {
|
|
398
873
|
value: {
|
|
399
|
-
src
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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,
|
|
405
880
|
},
|
|
406
|
-
}
|
|
881
|
+
};
|
|
407
882
|
});
|
|
408
883
|
}
|
|
409
884
|
//# sourceMappingURL=dialog.js.map
|