@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.
Files changed (78) hide show
  1. package/README.md +85 -0
  2. package/dist/block-type.d.ts +5 -0
  3. package/dist/block-type.d.ts.map +1 -0
  4. package/dist/block-type.js +133 -0
  5. package/dist/block-type.js.map +1 -0
  6. package/dist/content-css.d.ts +18 -0
  7. package/dist/content-css.d.ts.map +1 -1
  8. package/dist/content-css.js +231 -16
  9. package/dist/content-css.js.map +1 -1
  10. package/dist/css.d.ts +23 -0
  11. package/dist/css.d.ts.map +1 -0
  12. package/dist/css.js +846 -0
  13. package/dist/css.js.map +1 -0
  14. package/dist/dialog.d.ts +93 -0
  15. package/dist/dialog.d.ts.map +1 -1
  16. package/dist/dialog.js +414 -86
  17. package/dist/dialog.js.map +1 -1
  18. package/dist/floating.d.ts +4 -0
  19. package/dist/floating.d.ts.map +1 -1
  20. package/dist/floating.js +50 -2
  21. package/dist/floating.js.map +1 -1
  22. package/dist/help.d.ts +1 -1
  23. package/dist/help.d.ts.map +1 -1
  24. package/dist/help.js +29 -5
  25. package/dist/help.js.map +1 -1
  26. package/dist/i18n.d.ts +8 -0
  27. package/dist/i18n.d.ts.map +1 -1
  28. package/dist/i18n.js +32 -7
  29. package/dist/i18n.js.map +1 -1
  30. package/dist/icons.d.ts +7 -1
  31. package/dist/icons.d.ts.map +1 -1
  32. package/dist/icons.js +6 -2
  33. package/dist/icons.js.map +1 -1
  34. package/dist/index.d.ts +10 -8
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +8 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/items.d.ts +0 -1
  39. package/dist/items.d.ts.map +1 -1
  40. package/dist/items.js +211 -20
  41. package/dist/items.js.map +1 -1
  42. package/dist/live.d.ts +42 -0
  43. package/dist/live.d.ts.map +1 -0
  44. package/dist/live.js +83 -0
  45. package/dist/live.js.map +1 -0
  46. package/dist/menu.d.ts +31 -2
  47. package/dist/menu.d.ts.map +1 -1
  48. package/dist/menu.js +163 -23
  49. package/dist/menu.js.map +1 -1
  50. package/dist/openleaf.css +147 -12
  51. package/dist/overflow.d.ts +47 -4
  52. package/dist/overflow.d.ts.map +1 -1
  53. package/dist/overflow.js +338 -83
  54. package/dist/overflow.js.map +1 -1
  55. package/dist/registry.d.ts +72 -18
  56. package/dist/registry.d.ts.map +1 -1
  57. package/dist/registry.js +7 -6
  58. package/dist/registry.js.map +1 -1
  59. package/dist/skins.d.ts.map +1 -1
  60. package/dist/skins.js +48 -14
  61. package/dist/skins.js.map +1 -1
  62. package/dist/styles.d.ts +61 -1
  63. package/dist/styles.d.ts.map +1 -1
  64. package/dist/styles.js +77 -637
  65. package/dist/styles.js.map +1 -1
  66. package/dist/testing.d.ts +3 -0
  67. package/dist/testing.d.ts.map +1 -0
  68. package/dist/testing.js +3 -0
  69. package/dist/testing.js.map +1 -0
  70. package/dist/toolbar.d.ts +71 -12
  71. package/dist/toolbar.d.ts.map +1 -1
  72. package/dist/toolbar.js +410 -166
  73. package/dist/toolbar.js.map +1 -1
  74. package/dist/upload.d.ts +32 -7
  75. package/dist/upload.d.ts.map +1 -1
  76. package/dist/upload.js +64 -8
  77. package/dist/upload.js.map +1 -1
  78. 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 { ensureStyles } from './styles.js';
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, #d1d9e0);
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
- .ol-dialog label { display: grid; gap: 4px; font-weight: 500; }
41
- .ol-dialog .ol-hint { font-weight: 400; font-size: .9em; opacity: .75; }
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, #d1d9e0);
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, #d1d9e0);
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); outline-offset: 1px;
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
- let dialogStylesReady = false;
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
- if (dialogStylesReady)
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
- const headingId = `ol-dlg-${Math.abs(hash(title))}`;
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
- const text = doc.createElement('span');
127
- text.textContent = field.label;
128
- label.appendChild(text);
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
- label.appendChild(hint);
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
- label.appendChild(control);
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(label);
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?.fill().then((filled) => {
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 = 'Save';
354
+ ok.textContent = saveLabel;
245
355
  actions.append(cancel, ok);
246
356
  form.appendChild(actions);
247
357
  dialog.appendChild(form);
248
- doc.body.appendChild(dialog);
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 ? (options.busyLabel ?? 'Working…') : 'Save';
267
- progress.textContent = value ? (options.busyLabel ?? 'Working…') : '';
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
- outcome = commit(values, files);
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.textContent = messageFrom(thrown);
441
+ showError({ error: messageFrom(thrown) });
302
442
  return;
303
443
  }
304
444
  if (!(outcome instanceof Promise)) {
305
445
  if ('error' in outcome) {
306
- error.textContent = outcome.error;
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
- error.textContent = settled.error;
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.textContent = messageFrom(thrown);
464
+ showError({ error: messageFrom(thrown) });
327
465
  });
328
466
  });
329
- const focusFirst = () => {
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
- focusFirst();
335
- const first = fields[0] ? inputs.get(fields[0].name) : undefined;
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
- function hash(value) {
346
- let out = 0;
347
- for (let i = 0; i < value.length; i += 1)
348
- out = (out * 31 + value.charCodeAt(i)) | 0;
349
- return out;
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 ? 'noopener noreferrer' : null,
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 ? `Suggested: ${classes.join(', ')}` : 'Optional class names, separated by spaces.',
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: `Ready to upload: ${file.name}` } : {}),
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 { error: upload ? 'Choose a file or enter an image address.' : 'Enter an image address.' };
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
- return { value: finish(src, alt, null, null, values) };
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