@openleaf-editor/element 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/dist/index.js CHANGED
@@ -20,21 +20,49 @@
20
20
  * reads `$_POST['body']` keeps working untouched.
21
21
  *
22
22
  * Attributes:
23
- * for id of the textarea to bind to
24
- * skin named appearance: midnight, paper, contrast, compact
25
- * theme light | dark | auto (default: follow the visitor's system)
26
- * toolbar space-separated item ids, `|` for a separator; `none` to omit
27
- * readonly render but do not allow editing
28
- * aria-label accessible name for the editable region
23
+ * for id of the textarea to bind to
24
+ * skin named appearance: midnight, paper, contrast, compact
25
+ * theme light | dark | auto (default: follow the visitor's system)
26
+ * toolbar space-separated item ids, `|` for a separator; `none` to omit
27
+ * toolbar2 a second toolbar, same grammar
28
+ * menubar space-separated menu ids, or omit to hide; `none` also hides
29
+ * contextmenu `none` to disable; default is link, image and table menus
30
+ * selection-toolbar floating bar for a non-empty selection; `none` disables
31
+ * insert-toolbar floating bar for an empty block; `none` disables
32
+ * formats `p.lead=Lead|h2=Section` entries for the formats dropdown
33
+ * content-css comma-separated URLs scoped onto the canvas
34
+ * lang UI locale, matched against registerTranslations()
35
+ * inline hide chrome until the editor is focused
36
+ * autoresize grow the canvas with the document
37
+ * toolbar-overflow collapse overflowing groups into a More menu
38
+ * readonly render but do not allow editing
39
+ * autolink `false` to stop URLs becoming links on space or Enter.
40
+ * Trailing prose punctuation is left outside the mark.
41
+ * visualaids `false` to hide the guides for invisible structure
42
+ * aria-label accessible name for the editable region
43
+ *
44
+ * See `docs/api-reference.md` for the properties, methods and events too.
29
45
  */
30
- import { buildKeymap, coreSchema, createRegisteredPlugins, insertImage, onEditorPluginsChange, onSchemaExtensionsChange, parseHtml, serializeHtml, } from '@openleaf-editor/core';
46
+ var _a;
47
+ import { autolinkPlugin, disclosurePlugin, buildKeymap, coreSchema, createRegisteredPlugins, insertImage, isolatingSelectionPlugin, nonEditablePlugin, tableCaptionPlugin, onEditorPluginsChange, onSchemaExtensionsChange, parseFormatList, parseHtml, serializeHtml, visualAidsPlugin, } from '@openleaf-editor/core';
31
48
  import { normalizePastedHtml } from '@openleaf-editor/paste';
32
- import { SOURCE_TOGGLE_EVENT, Toolbar, applyColourScheme, applySkin, canUploadImages, ensureSkins, ensureStyles, imageFilesFrom, imageUploaderFor, promptForImage, registerDefaultItems, runUploader, } from '@openleaf-editor/ui';
49
+ import { DEFAULT_INSERT_LAYOUT, DEFAULT_SELECTION_LAYOUT, FULLSCREEN_TOGGLE_EVENT, FloatingToolbars, IMAGE_CONTEXT_ITEMS, LINK_CONTEXT_ITEMS, MenuBar, PopupMenu, SOURCE_TOGGLE_EVENT, selectMenus, TABLE_CONTEXT_ITEMS, Toolbar, VISUAL_AIDS_TOGGLE_EVENT, applyColourScheme, applySkin, canUploadImages, contentCssUrls, ensureSkins, ensureStyles, imageFilesFrom, announce, imageUploaderFor, disposeLiveRegion, liveRegion, loadContentCss, promptForImage, promptHelp, registerDefaultItems, runUploader, t, withLocale, } from '@openleaf-editor/ui';
33
50
  import { baseKeymap } from 'prosemirror-commands';
34
51
  import { history } from 'prosemirror-history';
35
52
  import { keymap } from 'prosemirror-keymap';
36
- import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
53
+ import { EditorState, NodeSelection, Plugin, TextSelection } from 'prosemirror-state';
37
54
  import { EditorView } from 'prosemirror-view';
55
+ import { FormBridge } from './form-bridge.js';
56
+ const CHROME_ATTRIBUTES = [
57
+ 'toolbar',
58
+ 'toolbar2',
59
+ 'menubar',
60
+ 'formats',
61
+ 'contextmenu',
62
+ 'selection-toolbar',
63
+ 'insert-toolbar',
64
+ 'toolbar-overflow',
65
+ ];
38
66
  let hintCounter = 0;
39
67
  /**
40
68
  * Emitted when the HTML source view opens and closes, carrying the textarea.
@@ -43,12 +71,38 @@ let hintCounter = 0;
43
71
  * formatting, syntax highlighting -- without the element having to know anything
44
72
  * about it. Names are defined here rather than imported so the element keeps no
45
73
  * dependency on any plugin.
74
+ *
75
+ * These fire on a REAL teardown, not on a DOM move. Moving the element keeps
76
+ * the whole session -- including source mode and the same textarea node -- so
77
+ * an enhancer that attached on open stays correctly attached, and gets its
78
+ * close only when the element is actually removed for good.
46
79
  */
47
80
  export const SOURCE_OPEN_EVENT = 'openleaf:source-open';
48
81
  export const SOURCE_CLOSE_EVENT = 'openleaf:source-close';
49
- export class OpenLeafEditor extends HTMLElement {
82
+ // Evaluating a custom-element module must be safe during SSR. Registration and
83
+ // construction still happen only in a browser, but framework servers routinely
84
+ // import their component modules while rendering a route.
85
+ const HTMLElementBase = (globalThis.HTMLElement ?? class {
86
+ });
87
+ export class OpenLeafEditor extends HTMLElementBase {
50
88
  static get observedAttributes() {
51
- return ['for', 'readonly', 'skin', 'theme'];
89
+ // `aria-label` is observed because a framework changes it after mount far
90
+ // more often than it sets it once: a React editor whose label came from
91
+ // props never reached the editable region at all.
92
+ return [
93
+ 'for',
94
+ 'readonly',
95
+ 'skin',
96
+ 'theme',
97
+ 'lang',
98
+ 'aria-label',
99
+ 'inline',
100
+ 'autoresize',
101
+ 'visualaids',
102
+ 'autolink',
103
+ 'content-css',
104
+ ...CHROME_ATTRIBUTES,
105
+ ];
52
106
  }
53
107
  /**
54
108
  * Appearance attributes are applied on change as well as at build time, so a
@@ -56,14 +110,50 @@ export class OpenLeafEditor extends HTMLElement {
56
110
  * which would cost them their undo history for a colour change.
57
111
  */
58
112
  attributeChangedCallback(name) {
59
- if (name === 'skin')
60
- applySkin(this, this.getAttribute('skin'));
61
- if (name === 'theme')
62
- applyColourScheme(this, this.#colourScheme());
63
- if (name === 'readonly')
64
- this.#applyReadonly();
65
- if (name === 'for')
66
- this.#rebindTextarea();
113
+ switch (name) {
114
+ case 'skin':
115
+ applySkin(this, this.getAttribute('skin'));
116
+ return;
117
+ case 'theme':
118
+ applyColourScheme(this, this.#colourScheme());
119
+ return;
120
+ case 'readonly':
121
+ this.#applyReadonly();
122
+ return;
123
+ case 'for':
124
+ if (this.#view) {
125
+ this.#formBridge.rebind();
126
+ // The name may have come from the old textarea's <label>.
127
+ this.#view.setProps({});
128
+ }
129
+ return;
130
+ case 'lang':
131
+ this.#applyLocale();
132
+ return;
133
+ case 'aria-label':
134
+ this.#applyHostRole();
135
+ this.#view?.setProps({});
136
+ return;
137
+ case 'inline':
138
+ this.#applyInline();
139
+ return;
140
+ case 'autoresize':
141
+ this.#applyAutoresize();
142
+ return;
143
+ case 'visualaids':
144
+ this.#applyVisualAids(this.getAttribute('visualaids') !== 'false');
145
+ return;
146
+ case 'autolink':
147
+ this.#reconfigurePlugins();
148
+ return;
149
+ case 'content-css':
150
+ void this.#mountContentCss();
151
+ return;
152
+ default:
153
+ // Everything in CHROME_ATTRIBUTES. The canvas is re-parented rather
154
+ // than replaced, so the document and undo history survive.
155
+ this.#rerenderChrome();
156
+ }
67
157
  }
68
158
  #colourScheme() {
69
159
  const value = this.getAttribute('theme');
@@ -71,38 +161,97 @@ export class OpenLeafEditor extends HTMLElement {
71
161
  }
72
162
  #view = null;
73
163
  #toolbar = null;
74
- #textarea = null;
75
- #form = null;
164
+ #toolbar2 = null;
165
+ #menubar = null;
166
+ #contextMenu = null;
167
+ #floating = null;
168
+ #formBridge = new FormBridge(this, () => this.value, (html) => { this.value = html; });
76
169
  #contentHost = null;
170
+ /** The Alt+F10 hint, when there is a toolbar for it to describe. */
171
+ #hint = null;
77
172
  #sourceArea = null;
173
+ /**
174
+ * The last serialization, keyed on the document it came from.
175
+ *
176
+ * A ProseMirror document is persistent and immutable, so identity is a sound
177
+ * cache key: the same node can only ever serialize to the same string. Within
178
+ * one frame `value` is read several times over -- the change event, a
179
+ * framework wrapper comparing against its controlled value, an autosave --
180
+ * and each read used to repeat a full `DOMSerializer` pass over the whole
181
+ * document.
182
+ */
183
+ #serialized = null;
78
184
  #sourceMode = false;
79
185
  #deferred = false;
186
+ /** True between a completed #build() and its #teardown(). Makes teardown once-only. */
187
+ #built = false;
188
+ /**
189
+ * The document the build registered its listeners on.
190
+ *
191
+ * Teardown is deferred by a microtask, and `adoptNode` both removes the
192
+ * element and reassigns `ownerDocument` -- so by the time teardown runs,
193
+ * `this.ownerDocument` can be a different document than the one holding the
194
+ * listeners. Toolbar and MenuBar already hold their own document for exactly
195
+ * this reason.
196
+ */
197
+ #boundDoc = null;
198
+ /**
199
+ * The document the context menu's capture listener went on.
200
+ *
201
+ * Same reason as `#boundDoc`, one scope smaller: the chrome can be rebuilt
202
+ * while the element sits in a document it was adopted into, and
203
+ * `this.ownerDocument` by then is not the document the listener is on.
204
+ * Removing from the wrong one is a silent no-op that leaves the listener --
205
+ * and everything it retains, including the serialized-document cache -- alive
206
+ * for the life of that document. Toolbar and MenuBar already hold their own
207
+ * document for exactly this reason.
208
+ */
209
+ #contextDoc = null;
210
+ /**
211
+ * The last document this editor knew about, as HTML.
212
+ *
213
+ * Written by every build and refreshed on disconnect. It exists so a rebuild
214
+ * has something better than `this.innerHTML` to fall back to -- by then the
215
+ * subtree has held the toolbar markup this element appended, and reading that
216
+ * back made it the author's document and posted it to the server. It also
217
+ * backs `get value` once the view is gone.
218
+ */
219
+ #initialHtml = null;
220
+ /**
221
+ * A `value` assignment made while there was no view to receive it: before the
222
+ * build (pre-upgrade, or while waiting for DOMContentLoaded) or after a
223
+ * teardown. Consumed by the next build.
224
+ */
225
+ #pendingValue = null;
226
+ /**
227
+ * False until the document changes at all after a build.
228
+ *
229
+ * Every wrapper mounts an empty editor and then pushes the server's HTML in
230
+ * through `value`. That first fill is not an edit, and making it undoable is
231
+ * why an author's FIRST Ctrl-Z used to empty the document. Any later
232
+ * assignment -- or any keystroke -- is a real change, and undoable.
233
+ */
234
+ #docTouched = false;
80
235
  /** The schema this editor was built with. Fixed for its lifetime. */
81
236
  #schema = coreSchema();
82
237
  #basePlugins = [];
238
+ /** Held so `autolink` can be toggled without rebuilding the rest. */
239
+ #autolink = null;
240
+ /** Held so `visualaids` can be toggled without rebuilding the rest. */
241
+ #visualAidsPlugin = null;
242
+ #imageUploader = null;
83
243
  #pluginCache = new Map();
84
244
  #unwatchPlugins;
85
245
  #unwatchSchema;
86
- #onSubmit = () => this.#syncToTextarea();
87
- #onFormData = (event) => {
88
- this.#syncToTextarea();
89
- // formdata fires after the browser has already built event.formData from
90
- // the current controls. Updating textarea.value does not change that
91
- // snapshot; the entry has to be written onto the FormData itself.
92
- if (this.#textarea?.name)
93
- event.formData.set(this.#textarea.name, this.#textarea.value);
94
- };
95
- #onReset = () => {
96
- // The reset event fires *before* the controls are restored -- read
97
- // textarea.value in the handler and it is still the edited text. The
98
- // microtask runs after the reset algorithm finishes, which is the first
99
- // point the default is actually readable. Removing it re-loads the
100
- // editor with the content the reset was meant to discard.
101
- queueMicrotask(() => {
102
- if (this.#textarea)
103
- this.value = this.#textarea.value;
104
- });
105
- };
246
+ #resizeObserver = null;
247
+ /** Pending autoresize frame, so a burst of observations relays out once. */
248
+ #resizeFrame = 0;
249
+ #visualAids = true;
250
+ /** Whether the aids plugin was installed at all. Build-time, like the attribute. */
251
+ #visualAidsAvailable = true;
252
+ #fullscreen = false;
253
+ /** True while a real fullscreen session is ours, as opposed to the fallback. */
254
+ #nativeFullscreen = false;
106
255
  /**
107
256
  * Build the editor -- but not before the document's scripts have run.
108
257
  *
@@ -121,8 +270,30 @@ export class OpenLeafEditor extends HTMLElement {
121
270
  * nothing about this is visible to an author.
122
271
  */
123
272
  connectedCallback() {
124
- if (this.#view || this.#deferred)
273
+ // Before anything else: a property assigned to this element BEFORE its
274
+ // definition loaded is an own data property, and an own data property
275
+ // shadows the prototype accessor for good. `defer`, code splitting and SSR
276
+ // hydration all set `.value` before the element upgrades, and without this
277
+ // the author sees an empty editor while `el.value` reports their content.
278
+ this.#upgradeProperty('value');
279
+ this.#upgradeProperty('imageUploader');
280
+ if (this.#view || this.#deferred) {
281
+ // Reconnection with the session still alive -- a move. Nothing is
282
+ // rebuilt, but the element may have landed in a different <form>, and the
283
+ // submit hooks have to follow it. `attach()` detaches first, so this
284
+ // cannot double-register.
285
+ //
286
+ // `attach()` and not `rebind()`, deliberately. Re-resolving the textarea
287
+ // would be wrong more often than right: `bind()`'s nested branch is
288
+ // `querySelector('textarea')`, which in source mode matches the
289
+ // `.ol-source` box ahead of the real one. The residual gap -- a host that
290
+ // REPLACES the bound textarea while the element is moved leaves the
291
+ // bridge writing into a detached node -- is left open knowingly; closing
292
+ // it means teaching `bind()` to skip the source box first.
293
+ if (this.#view)
294
+ this.#formBridge.attach();
125
295
  return;
296
+ }
126
297
  if (this.ownerDocument.readyState === 'loading') {
127
298
  this.#deferred = true;
128
299
  this.ownerDocument.addEventListener('DOMContentLoaded', () => {
@@ -134,65 +305,106 @@ export class OpenLeafEditor extends HTMLElement {
134
305
  }
135
306
  this.#build();
136
307
  }
308
+ /**
309
+ * Re-apply a property that was set before this element upgraded.
310
+ *
311
+ * Deleting the own data property uncovers the prototype accessor again;
312
+ * assigning the saved value then actually runs it. This is the standard
313
+ * custom-element "lazy property" dance, and the element is unusable under
314
+ * every asynchronous loading strategy without it.
315
+ */
316
+ #upgradeProperty(name) {
317
+ if (!Object.prototype.hasOwnProperty.call(this, name))
318
+ return;
319
+ const self = this;
320
+ const pending = self[name];
321
+ delete self[name];
322
+ self[name] = pending;
323
+ }
137
324
  #build() {
138
325
  registerDefaultItems();
139
326
  ensureStyles(this.ownerDocument);
140
327
  ensureSkins(this.ownerDocument);
141
328
  applySkin(this, this.getAttribute('skin'));
142
329
  applyColourScheme(this, this.#colourScheme());
143
- this.#textarea = this.#findTextarea();
144
- const initialHtml = this.#textarea?.value ?? this.innerHTML;
145
- const nestedTextarea = this.#textarea && this.contains(this.#textarea) ? this.#textarea : null;
330
+ const textarea = this.#formBridge.bind();
331
+ // Precedence, and the reasoning for each step:
332
+ //
333
+ // - an explicit assignment the element could not apply yet outranks
334
+ // everything, because it is the host saying so imperatively;
335
+ // - then the bound textarea, which disconnect syncs before snapshotting,
336
+ // so the two only disagree when the host rewrote the textarea itself;
337
+ // - then whatever markup is in the element NOW, but only if there is any.
338
+ // Teardown empties the subtree, so anything here on a rebuild was put
339
+ // back by the host -- a server re-render, newer than any snapshot;
340
+ // - then the snapshot taken at disconnect. This is what stops the element
341
+ // reading its own toolbar markup back as the document.
342
+ const restored = this.innerHTML.trim();
343
+ const initialHtml = this.#pendingValue ?? textarea?.value ?? (restored || this.#initialHtml) ?? '';
344
+ this.#pendingValue = null;
345
+ this.#initialHtml = initialHtml;
346
+ const nestedTextarea = textarea && this.contains(textarea) ? textarea : null;
347
+ // An externally bound textarea -- the documented `for=` pattern -- stayed
348
+ // visible and focusable unless the integrator remembered `hidden`. Forgetting
349
+ // is not cosmetic: a keyboard user can tab into it, type, and have
350
+ // `FormBridge.sync()` overwrite every word at submit, silently, at the exact
351
+ // moment the work was meant to be saved. It still posts while hidden.
352
+ if (textarea && !nestedTextarea)
353
+ textarea.hidden = true;
146
354
  // Nested binding used to `innerHTML = ''` the textarea out of the document,
147
355
  // so it was no longer a successful form control. Lift it aside first, then
148
356
  // put it back hidden so the form still posts it.
149
357
  nestedTextarea?.remove();
150
358
  this.innerHTML = '';
151
359
  this.classList.add('ol-editor');
152
- const layout = this.getAttribute('toolbar');
153
- const wantsToolbar = layout !== 'none';
154
- if (wantsToolbar) {
155
- this.#toolbar = new Toolbar(this, this.ownerDocument, {
156
- ...(layout ? { layout } : {}),
157
- });
158
- this.appendChild(this.#toolbar.el);
159
- }
360
+ this.#applyHostRole();
361
+ if (this.hasAttribute('inline'))
362
+ this.classList.add('ol-inline');
363
+ if (this.hasAttribute('autoresize'))
364
+ this.classList.add('ol-autoresize');
365
+ this.#visualAidsAvailable = this.getAttribute('visualaids') !== 'false';
366
+ this.#visualAids = this.#visualAidsAvailable;
367
+ if (this.#visualAids)
368
+ this.classList.add('ol-visual-aids');
160
369
  const contentHost = this.ownerDocument.createElement('div');
161
370
  contentHost.className = 'ol-content';
162
- this.appendChild(contentHost);
163
371
  this.#contentHost = contentHost;
164
- // The Alt+F10 hint lives in a hidden element referenced by
165
- // aria-describedby. Screen reader users cannot guess the shortcut, and
166
- // discoverability comes from telling them rather than from choosing a
167
- // guessable key.
168
- const hintId = `ol-hint-${(hintCounter += 1)}`;
169
- const hint = this.ownerDocument.createElement('span');
170
- hint.id = hintId;
171
- hint.className = 'ol-live';
172
- hint.textContent = wantsToolbar
173
- ? 'Rich text editor. Press Alt plus F10 for the formatting toolbar.'
174
- : 'Rich text editor.';
175
- this.appendChild(hint);
176
- if (this.#toolbar)
177
- this.appendChild(this.#toolbar.liveRegion);
372
+ this.#buildChrome();
178
373
  // Held on the instance rather than built inline, because `reconfigure`
179
374
  // has to hand the view back the *same* history() it was created with.
180
375
  // Building a second one is what dropped undo when a plugin registered late.
181
376
  this.#basePlugins = [
377
+ // Before the table-editing bundle: later plugins win `nodeViews.table`.
378
+ tableCaptionPlugin(),
182
379
  history(),
183
- // Alt+F10 is bound before the shared keymap so it cannot be shadowed.
184
380
  keymap({
185
381
  'Alt-F10': () => {
186
382
  this.#toolbar?.focusToolbar();
187
383
  return true;
188
384
  },
385
+ F1: () => {
386
+ promptHelp(this.ownerDocument, this);
387
+ return true;
388
+ },
189
389
  }),
190
- // The shared shortcut table, so toolbar tooltips and any help dialog
191
- // render the real bindings rather than a duplicate list that drifts.
192
390
  keymap(buildKeymap()),
193
391
  keymap(baseKeymap),
392
+ nonEditablePlugin(),
393
+ isolatingSelectionPlugin(),
394
+ disclosurePlugin(),
194
395
  ];
396
+ if (this.getAttribute('autolink') !== 'false') {
397
+ this.#autolink = autolinkPlugin();
398
+ this.#basePlugins.push(this.#autolink);
399
+ }
400
+ if (this.#visualAids) {
401
+ this.#visualAidsPlugin = visualAidsPlugin();
402
+ this.#basePlugins.push(this.#visualAidsPlugin);
403
+ }
195
404
  this.#schema = coreSchema();
405
+ // Cleared before the view exists, so a transaction dispatched during mount
406
+ // is counted as a real change rather than wiped by a later reset.
407
+ this.#docTouched = false;
196
408
  this.#view = new EditorView(contentHost, {
197
409
  state: EditorState.create({
198
410
  doc: parseHtml(initialHtml, { schema: this.#schema }),
@@ -204,12 +416,11 @@ export class OpenLeafEditor extends HTMLElement {
204
416
  plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
205
417
  }),
206
418
  editable: () => !this.hasAttribute('readonly'),
207
- attributes: {
208
- role: 'textbox',
209
- 'aria-multiline': 'true',
210
- 'aria-label': this.getAttribute('aria-label') ?? 'Rich text editor',
211
- 'aria-describedby': hintId,
212
- },
419
+ // A function, not a literal. The literal was evaluated once at
420
+ // construction, so `readonly` added later never reached the region and a
421
+ // label changed later never reached it either -- both of which are the
422
+ // ordinary case, not an edge one.
423
+ attributes: () => this.#regionAttributes(),
213
424
  // Normalize before ProseMirror parses. Word and Google Docs express
214
425
  // structure as proprietary CSS, so without this a pasted list arrives as
215
426
  // a wall of paragraphs with stray bullet characters in the text.
@@ -222,6 +433,9 @@ export class OpenLeafEditor extends HTMLElement {
222
433
  const view = this.#view;
223
434
  if (!view)
224
435
  return;
436
+ // Named so the change event's `value` getter, which has its own `this`,
437
+ // can reach the element.
438
+ const host = this;
225
439
  view.updateState(view.state.apply(tr));
226
440
  // Persistence comes FIRST, and deliberately so. The document is already
227
441
  // committed by the line above; nothing about writing it out should
@@ -231,22 +445,43 @@ export class OpenLeafEditor extends HTMLElement {
231
445
  // ran again for the rest of the session -- an autosave listening here
232
446
  // would stop silently and the author would lose work.
233
447
  if (tr.docChanged) {
234
- this.#syncToTextarea();
235
- this.dispatchEvent(new CustomEvent('openleaf:change', { bubbles: true }));
448
+ this.#docTouched = true;
449
+ // Noted, not serialized. The textarea is written at submit, at
450
+ // teardown, and on a short trailing timer -- see FormBridge. Writing
451
+ // it here re-serialized the whole document on every keystroke, which
452
+ // was the single largest per-keystroke cost in the editor and was
453
+ // read by nothing until the form was posted.
454
+ this.#formBridge.markDirty();
455
+ this.#emitChange();
236
456
  }
237
457
  // Passing the transaction lets the toolbar tell a formatting change from
238
458
  // a cursor move, which is what keeps its announcements useful instead of
239
459
  // chatty. Guarded because everything it calls may be third-party code.
240
460
  try {
241
461
  this.#toolbar?.update(view.state, tr);
462
+ this.#toolbar2?.update(view.state, tr);
463
+ this.#floating?.update(view.state);
242
464
  }
243
465
  catch (error) {
244
466
  console.error('@openleaf-editor/element: toolbar update failed', error);
245
467
  }
246
468
  },
247
469
  });
248
- this.#toolbar?.mount(this.#view);
470
+ // Live from here on. Set as soon as there is something to tear down rather
471
+ // than at the end of the build: anything below this line may throw --
472
+ // mounting third-party chrome, serializing a document that contains a
473
+ // plugin's node type -- and a view that teardown refuses to touch is a
474
+ // permanent leak with a destroyed editor's listeners still attached.
475
+ this.#built = true;
476
+ this.#boundDoc = this.ownerDocument;
477
+ this.#mountChrome();
478
+ this.#mountInline();
479
+ this.#mountAutoresize();
480
+ void this.#mountContentCss();
249
481
  this.addEventListener(SOURCE_TOGGLE_EVENT, this.#onToggleSource);
482
+ this.addEventListener(FULLSCREEN_TOGGLE_EVENT, this.#onToggleFullscreen);
483
+ this.ownerDocument.addEventListener('fullscreenchange', this.#onFullscreenChange);
484
+ this.addEventListener(VISUAL_AIDS_TOGGLE_EVENT, this.#onToggleVisualAids);
250
485
  if (nestedTextarea) {
251
486
  nestedTextarea.hidden = true;
252
487
  this.appendChild(nestedTextarea);
@@ -276,53 +511,389 @@ export class OpenLeafEditor extends HTMLElement {
276
511
  ],
277
512
  }));
278
513
  this.#toolbar?.update(view.state);
514
+ this.#toolbar2?.update(view.state);
515
+ this.#floating?.update(view.state);
279
516
  });
280
517
  // Belt and braces: `submit` covers ordinary posts, `formdata` covers
281
518
  // fetch-based submissions built from a FormData snapshot.
282
519
  // Prefer the bound textarea's form: the documented `for` binding allows
283
520
  // the editor to live outside the <form>, next to a hidden textarea inside it.
284
- this.#form = this.#textarea?.form ?? this.closest('form');
285
- this.#form?.addEventListener('submit', this.#onSubmit);
286
- this.#form?.addEventListener('formdata', this.#onFormData);
287
- this.#form?.addEventListener('reset', this.#onReset);
288
- this.#syncToTextarea();
521
+ this.#formBridge.attach();
522
+ this.#formBridge.sync();
289
523
  }
524
+ /**
525
+ * Tear down -- but only once the element is really gone.
526
+ *
527
+ * Moving a node fires disconnect and then connect SYNCHRONOUSLY, so a guard
528
+ * in `connectedCallback` can never help: by the time it runs the view has
529
+ * already been destroyed. Deferring the decision by one microtask makes a
530
+ * move a no-op, which is what keeps undo history, selection and every
531
+ * plugin's state alive across a keyed-list reorder, an `insertBefore`
532
+ * shuffle or a drag-to-reorder.
533
+ *
534
+ * The limit is worth being precise about, because it is not "unmounting is
535
+ * safe now": this only covers a move completed within one task. Anything that
536
+ * parks the element in a detached container across ticks -- Vue's
537
+ * `<KeepAlive>` does exactly that -- is a real removal and tears down, which
538
+ * is why the rebuild path has to stay correct rather than merely unreachable.
539
+ */
290
540
  disconnectedCallback() {
291
541
  // Persist whatever is in the source box before tearing it down, so a
292
- // framework that moves the element does not drop unsaved HTML.
293
- this.#syncToTextarea();
294
- this.#teardownSource({ apply: false });
295
- this.#form?.removeEventListener('submit', this.#onSubmit);
296
- this.#form?.removeEventListener('formdata', this.#onFormData);
297
- this.#form?.removeEventListener('reset', this.#onReset);
298
- this.removeEventListener(SOURCE_TOGGLE_EVENT, this.#onToggleSource);
542
+ // framework that moves the element does not drop unsaved HTML. This part
543
+ // stays synchronous: the value has to be in the textarea even if the
544
+ // element is removed on the way into a form submission.
545
+ this.#formBridge.sync();
546
+ // Snapshot the document HERE rather than in the microtask below. A
547
+ // reconnection rebuilds from this instead of from the chrome left in the
548
+ // subtree -- and serializing needs a live document, which is not
549
+ // guaranteed by the time a deferred callback runs (a closing page, or a
550
+ // test environment being torn down). Guarded on `#built` so a disconnect
551
+ // before the first build cannot record an empty document over the
552
+ // element's real markup.
553
+ if (this.#built)
554
+ this.#initialHtml = this.value;
555
+ queueMicrotask(() => {
556
+ if (this.isConnected)
557
+ return;
558
+ this.#teardown();
559
+ });
560
+ }
561
+ /** Idempotent: two queued teardowns, or a teardown after one, do nothing. */
562
+ /**
563
+ * Build the menubar, toolbars and hint around the canvas.
564
+ *
565
+ * Split out of `#build` so `#rerenderChrome` can run exactly the same code
566
+ * on an attribute change, instead of a second copy that drifts from it.
567
+ */
568
+ #buildChrome() {
569
+ const contentHost = this.#contentHost;
570
+ if (!contentHost)
571
+ return;
572
+ const formats = parseFormatList(this.getAttribute('formats'));
573
+ const overflow = this.hasAttribute('toolbar-overflow');
574
+ const layout = this.getAttribute('toolbar');
575
+ const wantsToolbar = layout !== 'none';
576
+ const menubarAttr = this.getAttribute('menubar');
577
+ const wantsMenubar = menubarAttr !== null && menubarAttr !== 'none';
578
+ if (wantsMenubar) {
579
+ // The attribute is a list, not a flag: `menubar="edit help"` asks for those
580
+ // two menus in that order. An unrecognised list leaves no menubar rather
581
+ // than an empty one with nothing in it.
582
+ const menus = selectMenus(menubarAttr);
583
+ if (menus.length > 0) {
584
+ this.#menubar = new MenuBar(this, this.ownerDocument, menus, this.getAttribute('lang'));
585
+ this.appendChild(this.#menubar.el);
586
+ }
587
+ }
588
+ if (wantsToolbar) {
589
+ this.#toolbar = new Toolbar(this, this.ownerDocument, {
590
+ ...(layout ? { layout } : {}),
591
+ overflow,
592
+ formats,
593
+ locale: this.getAttribute('lang'),
594
+ });
595
+ this.appendChild(this.#toolbar.el);
596
+ }
597
+ const toolbar2 = this.getAttribute('toolbar2');
598
+ if (toolbar2 && toolbar2 !== 'none') {
599
+ this.#toolbar2 = new Toolbar(this, this.ownerDocument, {
600
+ layout: toolbar2,
601
+ label: 'More formatting',
602
+ overflow,
603
+ formats,
604
+ locale: this.getAttribute('lang'),
605
+ });
606
+ this.appendChild(this.#toolbar2.el);
607
+ }
608
+ // Re-parented, never replaced: the view lives on this element, so the
609
+ // document, selection and undo history survive a chrome rebuild.
610
+ this.appendChild(contentHost);
611
+ // The Alt+F10 hint lives in a hidden element referenced by
612
+ // aria-describedby. Screen reader users cannot guess the shortcut, and
613
+ // discoverability comes from telling them rather than from choosing a
614
+ // guessable key.
615
+ //
616
+ // It no longer repeats the region's own name. A description is read
617
+ // immediately after the name, so "Rich text editor" followed by "Rich text
618
+ // editor. Press Alt plus F10..." made NVDA say the phrase twice -- and with
619
+ // no toolbar the whole description said nothing the role had not already.
620
+ if (wantsToolbar) {
621
+ const hint = this.ownerDocument.createElement('span');
622
+ hint.id = `ol-hint-${(hintCounter += 1)}`;
623
+ hint.className = 'ol-live';
624
+ hint.textContent = this.#localised('Press Alt plus F10 for the formatting toolbar.');
625
+ this.appendChild(hint);
626
+ this.#hint = hint;
627
+ }
628
+ // Unconditionally, and not from whichever bar happens to exist. A layout of
629
+ // `toolbar="none" toolbar2="bold italic"` used to mount no region at all, so
630
+ // Ctrl+B was silent -- the failure the whole announcement design exists to
631
+ // prevent. One region per editor, shared by every bar on it.
632
+ liveRegion(this);
633
+ }
634
+ /** Give the freshly built chrome the view, and the state it cannot derive. */
635
+ #mountChrome() {
636
+ const view = this.#view;
637
+ if (!view)
638
+ return;
639
+ this.#toolbar?.mount(view);
640
+ this.#toolbar2?.mount(view);
641
+ this.#menubar?.mount(view);
642
+ this.#mountFloating();
643
+ this.#mountContextMenu();
644
+ const aids = { active: this.#visualAids, enabled: this.#visualAidsAvailable };
645
+ this.#toolbar?.setItemState('visualAids', aids);
646
+ this.#toolbar2?.setItemState('visualAids', aids);
647
+ this.#toolbar?.setItemState('source', { active: this.#sourceMode });
648
+ this.#toolbar2?.setItemState('source', { active: this.#sourceMode });
649
+ this.#toolbar?.setItemState('fullscreen', { active: this.#fullscreen });
650
+ this.#toolbar2?.setItemState('fullscreen', { active: this.#fullscreen });
651
+ }
652
+ /**
653
+ * Tear the chrome down, leaving the canvas and the view untouched.
654
+ *
655
+ * The announcement region is deliberately NOT removed here. It belongs to the
656
+ * host and is shared by every bar on it, so a chrome rebuild that took it away
657
+ * would silence the bars it did not rebuild. It goes in `#teardown`, with the
658
+ * editor.
659
+ */
660
+ #destroyChrome() {
661
+ this.#unmountContextMenuListeners();
662
+ this.removeEventListener('focusin', this.#onInlineFocus);
663
+ this.removeEventListener('focusout', this.#onInlineBlur);
664
+ this.#resizeObserver?.disconnect();
665
+ this.#resizeObserver = null;
666
+ if (this.#resizeFrame)
667
+ cancelAnimationFrame(this.#resizeFrame);
668
+ this.#resizeFrame = 0;
299
669
  this.#unwatchPlugins?.();
300
670
  this.#unwatchSchema?.();
671
+ this.#floating?.destroy();
672
+ this.#floating = null;
673
+ this.#contextMenu?.destroy();
674
+ this.#contextMenu = null;
675
+ this.#menubar?.destroy();
676
+ this.#menubar = null;
677
+ this.#toolbar2?.destroy();
678
+ this.#toolbar2 = null;
301
679
  this.#toolbar?.destroy();
302
680
  this.#toolbar = null;
681
+ this.#hint?.remove();
682
+ this.#hint = null;
683
+ }
684
+ /**
685
+ * Rebuild the chrome for a changed attribute, preserving everything else.
686
+ *
687
+ * The view, the document, the selection and the undo history all survive,
688
+ * because none of them is touched: the canvas element is re-parented rather
689
+ * than replaced. This is what makes `toolbar`, `menubar`, `formats` and the
690
+ * rest safe to treat as reactive props, which all three wrappers already
691
+ * did -- to no effect, before this existed.
692
+ */
693
+ #rerenderChrome() {
694
+ if (!this.#view || !this.#contentHost)
695
+ return;
696
+ this.#destroyChrome();
697
+ this.#buildChrome();
698
+ this.#mountChrome();
699
+ this.#toolbar?.update(this.#view.state);
700
+ this.#toolbar2?.update(this.#view.state);
701
+ this.#view.setProps({ attributes: () => this.#regionAttributes() });
702
+ }
703
+ /**
704
+ * Announce that the document changed.
705
+ *
706
+ * `value` is a getter, not a string. A listener that only wants to know
707
+ * *that* the document changed -- an autosave marking a draft dirty, a
708
+ * "unsaved changes" flag -- does not pay to find out what it changed to, and
709
+ * serializing a large document was the single largest per-keystroke cost in
710
+ * the editor. A listener that does want it reads `detail.value` and gets the
711
+ * cached string `get value` already holds, so the wrappers pay once.
712
+ */
713
+ #emitChange() {
714
+ const host = this;
715
+ this.dispatchEvent(new CustomEvent('openleaf:change', {
716
+ bubbles: true,
717
+ composed: true,
718
+ detail: {
719
+ get value() {
720
+ return host.value;
721
+ },
722
+ },
723
+ }));
724
+ }
725
+ #applyInline() {
726
+ const wanted = this.hasAttribute('inline');
727
+ this.classList.toggle('ol-inline', wanted);
728
+ // Both are idempotent: adding a listener twice with the same function
729
+ // reference is a no-op, and removing one that is not attached is too.
730
+ this.removeEventListener('focusin', this.#onInlineFocus);
731
+ this.removeEventListener('focusout', this.#onInlineBlur);
732
+ if (wanted)
733
+ this.#mountInline();
734
+ else
735
+ this.classList.remove('ol-inline-active');
736
+ }
737
+ /** `autoresize` after mount: start or stop growing the canvas. */
738
+ #applyAutoresize() {
739
+ const wanted = this.hasAttribute('autoresize');
740
+ this.classList.toggle('ol-autoresize', wanted);
741
+ this.#resizeObserver?.disconnect();
742
+ this.#resizeObserver = null;
743
+ if (wanted) {
744
+ this.#mountAutoresize();
745
+ return;
746
+ }
747
+ // Leaving the measured pixel height behind would freeze the canvas at
748
+ // whatever size it happened to be when the attribute was removed.
749
+ const pm = this.#contentHost?.querySelector('.ProseMirror');
750
+ if (pm)
751
+ pm.style.height = '';
752
+ }
753
+ /** Rebuild the plugin list in place, keeping the document and history. */
754
+ #reconfigurePlugins() {
755
+ const view = this.#view;
756
+ if (!view)
757
+ return;
758
+ this.#basePlugins = this.#basePlugins.filter((plugin) => plugin !== this.#autolink);
759
+ this.#autolink = null;
760
+ if (this.getAttribute('autolink') !== 'false') {
761
+ this.#autolink = autolinkPlugin();
762
+ this.#basePlugins.push(this.#autolink);
763
+ }
764
+ view.updateState(view.state.reconfigure({
765
+ plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
766
+ }));
767
+ }
768
+ #teardown() {
769
+ if (!this.#built)
770
+ return;
771
+ this.#built = false;
772
+ // The document the listeners went ON, which is not necessarily the one this
773
+ // element belongs to now -- see #boundDoc.
774
+ const doc = this.#boundDoc ?? this.ownerDocument;
775
+ this.#boundDoc = null;
776
+ this.#teardownSource({ apply: false });
777
+ this.#formBridge.detach();
778
+ this.removeEventListener(SOURCE_TOGGLE_EVENT, this.#onToggleSource);
779
+ this.removeEventListener(FULLSCREEN_TOGGLE_EVENT, this.#onToggleFullscreen);
780
+ doc.removeEventListener('fullscreenchange', this.#onFullscreenChange);
781
+ this.removeEventListener(VISUAL_AIDS_TOGGLE_EVENT, this.#onToggleVisualAids);
782
+ // The context menu's three listeners are not removed here: #destroyChrome
783
+ // below owns all of them, from the document it recorded at mount. Removing
784
+ // them in two places is how the document-level one came to be removed from
785
+ // `#boundDoc` in one and `ownerDocument` in the other, and leaked whenever
786
+ // those differed.
787
+ this.removeEventListener('focusin', this.#onInlineFocus);
788
+ this.removeEventListener('focusout', this.#onInlineBlur);
789
+ this.#resizeObserver?.disconnect();
790
+ this.#resizeObserver = null;
791
+ this.#unwatchPlugins?.();
792
+ this.#unwatchSchema?.();
793
+ this.#destroyChrome();
303
794
  this.#view?.destroy();
304
795
  this.#view = null;
796
+ // Everything this element appended goes with it. Chrome left behind is not
797
+ // just a leak: `#build()` reads `this.innerHTML` as a last resort, so
798
+ // leftovers become the next document.
799
+ this.#contentHost?.remove();
800
+ this.#contentHost = null;
801
+ this.#hint?.remove();
802
+ this.#hint = null;
803
+ // The shared announcement region is the host's, so no individual toolbar
804
+ // may remove it -- but it must not survive the editor either, for the same
805
+ // reason the chrome above does not: #build() falls back to this.innerHTML.
806
+ disposeLiveRegion(this);
807
+ // Presentation state goes too. `ol-fullscreen` is fixed-position, inset 0,
808
+ // at the top of the stacking order, and nothing re-applies it on a rebuild
809
+ // -- so an editor removed while fullscreen used to come back as an opaque
810
+ // full-viewport overlay whose toolbar button showed inactive.
811
+ this.#fullscreen = false;
812
+ this.#nativeFullscreen = false;
813
+ this.classList.remove('ol-fullscreen', 'ol-inline-active', 'ol-editor', 'ol-inline', 'ol-autoresize', 'ol-visual-aids');
305
814
  }
306
815
  /** Current document as an HTML string. */
307
816
  get value() {
308
817
  if (this.#sourceMode && this.#sourceArea)
309
818
  return this.#sourceArea.value;
310
- if (!this.#view)
311
- return this.#textarea?.value ?? '';
312
- return serializeHtml(this.#view.state.doc);
819
+ // After a teardown there is no view, but the document is not gone: it was
820
+ // snapshotted on disconnect. Without `#initialHtml` here an unbound editor
821
+ // reports an empty document the moment it is unmounted, which is the same
822
+ // content loss this fix exists to stop -- just read back rather than
823
+ // rebuilt.
824
+ if (!this.#view) {
825
+ return this.#pendingValue ?? this.#formBridge.textarea?.value ?? this.#initialHtml ?? '';
826
+ }
827
+ // Cached against the document it was produced from. `value` is read by the
828
+ // change event's detail, by the form bridge and by the host, often several
829
+ // times for one keystroke, and serializing a large document is the most
830
+ // expensive thing this class does.
831
+ const doc = this.#view.state.doc;
832
+ const cached = this.#serialized;
833
+ if (cached && cached.doc === doc)
834
+ return cached.html;
835
+ const html = serializeHtml(doc);
836
+ this.#serialized = { doc, html };
837
+ return html;
313
838
  }
314
839
  set value(html) {
315
840
  if (this.#sourceMode && this.#sourceArea) {
316
841
  this.#sourceArea.value = html;
317
- this.#syncToTextarea();
842
+ this.#formBridge.sync();
318
843
  return;
319
844
  }
320
845
  if (!this.#view) {
321
- if (this.#textarea)
322
- this.#textarea.value = html;
846
+ // No view to receive it: either the build has not happened yet (an
847
+ // assignment before upgrade, or while waiting for DOMContentLoaded) or it
848
+ // has been torn down. Hold it for the next build rather than dropping it.
849
+ this.#pendingValue = html;
850
+ if (this.#formBridge.textarea)
851
+ this.#formBridge.textarea.value = html;
323
852
  return;
324
853
  }
325
- this.#replaceDocument(html);
854
+ // `onlyIfChanged` makes assignment idempotent: `el.value = el.value` is a
855
+ // no-op instead of an undo step, a change event and a collapsed selection.
856
+ //
857
+ // The mount-then-fill exception is narrow on purpose. Every wrapper renders
858
+ // a bare element and pushes the server's HTML in afterwards, so that fill
859
+ // lands on an untouched, empty document -- and making it undoable is what
860
+ // let an author's FIRST Ctrl-Z wipe everything. An assignment onto a
861
+ // document that already HAS content is a different thing entirely: it is a
862
+ // "load template" or "reset draft" button replacing the author's work, and
863
+ // that must stay undoable.
864
+ this.#replaceDocument(html, {
865
+ onlyIfChanged: true,
866
+ addToHistory: this.#docTouched || !this.#isEmptyDocument(),
867
+ });
868
+ // Written through to the textarea now rather than on the trailing timer.
869
+ // The debounce exists for the keystroke path; a programmatic assignment is
870
+ // rare, and a host that sets `.value` and then reads the bound textarea in
871
+ // the same tick -- which every wrapper does on mount -- must not see the
872
+ // document it just replaced.
873
+ this.#formBridge.sync();
874
+ }
875
+ /** An untouched editor holds one empty text block -- what a wrapper mounts. */
876
+ #isEmptyDocument() {
877
+ const doc = this.#view?.state.doc;
878
+ if (!doc)
879
+ return true;
880
+ const first = doc.firstChild;
881
+ return doc.childCount <= 1 && (!first || (first.isTextblock && first.content.size === 0));
882
+ }
883
+ /**
884
+ * Uploader for this editor alone, overriding `registerImageUploader`.
885
+ *
886
+ * An accessor rather than a class field, and that is load-bearing: a field
887
+ * initializer runs when the element upgrades, so it would overwrite an
888
+ * uploader assigned before the definition loaded -- exactly the case
889
+ * `#upgradeProperty` exists to rescue, defeated by the declaration meant to
890
+ * make the property visible.
891
+ */
892
+ get imageUploader() {
893
+ return this.#imageUploader;
894
+ }
895
+ set imageUploader(uploader) {
896
+ this.#imageUploader = uploader;
326
897
  }
327
898
  /** Escape hatch for plugins and integrations that need the real view. */
328
899
  get view() {
@@ -333,12 +904,59 @@ export class OpenLeafEditor extends HTMLElement {
333
904
  return this.#schema;
334
905
  }
335
906
  /** The toolbar, for plugins pushing external state via setItemState. */
336
- get toolbar() {
907
+ get toolbarInstance() {
337
908
  return this.#toolbar;
338
909
  }
910
+ /** The `toolbar` attribute. Assigning reflects, as HTML properties should. */
911
+ get toolbar() {
912
+ return this.getAttribute('toolbar');
913
+ }
914
+ set toolbar(layout) {
915
+ this.#reflect('toolbar', layout);
916
+ }
917
+ /** The `toolbar2` attribute. */
918
+ get toolbar2() {
919
+ return this.getAttribute('toolbar2');
920
+ }
921
+ set toolbar2(layout) {
922
+ this.#reflect('toolbar2', layout);
923
+ }
924
+ /** The `menubar` attribute. */
925
+ get menubar() {
926
+ return this.getAttribute('menubar');
927
+ }
928
+ set menubar(menus) {
929
+ this.#reflect('menubar', menus);
930
+ }
931
+ /** The `formats` attribute. */
932
+ get formats() {
933
+ return this.getAttribute('formats');
934
+ }
935
+ set formats(spec) {
936
+ this.#reflect('formats', spec);
937
+ }
938
+ /** The `readonly` attribute, as the boolean every framework binds it as. */
939
+ get readOnly() {
940
+ return this.hasAttribute('readonly');
941
+ }
942
+ set readOnly(value) {
943
+ this.#reflect('readonly', value ? '' : null);
944
+ }
945
+ #reflect(name, value) {
946
+ if (value === null || value === undefined)
947
+ this.removeAttribute(name);
948
+ else
949
+ this.setAttribute(name, value);
950
+ }
951
+ /** Whether the HTML source view is open. Assigning toggles it. */
339
952
  get sourceMode() {
340
953
  return this.#sourceMode;
341
954
  }
955
+ set sourceMode(open) {
956
+ if (open === this.#sourceMode)
957
+ return;
958
+ this.#onToggleSource();
959
+ }
342
960
  /* -------------------------------------------------------------- *
343
961
  * Source view
344
962
  * -------------------------------------------------------------- */
@@ -357,7 +975,7 @@ export class OpenLeafEditor extends HTMLElement {
357
975
  if (!this.#sourceMode) {
358
976
  const area = this.ownerDocument.createElement('textarea');
359
977
  area.className = 'ol-source';
360
- area.setAttribute('aria-label', 'HTML source');
978
+ area.setAttribute('aria-label', this.#localised('HTML source'));
361
979
  area.spellcheck = false;
362
980
  area.readOnly = this.hasAttribute('readonly');
363
981
  area.value = serializeHtml(view.state.doc);
@@ -366,6 +984,14 @@ export class OpenLeafEditor extends HTMLElement {
366
984
  this.#sourceArea = area;
367
985
  this.#sourceMode = true;
368
986
  this.#toolbar?.setItemState('source', { active: true });
987
+ this.#toolbar2?.setItemState('source', { active: true });
988
+ // Every other control goes unavailable: a formatting command here runs
989
+ // against the hidden document, which `#teardownSource` then reparses over
990
+ // the top of. The mode change is announced because moving focus into a
991
+ // textarea full of angle brackets, with no explanation, is disorienting.
992
+ this.#toolbar?.setSourceMode(true);
993
+ this.#toolbar2?.setSourceMode(true);
994
+ announce(this, this.#localised('HTML source view'));
369
995
  // Announced before focus so an enhancer can wrap the textarea while it is
370
996
  // still inert; focusing first would move the caret and then move the
371
997
  // element out from under it.
@@ -373,13 +999,38 @@ export class OpenLeafEditor extends HTMLElement {
373
999
  area.focus();
374
1000
  return;
375
1001
  }
376
- // `apply: false` under readonly: the source box is read-only there, so
377
- // there is nothing to write back and parsing it would be a no-op that
378
- // still lands a transaction.
379
- this.#teardownSource({ apply: !this.hasAttribute('readonly') });
380
- contentHost.hidden = false;
381
- this.#toolbar?.setItemState('source', { active: false });
382
- view.focus();
1002
+ /*
1003
+ * `finally`, because a half-completed teardown is unrecoverable.
1004
+ *
1005
+ * Applying the source parses it and renders the result, and that used to be
1006
+ * able to throw -- `<p ="v">` carried an attribute name `setAttribute`
1007
+ * refuses. The teardown had already removed the source box and flipped
1008
+ * `#sourceMode`, so the throw escaped before the content host was unhidden:
1009
+ * a blank rectangle, `sourceMode` reporting false while the Source button
1010
+ * still read pressed, nothing clickable, and toggling back throwing again.
1011
+ * A page reload was the only way out and the author's work was gone.
1012
+ *
1013
+ * The carried-attribute hole is fixed at its source, in the schema. This is
1014
+ * the other half: whatever a future failure in applying source HTML turns
1015
+ * out to be, it leaves a visible, usable editor. The error still propagates,
1016
+ * because the host should hear about it.
1017
+ *
1018
+ * `apply: false` under readonly: the source box is read-only there, so there
1019
+ * is nothing to write back and parsing it would be a no-op that still lands
1020
+ * a transaction.
1021
+ */
1022
+ try {
1023
+ this.#teardownSource({ apply: !this.hasAttribute('readonly') });
1024
+ }
1025
+ finally {
1026
+ contentHost.hidden = false;
1027
+ this.#toolbar?.setItemState('source', { active: false });
1028
+ this.#toolbar2?.setItemState('source', { active: false });
1029
+ this.#toolbar?.setSourceMode(false);
1030
+ this.#toolbar2?.setSourceMode(false);
1031
+ announce(this, this.#localised('Rich text view'));
1032
+ view.focus();
1033
+ }
383
1034
  };
384
1035
  /**
385
1036
  * Leave source mode.
@@ -413,7 +1064,8 @@ export class OpenLeafEditor extends HTMLElement {
413
1064
  * Replace the document with a transaction, so undo and change events survive.
414
1065
  *
415
1066
  * `onlyIfChanged` skips the dispatch when the HTML parses to the document
416
- * already on screen.
1067
+ * already on screen. `addToHistory: false` keeps the replacement out of the
1068
+ * undo stack, for the mount-then-fill sequence every wrapper performs.
417
1069
  */
418
1070
  #replaceDocument(html, options) {
419
1071
  const view = this.#view;
@@ -422,36 +1074,19 @@ export class OpenLeafEditor extends HTMLElement {
422
1074
  const next = parseHtml(html, { schema: this.#schema });
423
1075
  if (options?.onlyIfChanged && next.eq(view.state.doc))
424
1076
  return;
425
- view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, next.content));
1077
+ const tr = view.state.tr.replaceWith(0, view.state.doc.content.size, next.content);
1078
+ // Replacing the whole document maps every old position onto the boundary,
1079
+ // so the caret would jump to the top on any programmatic assignment. Put it
1080
+ // back where the author left it, clamped to the new document.
1081
+ const at = Math.min(view.state.selection.from, tr.doc.content.size);
1082
+ tr.setSelection(TextSelection.near(tr.doc.resolve(at)));
1083
+ if (options?.addToHistory === false)
1084
+ tr.setMeta('addToHistory', false);
1085
+ view.dispatch(tr);
426
1086
  }
427
1087
  /* -------------------------------------------------------------- *
428
1088
  * Textarea binding
429
1089
  * -------------------------------------------------------------- */
430
- #findTextarea() {
431
- const id = this.getAttribute('for');
432
- if (id) {
433
- const el = this.getRootNode().getElementById?.(id);
434
- if (el instanceof HTMLTextAreaElement)
435
- return el;
436
- // A `for` that resolves to nothing is a silent data-loss bug waiting to
437
- // happen, so say so loudly rather than falling back.
438
- console.error(`<openleaf-editor for="${id}">: no <textarea id="${id}"> found. ` +
439
- 'Content will not be submitted with the form.');
440
- return null;
441
- }
442
- return this.querySelector('textarea');
443
- }
444
- #syncToTextarea() {
445
- if (!this.#textarea)
446
- return;
447
- if (this.#sourceMode && this.#sourceArea) {
448
- this.#textarea.value = this.#sourceArea.value;
449
- return;
450
- }
451
- if (!this.#view)
452
- return;
453
- this.#textarea.value = serializeHtml(this.#view.state.doc);
454
- }
455
1090
  /**
456
1091
  * Claim a drop or paste that carries image files.
457
1092
  *
@@ -494,7 +1129,9 @@ export class OpenLeafEditor extends HTMLElement {
494
1129
  * a property worth more: every image OpenLeaf inserts has been described or
495
1130
  * explicitly marked decorative. Uploading in parallel would mean either
496
1131
  * stacking modal dialogs or inserting undescribed images and asking later --
497
- * and "later" has no UI, because there is no image-editing dialog yet.
1132
+ * and "later" used to have no UI. The image toolbar item now edits a selected
1133
+ * image, including its alt text; a drop still describes each file before
1134
+ * insert because a drop is not an edit of whatever happens to be selected.
498
1135
  */
499
1136
  async #uploadImages(view, files) {
500
1137
  const uploader = imageUploaderFor(this);
@@ -504,6 +1141,7 @@ export class OpenLeafEditor extends HTMLElement {
504
1141
  const result = await promptForImage(this.ownerDocument, {
505
1142
  file,
506
1143
  upload: (chosen) => runUploader(uploader, chosen, this),
1144
+ host: this,
507
1145
  });
508
1146
  // A cancelled description skips this file and moves to the next, rather
509
1147
  // than abandoning the rest of a multi-file drop.
@@ -512,12 +1150,78 @@ export class OpenLeafEditor extends HTMLElement {
512
1150
  insertImage({
513
1151
  src: result.src,
514
1152
  alt: result.alt,
1153
+ title: result.title,
515
1154
  width: result.width,
516
1155
  height: result.height,
1156
+ align: result.align,
1157
+ className: result.className,
1158
+ ...(result.caption ? { caption: result.caption } : {}),
517
1159
  })(view.state, view.dispatch, view);
518
1160
  }
519
1161
  view.focus();
520
1162
  }
1163
+ /* -------------------------------------------------------------- *
1164
+ * Accessible semantics of the editable region
1165
+ * -------------------------------------------------------------- */
1166
+ /**
1167
+ * The ARIA attributes ProseMirror puts on the editable region.
1168
+ *
1169
+ * Recomputed on every update rather than frozen at construction, which is
1170
+ * what makes `readonly` and a changing name observable at all.
1171
+ */
1172
+ #regionAttributes() {
1173
+ const attributes = {
1174
+ role: 'textbox',
1175
+ 'aria-multiline': 'true',
1176
+ // Always written, never omitted when false. `contenteditable="false"` is
1177
+ // not a signal any screen reader reports, so without this a read-only
1178
+ // editor announced "edit multiline", the author typed, and nothing
1179
+ // happened.
1180
+ 'aria-readonly': this.hasAttribute('readonly') ? 'true' : 'false',
1181
+ 'aria-label': this.#regionName(),
1182
+ };
1183
+ if (this.#hint)
1184
+ attributes['aria-describedby'] = this.#hint.id;
1185
+ return attributes;
1186
+ }
1187
+ /**
1188
+ * What this editor is called.
1189
+ *
1190
+ * The documented integration is `<label for="body">` beside
1191
+ * `<textarea id="body">`, and that label names the TEXTAREA -- the editable
1192
+ * region is a different element entirely, so it inherited nothing. Every
1193
+ * integrator who followed the README and did not also duplicate the text as
1194
+ * `aria-label` shipped an editor called "Rich text editor", and two of them on
1195
+ * one page were indistinguishable.
1196
+ */
1197
+ #regionName() {
1198
+ const explicit = this.getAttribute('aria-label')?.trim();
1199
+ if (explicit)
1200
+ return explicit;
1201
+ const inherited = this.#formBridge.textarea?.labels?.[0]?.textContent?.trim();
1202
+ if (inherited)
1203
+ return inherited;
1204
+ return this.#localised('Rich text editor');
1205
+ }
1206
+ /**
1207
+ * Give the host a role when, and only when, it carries `aria-label`.
1208
+ *
1209
+ * ARIA prohibits a label on a `generic` element, which is what
1210
+ * `<openleaf-editor>` is with no role of its own -- axe reports it as
1211
+ * `aria-prohibited-attr`. Adding the role unconditionally would instead have
1212
+ * every editor announce its name twice, once for the group and once for the
1213
+ * region inside it, so it is added exactly where the violation is.
1214
+ */
1215
+ #applyHostRole() {
1216
+ if (this.getAttribute('aria-label')?.trim())
1217
+ this.setAttribute('role', 'group');
1218
+ else if (this.getAttribute('role') === 'group')
1219
+ this.removeAttribute('role');
1220
+ }
1221
+ /** A UI string in this editor's own `lang`, not the document-wide locale. */
1222
+ #localised(source) {
1223
+ return withLocale(this.getAttribute('lang'), () => t(source));
1224
+ }
521
1225
  #applyReadonly() {
522
1226
  // `editable()` already reads the attribute; the view has to be told to
523
1227
  // re-evaluate it. Without this, adding readonly after mount leaves
@@ -525,16 +1229,358 @@ export class OpenLeafEditor extends HTMLElement {
525
1229
  this.#view?.setProps({});
526
1230
  if (this.#sourceArea)
527
1231
  this.#sourceArea.readOnly = this.hasAttribute('readonly');
528
- if (this.#view)
1232
+ if (this.#view) {
529
1233
  this.#toolbar?.update(this.#view.state);
1234
+ this.#toolbar2?.update(this.#view.state);
1235
+ }
1236
+ }
1237
+ /**
1238
+ * Relabel this editor's chrome for its own `lang`.
1239
+ *
1240
+ * Deliberately not `setUiLocale`, which is the document-wide default: two
1241
+ * editors with different `lang` values on one page both ended up in whichever
1242
+ * built last, because every subscribed toolbar re-rendered on the change.
1243
+ */
1244
+ #applyLocale() {
1245
+ const lang = this.getAttribute('lang');
1246
+ this.#toolbar?.setLocale(lang);
1247
+ this.#toolbar2?.setLocale(lang);
1248
+ this.#floating?.setLocale(lang);
1249
+ if (this.#hint) {
1250
+ this.#hint.textContent = this.#localised('Press Alt plus F10 for the formatting toolbar.');
1251
+ }
1252
+ if (this.#sourceArea) {
1253
+ this.#sourceArea.setAttribute('aria-label', this.#localised('HTML source'));
1254
+ }
1255
+ // The region's own name may be the generic fallback, which is translated.
1256
+ this.#view?.setProps({});
530
1257
  }
531
- #rebindTextarea() {
532
- if (!this.#view)
1258
+ #mountFloating() {
1259
+ const view = this.#view;
1260
+ if (!view)
533
1261
  return;
534
- this.#textarea = this.#findTextarea();
535
- this.#syncToTextarea();
1262
+ const selection = this.getAttribute('selection-toolbar');
1263
+ const insert = this.getAttribute('insert-toolbar');
1264
+ const selectionLayout = selection === null ? null : selection === 'none' ? null : selection || DEFAULT_SELECTION_LAYOUT;
1265
+ const insertLayout = insert === null ? null : insert === 'none' ? null : insert || DEFAULT_INSERT_LAYOUT;
1266
+ if (!selectionLayout && !insertLayout)
1267
+ return;
1268
+ this.#floating = new FloatingToolbars(this, this.ownerDocument, {
1269
+ selectionLayout,
1270
+ insertLayout,
1271
+ locale: this.getAttribute('lang'),
1272
+ });
1273
+ this.#floating.mount(view);
536
1274
  }
1275
+ /**
1276
+ * Every listener `#mountContextMenu` added, removed from where it added it.
1277
+ *
1278
+ * The keydown one used to be dropped only in `#teardown`, which was safe by
1279
+ * accident -- every `#destroyChrome` call site either remounts immediately or
1280
+ * precedes a full teardown, and re-adding the same function reference is a
1281
+ * no-op. Symmetry here is cheaper than that argument.
1282
+ */
1283
+ #unmountContextMenuListeners() {
1284
+ this.removeEventListener('contextmenu', this.#onContextMenu);
1285
+ this.removeEventListener('keydown', this.#onContextKey, true);
1286
+ this.#contextDoc?.removeEventListener('pointerdown', this.#onContextPointer, true);
1287
+ this.#contextDoc = null;
1288
+ }
1289
+ #mountContextMenu() {
1290
+ if (this.getAttribute('contextmenu') === 'none')
1291
+ return;
1292
+ this.#contextMenu = new PopupMenu(this, this.ownerDocument);
1293
+ if (this.#view)
1294
+ this.#contextMenu.attach(this.#view);
1295
+ this.appendChild(this.#contextMenu.el);
1296
+ this.addEventListener('contextmenu', this.#onContextMenu);
1297
+ this.addEventListener('keydown', this.#onContextKey, true);
1298
+ // Recorded, not re-derived at removal time. See #contextDoc.
1299
+ this.#contextDoc = this.ownerDocument;
1300
+ this.#contextDoc.addEventListener('pointerdown', this.#onContextPointer, true);
1301
+ }
1302
+ #onContextPointer = (event) => {
1303
+ const menu = this.#contextMenu;
1304
+ if (!menu?.open)
1305
+ return;
1306
+ if (event.target instanceof Node && menu.el.contains(event.target))
1307
+ return;
1308
+ menu.close();
1309
+ };
1310
+ /**
1311
+ * Open the context menu from the keyboard.
1312
+ *
1313
+ * Shift+F10 and the Menu key were assumed to arrive as a synthesized
1314
+ * `contextmenu` event, and the handler was left to work out the rest. They do
1315
+ * not, reliably: Chromium fires no `contextmenu` for Shift+F10 at all when the
1316
+ * key is delivered to the renderer, so the documented keyboard entry point was
1317
+ * a no-op that no test could see, because no test pressed the key.
1318
+ *
1319
+ * Opening straight from the key is also the only way to get the two things
1320
+ * that follow right -- the node the caret is in, and a position to put the
1321
+ * menu at -- because neither is recoverable from a synthesized mouse event.
1322
+ */
1323
+ #onContextKey = (event) => {
1324
+ if (event.key !== 'ContextMenu' && !(event.key === 'F10' && event.shiftKey))
1325
+ return;
1326
+ // See `#onContextMenu`: the keyboard route to the same edit-only menu.
1327
+ if (this.hasAttribute('readonly'))
1328
+ return;
1329
+ const view = this.#view;
1330
+ if (!view)
1331
+ return;
1332
+ if (!this.#showContext(view, this.#contextItemsAtCaret(view.state), null))
1333
+ return;
1334
+ event.preventDefault();
1335
+ };
1336
+ /** Open a menu, at a point for a pointer or at the caret for a key. */
1337
+ #showContext(view, items, point) {
1338
+ const menu = this.#contextMenu;
1339
+ if (!menu || !items)
1340
+ return false;
1341
+ let x = point?.x ?? 0;
1342
+ let y = point?.y ?? 0;
1343
+ // A synthesized event carries the focused element's corner at best and 0,0
1344
+ // at worst; neither of them is where the caret is.
1345
+ if (!point || x <= 0) {
1346
+ const coords = view.coordsAtPos(view.state.selection.from);
1347
+ x = coords.left;
1348
+ y = coords.bottom;
1349
+ }
1350
+ menu.show(items, x, y, { label: 'Editor menu', onClose: () => view.focus() });
1351
+ return true;
1352
+ }
1353
+ /**
1354
+ * What the menu is about, read from the document rather than the DOM.
1355
+ *
1356
+ * For a pointer the answer is whatever was clicked. For the keyboard it is
1357
+ * emphatically NOT `event.target`: the focused element is the ProseMirror
1358
+ * contenteditable div, and `closest()` walks UP from it, so it never found the
1359
+ * `<a>` or `<img>` the caret was in and the handler returned in silence. The
1360
+ * selection is the only thing that knows where the caret is, and asking the
1361
+ * document is more direct than mapping a position back to a node and walking
1362
+ * the tree from there.
1363
+ */
1364
+ #contextItemsAtCaret(state) {
1365
+ const selection = state.selection;
1366
+ const $from = selection.$from;
1367
+ const link = state.schema.marks['link'];
1368
+ if (link && link.isInSet($from.marks()))
1369
+ return LINK_CONTEXT_ITEMS;
1370
+ const node = selection instanceof NodeSelection ? selection.node : $from.nodeAfter;
1371
+ if (node?.type.name === 'image')
1372
+ return IMAGE_CONTEXT_ITEMS;
1373
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
1374
+ if ($from.node(depth).type.name === 'table')
1375
+ return TABLE_CONTEXT_ITEMS;
1376
+ }
1377
+ return null;
1378
+ }
1379
+ #onContextMenu = (event) => {
1380
+ const view = this.#view;
1381
+ if (!view)
1382
+ return;
1383
+ // Before anything else, and before `preventDefault`: every entry in these
1384
+ // menus is an edit. `invoke` already refuses to run one under `readonly`, so
1385
+ // opening the menu offered a list of items that silently did nothing -- and
1386
+ // worse, opening it at all called `preventDefault()` and took away the
1387
+ // browser's own menu, which is the copy-and-inspect a read-only reader is
1388
+ // left with. The table plugin's menu makes the same check on the same event;
1389
+ // this is the other listener on it.
1390
+ if (this.hasAttribute('readonly'))
1391
+ return;
1392
+ const clicked = event.target;
1393
+ if (!(clicked instanceof Element) || !this.#contentHost?.contains(clicked))
1394
+ return;
1395
+ const items = clicked.closest('a')
1396
+ ? LINK_CONTEXT_ITEMS
1397
+ : clicked.closest('img')
1398
+ ? IMAGE_CONTEXT_ITEMS
1399
+ : clicked.closest('table')
1400
+ ? TABLE_CONTEXT_ITEMS
1401
+ : null;
1402
+ if (this.#showContext(view, items, { x: event.clientX, y: event.clientY })) {
1403
+ event.preventDefault();
1404
+ }
1405
+ };
1406
+ #mountInline() {
1407
+ if (!this.hasAttribute('inline'))
1408
+ return;
1409
+ this.addEventListener('focusin', this.#onInlineFocus);
1410
+ this.addEventListener('focusout', this.#onInlineBlur);
1411
+ }
1412
+ #onInlineFocus = () => {
1413
+ this.classList.add('ol-inline-active');
1414
+ };
1415
+ #onInlineBlur = (event) => {
1416
+ const next = event.relatedTarget;
1417
+ if (next instanceof Node && this.contains(next))
1418
+ return;
1419
+ this.classList.remove('ol-inline-active');
1420
+ };
1421
+ /**
1422
+ * Grow the canvas with the document.
1423
+ *
1424
+ * `height: auto` followed by a `scrollHeight` read is a forced synchronous
1425
+ * layout of the entire content, and it happens inside a `ResizeObserver`
1426
+ * watching the element the write resizes -- so an unbatched version relaid
1427
+ * out the document on every observation, and each write invited the next
1428
+ * observation. Coalescing into one animation frame collapses a burst into a
1429
+ * single relayout, and skipping an unchanged height stops the write that
1430
+ * would re-notify the observer for nothing.
1431
+ */
1432
+ #mountAutoresize() {
1433
+ const host = this.#contentHost;
1434
+ const view = this.#view;
1435
+ if (!host || !view || !this.hasAttribute('autoresize'))
1436
+ return;
1437
+ // Cached: `querySelector` on every observation walks the whole canvas.
1438
+ let pm = null;
1439
+ let last = '';
1440
+ const apply = () => {
1441
+ this.#resizeFrame = 0;
1442
+ if (!pm?.isConnected)
1443
+ pm = host.querySelector('.ProseMirror');
1444
+ if (!pm)
1445
+ return;
1446
+ pm.style.height = 'auto';
1447
+ const height = `${pm.scrollHeight}px`;
1448
+ if (height === last)
1449
+ return;
1450
+ last = height;
1451
+ pm.style.height = height;
1452
+ };
1453
+ apply();
1454
+ if (typeof ResizeObserver === 'undefined')
1455
+ return;
1456
+ this.#resizeObserver = new ResizeObserver(() => {
1457
+ if (this.#resizeFrame)
1458
+ return;
1459
+ this.#resizeFrame = requestAnimationFrame(apply);
1460
+ });
1461
+ this.#resizeObserver.observe(host);
1462
+ }
1463
+ async #mountContentCss() {
1464
+ const urls = contentCssUrls(this.getAttribute('content-css'));
1465
+ if (urls.length === 0)
1466
+ return;
1467
+ // Captured before the await, for the reason `#boundDoc` exists: `adoptNode`
1468
+ // reassigns `ownerDocument`, so an editor moved across documents while the
1469
+ // fetch was in flight would adopt the sheet into one document and then go
1470
+ // looking for editors in another.
1471
+ const doc = this.ownerDocument;
1472
+ await loadContentCss(doc, urls);
1473
+ // Adopting a stylesheet changes the layout and dispatches no transaction, so
1474
+ // anything positioned in viewport coordinates is now pointing at where the
1475
+ // caret used to be. The floating bars are the only such thing, and an editor
1476
+ // that opens empty shows the insert bar immediately -- placed before this
1477
+ // sheet loaded, and left 215px adrift of the caret until the selection moved.
1478
+ //
1479
+ // Every editor on the document, not just this one: `loadContentCss` adopts
1480
+ // the sheet on the shared `Document` and `scopeContentCss` rewrites its
1481
+ // selectors to `.ol-editor .ol-content .ProseMirror ...`, so one editor
1482
+ // naming a stylesheet moves the caret in all of them.
1483
+ //
1484
+ // By class rather than by tag: `defineOpenLeafEditor(tag)` takes a name, so
1485
+ // a subclass can be registered as anything, and the sheet reaches it through
1486
+ // `.ol-editor` whatever it is called. `instanceof` keeps that honest.
1487
+ //
1488
+ // Guarded per editor, because any of them can have been torn down or rebuilt
1489
+ // while the fetch was in flight.
1490
+ for (const element of doc.querySelectorAll('.ol-editor')) {
1491
+ if (!(element instanceof _a))
1492
+ continue;
1493
+ const view = element.#view;
1494
+ if (!view || view.isDestroyed)
1495
+ continue;
1496
+ element.#floating?.update(view.state);
1497
+ }
1498
+ }
1499
+ #applyFullscreen(active) {
1500
+ this.#fullscreen = active;
1501
+ this.classList.toggle('ol-fullscreen', active);
1502
+ this.#toolbar?.setItemState('fullscreen', { active });
1503
+ this.#toolbar2?.setItemState('fullscreen', { active });
1504
+ }
1505
+ /**
1506
+ * Reconcile with a fullscreen session that ended somewhere else.
1507
+ *
1508
+ * Escape and the browser's own control leave fullscreen without going through
1509
+ * the toolbar. The `ol-fullscreen` class carries the fixed-position fallback,
1510
+ * so left set it kept the editor covering the page, and the next press of the
1511
+ * button only cleared the stale state instead of entering fullscreen.
1512
+ *
1513
+ * Guarded on `#nativeFullscreen`, because no `fullscreenchange` fires when
1514
+ * `requestFullscreen` is unavailable and the class-based fallback is all there
1515
+ * is -- an event about some other element must not tear that down.
1516
+ */
1517
+ #onFullscreenChange = () => {
1518
+ const native = this.ownerDocument.fullscreenElement === this;
1519
+ if (native)
1520
+ this.#applyFullscreen(true);
1521
+ else if (this.#nativeFullscreen)
1522
+ this.#applyFullscreen(false);
1523
+ this.#nativeFullscreen = native;
1524
+ };
1525
+ #onToggleFullscreen = () => {
1526
+ const next = !this.#fullscreen;
1527
+ this.#applyFullscreen(next);
1528
+ if (next) {
1529
+ void Promise.resolve(this.requestFullscreen?.()).catch(() => {
1530
+ /* class-based fallback already applied */
1531
+ });
1532
+ }
1533
+ else if (this.ownerDocument.fullscreenElement === this) {
1534
+ void this.ownerDocument.exitFullscreen?.();
1535
+ }
1536
+ this.#view?.focus();
1537
+ };
1538
+ /**
1539
+ * `visualaids` after mount.
1540
+ *
1541
+ * The attribute decides whether the aids exist at all, not merely whether
1542
+ * they are showing: the plugin that draws them is only installed when it is
1543
+ * on. So this reconfigures the plugin list rather than toggling a class over
1544
+ * a plugin that was never there -- which is the state `#onToggleVisualAids`
1545
+ * refuses to misreport to a screen reader.
1546
+ */
1547
+ #applyVisualAids(active) {
1548
+ if (active === this.#visualAidsAvailable)
1549
+ return;
1550
+ this.#visualAidsAvailable = active;
1551
+ this.#visualAids = active;
1552
+ this.classList.toggle('ol-visual-aids', active);
1553
+ this.#basePlugins = this.#basePlugins.filter((plugin) => plugin !== this.#visualAidsPlugin);
1554
+ this.#visualAidsPlugin = null;
1555
+ if (active) {
1556
+ this.#visualAidsPlugin = visualAidsPlugin();
1557
+ this.#basePlugins.push(this.#visualAidsPlugin);
1558
+ }
1559
+ const view = this.#view;
1560
+ if (view) {
1561
+ view.updateState(view.state.reconfigure({
1562
+ plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
1563
+ }));
1564
+ }
1565
+ const aids = { active, enabled: active };
1566
+ this.#toolbar?.setItemState('visualAids', aids);
1567
+ this.#toolbar2?.setItemState('visualAids', aids);
1568
+ }
1569
+ #onToggleVisualAids = () => {
1570
+ // `visualaids="false"` is read once, at build time, and the plugin that
1571
+ // draws the aids is never installed. The toggle still flipped aria-pressed,
1572
+ // so the button reported a feature as on that does not exist -- a lie a
1573
+ // screen reader repeats, and the one kind of state error ARIA cannot
1574
+ // recover from. The control is disabled instead.
1575
+ if (!this.#visualAidsAvailable)
1576
+ return;
1577
+ this.#visualAids = !this.#visualAids;
1578
+ this.classList.toggle('ol-visual-aids', this.#visualAids);
1579
+ this.#toolbar?.setItemState('visualAids', { active: this.#visualAids });
1580
+ this.#toolbar2?.setItemState('visualAids', { active: this.#visualAids });
1581
+ };
537
1582
  }
1583
+ _a = OpenLeafEditor;
538
1584
  /**
539
1585
  * Re-exported so the single-file bundle can offer paste normalization without a
540
1586
  * second script tag. Useful for custom paste handling, and for normalizing
@@ -546,7 +1592,24 @@ export { normalizePastedHtml } from '@openleaf-editor/paste';
546
1592
  * without a build step: `OpenLeaf.registerImageUploader(fn)`. Setting
547
1593
  * `element.imageUploader` overrides it for one editor.
548
1594
  */
549
- export { registerImageUploader, } from '@openleaf-editor/ui';
1595
+ export { registerFilePicker, registerImageClasses, registerImageList, registerImageUploader, registerLinkList, registerTranslations, setUiLocale, } from '@openleaf-editor/ui';
1596
+ /**
1597
+ * Re-exported because it is the documented way to add a toolbar control and it
1598
+ * lived in a package no install command mentions.
1599
+ *
1600
+ * `registerToolbarItem` is exported from `@openleaf-editor/ui`, which is a
1601
+ * transitive dependency of this package and appears in no `npm install` line in
1602
+ * any README. So the one extension point an integrator is most likely to reach
1603
+ * for was reachable only by guessing at a package name. It is already in this
1604
+ * bundle; re-exporting it costs nothing and means `registerToolbarItem` is
1605
+ * available wherever `<openleaf-editor>` is -- including from
1606
+ * `window.OpenLeaf` in a plain `<script>` integration, which has no other route
1607
+ * to it at all.
1608
+ *
1609
+ * `t` comes with it: a custom control's label is translated by the toolbar, but
1610
+ * anything the control builds itself has to be translated by the control.
1611
+ */
1612
+ export { registerIcons, registerStyles, registerToolbarItem, t, } from '@openleaf-editor/ui';
550
1613
  /** Idempotent: safe to import twice, or alongside a bundled copy. */
551
1614
  export function defineOpenLeafEditor(tag = 'openleaf-editor') {
552
1615
  if (typeof customElements === 'undefined')