@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.
Files changed (82) 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 +31 -0
  7. package/dist/content-css.d.ts.map +1 -0
  8. package/dist/content-css.js +272 -0
  9. package/dist/content-css.js.map +1 -0
  10. package/dist/css.d.ts +23 -0
  11. package/dist/css.d.ts.map +1 -0
  12. package/dist/css.js +813 -0
  13. package/dist/css.js.map +1 -0
  14. package/dist/dialog.d.ts +158 -18
  15. package/dist/dialog.d.ts.map +1 -1
  16. package/dist/dialog.js +605 -130
  17. package/dist/dialog.js.map +1 -1
  18. package/dist/floating.d.ts +29 -0
  19. package/dist/floating.d.ts.map +1 -0
  20. package/dist/floating.js +130 -0
  21. package/dist/floating.js.map +1 -0
  22. package/dist/help.d.ts +9 -0
  23. package/dist/help.d.ts.map +1 -0
  24. package/dist/help.js +97 -0
  25. package/dist/help.js.map +1 -0
  26. package/dist/i18n.d.ts +39 -0
  27. package/dist/i18n.d.ts.map +1 -0
  28. package/dist/i18n.js +114 -0
  29. package/dist/i18n.js.map +1 -0
  30. package/dist/icons.d.ts +7 -1
  31. package/dist/icons.d.ts.map +1 -1
  32. package/dist/icons.js +10 -2
  33. package/dist/icons.js.map +1 -1
  34. package/dist/index.d.ts +15 -7
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +14 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/items.d.ts +2 -1
  39. package/dist/items.d.ts.map +1 -1
  40. package/dist/items.js +263 -17
  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 +85 -0
  47. package/dist/menu.d.ts.map +1 -0
  48. package/dist/menu.js +500 -0
  49. package/dist/menu.js.map +1 -0
  50. package/dist/openleaf.css +325 -7
  51. package/dist/overflow.d.ts +44 -0
  52. package/dist/overflow.d.ts.map +1 -0
  53. package/dist/overflow.js +323 -0
  54. package/dist/overflow.js.map +1 -0
  55. package/dist/pickers.d.ts +43 -0
  56. package/dist/pickers.d.ts.map +1 -0
  57. package/dist/pickers.js +52 -0
  58. package/dist/pickers.js.map +1 -0
  59. package/dist/registry.d.ts +72 -18
  60. package/dist/registry.d.ts.map +1 -1
  61. package/dist/registry.js +7 -6
  62. package/dist/registry.js.map +1 -1
  63. package/dist/skins.d.ts.map +1 -1
  64. package/dist/skins.js +48 -14
  65. package/dist/skins.js.map +1 -1
  66. package/dist/styles.d.ts +61 -1
  67. package/dist/styles.d.ts.map +1 -1
  68. package/dist/styles.js +77 -421
  69. package/dist/styles.js.map +1 -1
  70. package/dist/testing.d.ts +3 -0
  71. package/dist/testing.d.ts.map +1 -0
  72. package/dist/testing.js +3 -0
  73. package/dist/testing.js.map +1 -0
  74. package/dist/toolbar.d.ts +91 -12
  75. package/dist/toolbar.d.ts.map +1 -1
  76. package/dist/toolbar.js +459 -127
  77. package/dist/toolbar.js.map +1 -1
  78. package/dist/upload.d.ts +11 -0
  79. package/dist/upload.d.ts.map +1 -1
  80. package/dist/upload.js +15 -1
  81. package/dist/upload.js.map +1 -1
  82. 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 { 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';
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, #d1d9e0);
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
- .ol-dialog label { display: grid; gap: 4px; font-weight: 500; }
40
- .ol-dialog .ol-hint { font-weight: 400; font-size: .9em; opacity: .75; }
41
- .ol-dialog input[type="text"], .ol-dialog input[type="url"], .ol-dialog input[type="file"] {
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, #d1d9e0);
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, #d1d9e0);
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); outline-offset: 1px;
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
- let dialogStylesReady = false;
71
- function ensureDialogStyles(doc) {
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
- if (dialogStylesReady)
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
- 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');
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
- const text = doc.createElement('span');
124
- text.textContent = field.label;
125
- label.appendChild(text);
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
- label.appendChild(hint);
215
+ hint.textContent = t(field.hint);
216
+ wrap.appendChild(hint);
217
+ describedBy.push(hintId);
131
218
  }
132
- const input = doc.createElement('input');
133
- input.type = field.type ?? 'text';
134
- input.name = field.name;
135
- if (field.type === 'file') {
136
- if (field.accept)
137
- input.accept = field.accept;
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.value = field.value ?? '';
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
- label.appendChild(input);
147
- inputs.set(field.name, input);
148
- form.appendChild(label);
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 = 'Save';
354
+ ok.textContent = saveLabel;
191
355
  actions.append(cancel, ok);
192
356
  form.appendChild(actions);
193
357
  dialog.appendChild(form);
194
- doc.body.appendChild(dialog);
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 ? (options.busyLabel ?? 'Working…') : 'Save';
213
- 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();
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
- 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));
245
439
  }
246
440
  catch (thrown) {
247
- error.textContent = messageFrom(thrown);
441
+ showError({ error: messageFrom(thrown) });
248
442
  return;
249
443
  }
250
444
  if (!(outcome instanceof Promise)) {
251
445
  if ('error' in outcome) {
252
- error.textContent = outcome.error;
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
- error.textContent = settled.error;
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.textContent = messageFrom(thrown);
464
+ showError({ error: messageFrom(thrown) });
273
465
  });
274
466
  });
275
- const focusFirst = () => {
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
- focusFirst();
281
- const first = fields[0] ? inputs.get(fields[0].name) : undefined;
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
- function hash(value) {
292
- let out = 0;
293
- for (let i = 0; i < value.length; i += 1)
294
- out = (out * 31 + value.charCodeAt(i)) | 0;
295
- return out;
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
- return showForm(doc, existing?.href ? 'Edit link' : 'Insert link', [
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
- const href = values['href'] ?? '';
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
- // rel="noopener" is not optional with target=_blank: without it the
325
- // opened page gets a handle on this window.
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 decorative = {
373
- name: 'decorative',
374
- label: 'This image is decorative and needs no description',
375
- };
376
- return showForm(doc, file ? 'Describe this image' : 'Insert image', fields, {
377
- extraCheckbox: decorative,
378
- ...(file ? { note: `Ready to upload: ${file.name}` } : {}),
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 { 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
+ };
385
730
  }
386
- // Required unless explicitly marked decorative. The checkbox exists so
387
- // that the honest answer "this needs no description" is easier than
388
- // typing "image" to defeat a validator -- which is what a hard
389
- // requirement actually teaches people to do.
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
- return { value: { src, alt, width: null, height: null } };
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: result.src,
400
- // An uploader's description only pre-fills; what the author confirmed
401
- // in the dialog is what gets stored.
402
- alt,
403
- width: dimension(result.width),
404
- height: dimension(result.height),
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