@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/README.md +53 -3
- package/dist/form-bridge.d.ts +46 -0
- package/dist/form-bridge.d.ts.map +1 -0
- package/dist/form-bridge.js +158 -0
- package/dist/form-bridge.js.map +1 -0
- package/dist/index.d.ts +113 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1039 -202
- package/dist/index.js.map +1 -1
- package/package.json +20 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.#
|
|
298
|
-
|
|
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
|
-
|
|
314
|
-
this
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.#
|
|
362
|
-
this.#
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
this.#
|
|
375
|
-
|
|
376
|
-
this
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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.#
|
|
844
|
+
this.#formBridge.sync();
|
|
414
845
|
return;
|
|
415
846
|
}
|
|
416
847
|
if (!this.#view) {
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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.
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
688
|
-
if (!(
|
|
1421
|
+
const clicked = event.target;
|
|
1422
|
+
if (!(clicked instanceof Element) || !this.#contentHost?.contains(clicked))
|
|
689
1423
|
return;
|
|
690
|
-
const items =
|
|
1424
|
+
const items = clicked.closest('a')
|
|
691
1425
|
? LINK_CONTEXT_ITEMS
|
|
692
|
-
:
|
|
1426
|
+
: clicked.closest('img')
|
|
693
1427
|
? IMAGE_CONTEXT_ITEMS
|
|
694
|
-
:
|
|
1428
|
+
: clicked.closest('table')
|
|
695
1429
|
? TABLE_CONTEXT_ITEMS
|
|
696
1430
|
: null;
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
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')
|