@openleaf-editor/element 0.1.0-beta.2 → 0.1.0-beta.4

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