@openleaf-editor/element 0.1.0-beta.2 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- 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 +1008 -200
- 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, 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, 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,28 @@ 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
|
+
isolatingSelectionPlugin(),
|
|
394
|
+
disclosurePlugin(),
|
|
253
395
|
];
|
|
254
|
-
if (this.getAttribute('autolink') !== 'false')
|
|
255
|
-
this.#
|
|
256
|
-
|
|
257
|
-
|
|
396
|
+
if (this.getAttribute('autolink') !== 'false') {
|
|
397
|
+
this.#autolink = autolinkPlugin();
|
|
398
|
+
this.#basePlugins.push(this.#autolink);
|
|
399
|
+
}
|
|
400
|
+
if (this.#visualAids) {
|
|
401
|
+
this.#visualAidsPlugin = visualAidsPlugin();
|
|
402
|
+
this.#basePlugins.push(this.#visualAidsPlugin);
|
|
403
|
+
}
|
|
258
404
|
this.#schema = coreSchema();
|
|
405
|
+
// Cleared before the view exists, so a transaction dispatched during mount
|
|
406
|
+
// is counted as a real change rather than wiped by a later reset.
|
|
407
|
+
this.#docTouched = false;
|
|
259
408
|
this.#view = new EditorView(contentHost, {
|
|
260
409
|
state: EditorState.create({
|
|
261
410
|
doc: parseHtml(initialHtml, { schema: this.#schema }),
|
|
@@ -267,12 +416,11 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
267
416
|
plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
|
|
268
417
|
}),
|
|
269
418
|
editable: () => !this.hasAttribute('readonly'),
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
},
|
|
419
|
+
// A function, not a literal. The literal was evaluated once at
|
|
420
|
+
// construction, so `readonly` added later never reached the region and a
|
|
421
|
+
// label changed later never reached it either -- both of which are the
|
|
422
|
+
// ordinary case, not an edge one.
|
|
423
|
+
attributes: () => this.#regionAttributes(),
|
|
276
424
|
// Normalize before ProseMirror parses. Word and Google Docs express
|
|
277
425
|
// structure as proprietary CSS, so without this a pasted list arrives as
|
|
278
426
|
// a wall of paragraphs with stray bullet characters in the text.
|
|
@@ -285,6 +433,9 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
285
433
|
const view = this.#view;
|
|
286
434
|
if (!view)
|
|
287
435
|
return;
|
|
436
|
+
// Named so the change event's `value` getter, which has its own `this`,
|
|
437
|
+
// can reach the element.
|
|
438
|
+
const host = this;
|
|
288
439
|
view.updateState(view.state.apply(tr));
|
|
289
440
|
// Persistence comes FIRST, and deliberately so. The document is already
|
|
290
441
|
// committed by the line above; nothing about writing it out should
|
|
@@ -294,8 +445,14 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
294
445
|
// ran again for the rest of the session -- an autosave listening here
|
|
295
446
|
// would stop silently and the author would lose work.
|
|
296
447
|
if (tr.docChanged) {
|
|
297
|
-
this.#
|
|
298
|
-
|
|
448
|
+
this.#docTouched = true;
|
|
449
|
+
// Noted, not serialized. The textarea is written at submit, at
|
|
450
|
+
// teardown, and on a short trailing timer -- see FormBridge. Writing
|
|
451
|
+
// it here re-serialized the whole document on every keystroke, which
|
|
452
|
+
// was the single largest per-keystroke cost in the editor and was
|
|
453
|
+
// read by nothing until the form was posted.
|
|
454
|
+
this.#formBridge.markDirty();
|
|
455
|
+
this.#emitChange();
|
|
299
456
|
}
|
|
300
457
|
// Passing the transaction lets the toolbar tell a formatting change from
|
|
301
458
|
// a cursor move, which is what keeps its announcements useful instead of
|
|
@@ -310,11 +467,14 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
310
467
|
}
|
|
311
468
|
},
|
|
312
469
|
});
|
|
313
|
-
|
|
314
|
-
this
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
470
|
+
// Live from here on. Set as soon as there is something to tear down rather
|
|
471
|
+
// than at the end of the build: anything below this line may throw --
|
|
472
|
+
// mounting third-party chrome, serializing a document that contains a
|
|
473
|
+
// plugin's node type -- and a view that teardown refuses to touch is a
|
|
474
|
+
// permanent leak with a destroyed editor's listeners still attached.
|
|
475
|
+
this.#built = true;
|
|
476
|
+
this.#boundDoc = this.ownerDocument;
|
|
477
|
+
this.#mountChrome();
|
|
318
478
|
this.#mountInline();
|
|
319
479
|
this.#mountAutoresize();
|
|
320
480
|
void this.#mountContentCss();
|
|
@@ -358,32 +518,154 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
358
518
|
// fetch-based submissions built from a FormData snapshot.
|
|
359
519
|
// Prefer the bound textarea's form: the documented `for` binding allows
|
|
360
520
|
// 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();
|
|
521
|
+
this.#formBridge.attach();
|
|
522
|
+
this.#formBridge.sync();
|
|
368
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* Tear down -- but only once the element is really gone.
|
|
526
|
+
*
|
|
527
|
+
* Moving a node fires disconnect and then connect SYNCHRONOUSLY, so a guard
|
|
528
|
+
* in `connectedCallback` can never help: by the time it runs the view has
|
|
529
|
+
* already been destroyed. Deferring the decision by one microtask makes a
|
|
530
|
+
* move a no-op, which is what keeps undo history, selection and every
|
|
531
|
+
* plugin's state alive across a keyed-list reorder, an `insertBefore`
|
|
532
|
+
* shuffle or a drag-to-reorder.
|
|
533
|
+
*
|
|
534
|
+
* The limit is worth being precise about, because it is not "unmounting is
|
|
535
|
+
* safe now": this only covers a move completed within one task. Anything that
|
|
536
|
+
* parks the element in a detached container across ticks -- Vue's
|
|
537
|
+
* `<KeepAlive>` does exactly that -- is a real removal and tears down, which
|
|
538
|
+
* is why the rebuild path has to stay correct rather than merely unreachable.
|
|
539
|
+
*/
|
|
369
540
|
disconnectedCallback() {
|
|
370
541
|
// 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
|
-
|
|
542
|
+
// framework that moves the element does not drop unsaved HTML. This part
|
|
543
|
+
// stays synchronous: the value has to be in the textarea even if the
|
|
544
|
+
// element is removed on the way into a form submission.
|
|
545
|
+
this.#formBridge.sync();
|
|
546
|
+
// Snapshot the document HERE rather than in the microtask below. A
|
|
547
|
+
// reconnection rebuilds from this instead of from the chrome left in the
|
|
548
|
+
// subtree -- and serializing needs a live document, which is not
|
|
549
|
+
// guaranteed by the time a deferred callback runs (a closing page, or a
|
|
550
|
+
// test environment being torn down). Guarded on `#built` so a disconnect
|
|
551
|
+
// before the first build cannot record an empty document over the
|
|
552
|
+
// element's real markup.
|
|
553
|
+
if (this.#built)
|
|
554
|
+
this.#initialHtml = this.value;
|
|
555
|
+
queueMicrotask(() => {
|
|
556
|
+
if (this.isConnected)
|
|
557
|
+
return;
|
|
558
|
+
this.#teardown();
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/** Idempotent: two queued teardowns, or a teardown after one, do nothing. */
|
|
562
|
+
/**
|
|
563
|
+
* Build the menubar, toolbars and hint around the canvas.
|
|
564
|
+
*
|
|
565
|
+
* Split out of `#build` so `#rerenderChrome` can run exactly the same code
|
|
566
|
+
* on an attribute change, instead of a second copy that drifts from it.
|
|
567
|
+
*/
|
|
568
|
+
#buildChrome() {
|
|
569
|
+
const contentHost = this.#contentHost;
|
|
570
|
+
if (!contentHost)
|
|
571
|
+
return;
|
|
572
|
+
const formats = parseFormatList(this.getAttribute('formats'));
|
|
573
|
+
const overflow = this.hasAttribute('toolbar-overflow');
|
|
574
|
+
const layout = this.getAttribute('toolbar');
|
|
575
|
+
const wantsToolbar = layout !== 'none';
|
|
576
|
+
const menubarAttr = this.getAttribute('menubar');
|
|
577
|
+
const wantsMenubar = menubarAttr !== null && menubarAttr !== 'none';
|
|
578
|
+
if (wantsMenubar) {
|
|
579
|
+
// The attribute is a list, not a flag: `menubar="edit help"` asks for those
|
|
580
|
+
// two menus in that order. An unrecognised list leaves no menubar rather
|
|
581
|
+
// than an empty one with nothing in it.
|
|
582
|
+
const menus = selectMenus(menubarAttr);
|
|
583
|
+
if (menus.length > 0) {
|
|
584
|
+
this.#menubar = new MenuBar(this, this.ownerDocument, menus, this.getAttribute('lang'));
|
|
585
|
+
this.appendChild(this.#menubar.el);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (wantsToolbar) {
|
|
589
|
+
this.#toolbar = new Toolbar(this, this.ownerDocument, {
|
|
590
|
+
...(layout ? { layout } : {}),
|
|
591
|
+
overflow,
|
|
592
|
+
formats,
|
|
593
|
+
locale: this.getAttribute('lang'),
|
|
594
|
+
});
|
|
595
|
+
this.appendChild(this.#toolbar.el);
|
|
596
|
+
}
|
|
597
|
+
const toolbar2 = this.getAttribute('toolbar2');
|
|
598
|
+
if (toolbar2 && toolbar2 !== 'none') {
|
|
599
|
+
this.#toolbar2 = new Toolbar(this, this.ownerDocument, {
|
|
600
|
+
layout: toolbar2,
|
|
601
|
+
label: 'More formatting',
|
|
602
|
+
overflow,
|
|
603
|
+
formats,
|
|
604
|
+
locale: this.getAttribute('lang'),
|
|
605
|
+
});
|
|
606
|
+
this.appendChild(this.#toolbar2.el);
|
|
607
|
+
}
|
|
608
|
+
// Re-parented, never replaced: the view lives on this element, so the
|
|
609
|
+
// document, selection and undo history survive a chrome rebuild.
|
|
610
|
+
this.appendChild(contentHost);
|
|
611
|
+
// The Alt+F10 hint lives in a hidden element referenced by
|
|
612
|
+
// aria-describedby. Screen reader users cannot guess the shortcut, and
|
|
613
|
+
// discoverability comes from telling them rather than from choosing a
|
|
614
|
+
// guessable key.
|
|
615
|
+
//
|
|
616
|
+
// It no longer repeats the region's own name. A description is read
|
|
617
|
+
// immediately after the name, so "Rich text editor" followed by "Rich text
|
|
618
|
+
// editor. Press Alt plus F10..." made NVDA say the phrase twice -- and with
|
|
619
|
+
// no toolbar the whole description said nothing the role had not already.
|
|
620
|
+
if (wantsToolbar) {
|
|
621
|
+
const hint = this.ownerDocument.createElement('span');
|
|
622
|
+
hint.id = `ol-hint-${(hintCounter += 1)}`;
|
|
623
|
+
hint.className = 'ol-live';
|
|
624
|
+
hint.textContent = this.#localised('Press Alt plus F10 for the formatting toolbar.');
|
|
625
|
+
this.appendChild(hint);
|
|
626
|
+
this.#hint = hint;
|
|
627
|
+
}
|
|
628
|
+
// Unconditionally, and not from whichever bar happens to exist. A layout of
|
|
629
|
+
// `toolbar="none" toolbar2="bold italic"` used to mount no region at all, so
|
|
630
|
+
// Ctrl+B was silent -- the failure the whole announcement design exists to
|
|
631
|
+
// prevent. One region per editor, shared by every bar on it.
|
|
632
|
+
liveRegion(this);
|
|
633
|
+
}
|
|
634
|
+
/** Give the freshly built chrome the view, and the state it cannot derive. */
|
|
635
|
+
#mountChrome() {
|
|
636
|
+
const view = this.#view;
|
|
637
|
+
if (!view)
|
|
638
|
+
return;
|
|
639
|
+
this.#toolbar?.mount(view);
|
|
640
|
+
this.#toolbar2?.mount(view);
|
|
641
|
+
this.#menubar?.mount(view);
|
|
642
|
+
this.#mountFloating();
|
|
643
|
+
this.#mountContextMenu();
|
|
644
|
+
const aids = { active: this.#visualAids, enabled: this.#visualAidsAvailable };
|
|
645
|
+
this.#toolbar?.setItemState('visualAids', aids);
|
|
646
|
+
this.#toolbar2?.setItemState('visualAids', aids);
|
|
647
|
+
this.#toolbar?.setItemState('source', { active: this.#sourceMode });
|
|
648
|
+
this.#toolbar2?.setItemState('source', { active: this.#sourceMode });
|
|
649
|
+
this.#toolbar?.setItemState('fullscreen', { active: this.#fullscreen });
|
|
650
|
+
this.#toolbar2?.setItemState('fullscreen', { active: this.#fullscreen });
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Tear the chrome down, leaving the canvas and the view untouched.
|
|
654
|
+
*
|
|
655
|
+
* The announcement region is deliberately NOT removed here. It belongs to the
|
|
656
|
+
* host and is shared by every bar on it, so a chrome rebuild that took it away
|
|
657
|
+
* would silence the bars it did not rebuild. It goes in `#teardown`, with the
|
|
658
|
+
* editor.
|
|
659
|
+
*/
|
|
660
|
+
#destroyChrome() {
|
|
661
|
+
this.#unmountContextMenuListeners();
|
|
383
662
|
this.removeEventListener('focusin', this.#onInlineFocus);
|
|
384
663
|
this.removeEventListener('focusout', this.#onInlineBlur);
|
|
385
664
|
this.#resizeObserver?.disconnect();
|
|
386
665
|
this.#resizeObserver = null;
|
|
666
|
+
if (this.#resizeFrame)
|
|
667
|
+
cancelAnimationFrame(this.#resizeFrame);
|
|
668
|
+
this.#resizeFrame = 0;
|
|
387
669
|
this.#unwatchPlugins?.();
|
|
388
670
|
this.#unwatchSchema?.();
|
|
389
671
|
this.#floating?.destroy();
|
|
@@ -396,29 +678,222 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
396
678
|
this.#toolbar2 = null;
|
|
397
679
|
this.#toolbar?.destroy();
|
|
398
680
|
this.#toolbar = null;
|
|
681
|
+
this.#hint?.remove();
|
|
682
|
+
this.#hint = null;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Rebuild the chrome for a changed attribute, preserving everything else.
|
|
686
|
+
*
|
|
687
|
+
* The view, the document, the selection and the undo history all survive,
|
|
688
|
+
* because none of them is touched: the canvas element is re-parented rather
|
|
689
|
+
* than replaced. This is what makes `toolbar`, `menubar`, `formats` and the
|
|
690
|
+
* rest safe to treat as reactive props, which all three wrappers already
|
|
691
|
+
* did -- to no effect, before this existed.
|
|
692
|
+
*/
|
|
693
|
+
#rerenderChrome() {
|
|
694
|
+
if (!this.#view || !this.#contentHost)
|
|
695
|
+
return;
|
|
696
|
+
this.#destroyChrome();
|
|
697
|
+
this.#buildChrome();
|
|
698
|
+
this.#mountChrome();
|
|
699
|
+
this.#toolbar?.update(this.#view.state);
|
|
700
|
+
this.#toolbar2?.update(this.#view.state);
|
|
701
|
+
this.#view.setProps({ attributes: () => this.#regionAttributes() });
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Announce that the document changed.
|
|
705
|
+
*
|
|
706
|
+
* `value` is a getter, not a string. A listener that only wants to know
|
|
707
|
+
* *that* the document changed -- an autosave marking a draft dirty, a
|
|
708
|
+
* "unsaved changes" flag -- does not pay to find out what it changed to, and
|
|
709
|
+
* serializing a large document was the single largest per-keystroke cost in
|
|
710
|
+
* the editor. A listener that does want it reads `detail.value` and gets the
|
|
711
|
+
* cached string `get value` already holds, so the wrappers pay once.
|
|
712
|
+
*/
|
|
713
|
+
#emitChange() {
|
|
714
|
+
const host = this;
|
|
715
|
+
this.dispatchEvent(new CustomEvent('openleaf:change', {
|
|
716
|
+
bubbles: true,
|
|
717
|
+
composed: true,
|
|
718
|
+
detail: {
|
|
719
|
+
get value() {
|
|
720
|
+
return host.value;
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
#applyInline() {
|
|
726
|
+
const wanted = this.hasAttribute('inline');
|
|
727
|
+
this.classList.toggle('ol-inline', wanted);
|
|
728
|
+
// Both are idempotent: adding a listener twice with the same function
|
|
729
|
+
// reference is a no-op, and removing one that is not attached is too.
|
|
730
|
+
this.removeEventListener('focusin', this.#onInlineFocus);
|
|
731
|
+
this.removeEventListener('focusout', this.#onInlineBlur);
|
|
732
|
+
if (wanted)
|
|
733
|
+
this.#mountInline();
|
|
734
|
+
else
|
|
735
|
+
this.classList.remove('ol-inline-active');
|
|
736
|
+
}
|
|
737
|
+
/** `autoresize` after mount: start or stop growing the canvas. */
|
|
738
|
+
#applyAutoresize() {
|
|
739
|
+
const wanted = this.hasAttribute('autoresize');
|
|
740
|
+
this.classList.toggle('ol-autoresize', wanted);
|
|
741
|
+
this.#resizeObserver?.disconnect();
|
|
742
|
+
this.#resizeObserver = null;
|
|
743
|
+
if (wanted) {
|
|
744
|
+
this.#mountAutoresize();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Leaving the measured pixel height behind would freeze the canvas at
|
|
748
|
+
// whatever size it happened to be when the attribute was removed.
|
|
749
|
+
const pm = this.#contentHost?.querySelector('.ProseMirror');
|
|
750
|
+
if (pm)
|
|
751
|
+
pm.style.height = '';
|
|
752
|
+
}
|
|
753
|
+
/** Rebuild the plugin list in place, keeping the document and history. */
|
|
754
|
+
#reconfigurePlugins() {
|
|
755
|
+
const view = this.#view;
|
|
756
|
+
if (!view)
|
|
757
|
+
return;
|
|
758
|
+
this.#basePlugins = this.#basePlugins.filter((plugin) => plugin !== this.#autolink);
|
|
759
|
+
this.#autolink = null;
|
|
760
|
+
if (this.getAttribute('autolink') !== 'false') {
|
|
761
|
+
this.#autolink = autolinkPlugin();
|
|
762
|
+
this.#basePlugins.push(this.#autolink);
|
|
763
|
+
}
|
|
764
|
+
view.updateState(view.state.reconfigure({
|
|
765
|
+
plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
#teardown() {
|
|
769
|
+
if (!this.#built)
|
|
770
|
+
return;
|
|
771
|
+
this.#built = false;
|
|
772
|
+
// The document the listeners went ON, which is not necessarily the one this
|
|
773
|
+
// element belongs to now -- see #boundDoc.
|
|
774
|
+
const doc = this.#boundDoc ?? this.ownerDocument;
|
|
775
|
+
this.#boundDoc = null;
|
|
776
|
+
this.#teardownSource({ apply: false });
|
|
777
|
+
this.#formBridge.detach();
|
|
778
|
+
this.removeEventListener(SOURCE_TOGGLE_EVENT, this.#onToggleSource);
|
|
779
|
+
this.removeEventListener(FULLSCREEN_TOGGLE_EVENT, this.#onToggleFullscreen);
|
|
780
|
+
doc.removeEventListener('fullscreenchange', this.#onFullscreenChange);
|
|
781
|
+
this.removeEventListener(VISUAL_AIDS_TOGGLE_EVENT, this.#onToggleVisualAids);
|
|
782
|
+
// The context menu's three listeners are not removed here: #destroyChrome
|
|
783
|
+
// below owns all of them, from the document it recorded at mount. Removing
|
|
784
|
+
// them in two places is how the document-level one came to be removed from
|
|
785
|
+
// `#boundDoc` in one and `ownerDocument` in the other, and leaked whenever
|
|
786
|
+
// those differed.
|
|
787
|
+
this.removeEventListener('focusin', this.#onInlineFocus);
|
|
788
|
+
this.removeEventListener('focusout', this.#onInlineBlur);
|
|
789
|
+
this.#resizeObserver?.disconnect();
|
|
790
|
+
this.#resizeObserver = null;
|
|
791
|
+
this.#unwatchPlugins?.();
|
|
792
|
+
this.#unwatchSchema?.();
|
|
793
|
+
this.#destroyChrome();
|
|
399
794
|
this.#view?.destroy();
|
|
400
795
|
this.#view = null;
|
|
796
|
+
// Everything this element appended goes with it. Chrome left behind is not
|
|
797
|
+
// just a leak: `#build()` reads `this.innerHTML` as a last resort, so
|
|
798
|
+
// leftovers become the next document.
|
|
799
|
+
this.#contentHost?.remove();
|
|
800
|
+
this.#contentHost = null;
|
|
801
|
+
this.#hint?.remove();
|
|
802
|
+
this.#hint = null;
|
|
803
|
+
// The shared announcement region is the host's, so no individual toolbar
|
|
804
|
+
// may remove it -- but it must not survive the editor either, for the same
|
|
805
|
+
// reason the chrome above does not: #build() falls back to this.innerHTML.
|
|
806
|
+
disposeLiveRegion(this);
|
|
807
|
+
// Presentation state goes too. `ol-fullscreen` is fixed-position, inset 0,
|
|
808
|
+
// at the top of the stacking order, and nothing re-applies it on a rebuild
|
|
809
|
+
// -- so an editor removed while fullscreen used to come back as an opaque
|
|
810
|
+
// full-viewport overlay whose toolbar button showed inactive.
|
|
811
|
+
this.#fullscreen = false;
|
|
812
|
+
this.#nativeFullscreen = false;
|
|
813
|
+
this.classList.remove('ol-fullscreen', 'ol-inline-active', 'ol-editor', 'ol-inline', 'ol-autoresize', 'ol-visual-aids');
|
|
401
814
|
}
|
|
402
815
|
/** Current document as an HTML string. */
|
|
403
816
|
get value() {
|
|
404
817
|
if (this.#sourceMode && this.#sourceArea)
|
|
405
818
|
return this.#sourceArea.value;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
819
|
+
// After a teardown there is no view, but the document is not gone: it was
|
|
820
|
+
// snapshotted on disconnect. Without `#initialHtml` here an unbound editor
|
|
821
|
+
// reports an empty document the moment it is unmounted, which is the same
|
|
822
|
+
// content loss this fix exists to stop -- just read back rather than
|
|
823
|
+
// rebuilt.
|
|
824
|
+
if (!this.#view) {
|
|
825
|
+
return this.#pendingValue ?? this.#formBridge.textarea?.value ?? this.#initialHtml ?? '';
|
|
826
|
+
}
|
|
827
|
+
// Cached against the document it was produced from. `value` is read by the
|
|
828
|
+
// change event's detail, by the form bridge and by the host, often several
|
|
829
|
+
// times for one keystroke, and serializing a large document is the most
|
|
830
|
+
// expensive thing this class does.
|
|
831
|
+
const doc = this.#view.state.doc;
|
|
832
|
+
const cached = this.#serialized;
|
|
833
|
+
if (cached && cached.doc === doc)
|
|
834
|
+
return cached.html;
|
|
835
|
+
const html = serializeHtml(doc);
|
|
836
|
+
this.#serialized = { doc, html };
|
|
837
|
+
return html;
|
|
409
838
|
}
|
|
410
839
|
set value(html) {
|
|
411
840
|
if (this.#sourceMode && this.#sourceArea) {
|
|
412
841
|
this.#sourceArea.value = html;
|
|
413
|
-
this.#
|
|
842
|
+
this.#formBridge.sync();
|
|
414
843
|
return;
|
|
415
844
|
}
|
|
416
845
|
if (!this.#view) {
|
|
417
|
-
|
|
418
|
-
|
|
846
|
+
// No view to receive it: either the build has not happened yet (an
|
|
847
|
+
// assignment before upgrade, or while waiting for DOMContentLoaded) or it
|
|
848
|
+
// has been torn down. Hold it for the next build rather than dropping it.
|
|
849
|
+
this.#pendingValue = html;
|
|
850
|
+
if (this.#formBridge.textarea)
|
|
851
|
+
this.#formBridge.textarea.value = html;
|
|
419
852
|
return;
|
|
420
853
|
}
|
|
421
|
-
|
|
854
|
+
// `onlyIfChanged` makes assignment idempotent: `el.value = el.value` is a
|
|
855
|
+
// no-op instead of an undo step, a change event and a collapsed selection.
|
|
856
|
+
//
|
|
857
|
+
// The mount-then-fill exception is narrow on purpose. Every wrapper renders
|
|
858
|
+
// a bare element and pushes the server's HTML in afterwards, so that fill
|
|
859
|
+
// lands on an untouched, empty document -- and making it undoable is what
|
|
860
|
+
// let an author's FIRST Ctrl-Z wipe everything. An assignment onto a
|
|
861
|
+
// document that already HAS content is a different thing entirely: it is a
|
|
862
|
+
// "load template" or "reset draft" button replacing the author's work, and
|
|
863
|
+
// that must stay undoable.
|
|
864
|
+
this.#replaceDocument(html, {
|
|
865
|
+
onlyIfChanged: true,
|
|
866
|
+
addToHistory: this.#docTouched || !this.#isEmptyDocument(),
|
|
867
|
+
});
|
|
868
|
+
// Written through to the textarea now rather than on the trailing timer.
|
|
869
|
+
// The debounce exists for the keystroke path; a programmatic assignment is
|
|
870
|
+
// rare, and a host that sets `.value` and then reads the bound textarea in
|
|
871
|
+
// the same tick -- which every wrapper does on mount -- must not see the
|
|
872
|
+
// document it just replaced.
|
|
873
|
+
this.#formBridge.sync();
|
|
874
|
+
}
|
|
875
|
+
/** An untouched editor holds one empty text block -- what a wrapper mounts. */
|
|
876
|
+
#isEmptyDocument() {
|
|
877
|
+
const doc = this.#view?.state.doc;
|
|
878
|
+
if (!doc)
|
|
879
|
+
return true;
|
|
880
|
+
const first = doc.firstChild;
|
|
881
|
+
return doc.childCount <= 1 && (!first || (first.isTextblock && first.content.size === 0));
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Uploader for this editor alone, overriding `registerImageUploader`.
|
|
885
|
+
*
|
|
886
|
+
* An accessor rather than a class field, and that is load-bearing: a field
|
|
887
|
+
* initializer runs when the element upgrades, so it would overwrite an
|
|
888
|
+
* uploader assigned before the definition loaded -- exactly the case
|
|
889
|
+
* `#upgradeProperty` exists to rescue, defeated by the declaration meant to
|
|
890
|
+
* make the property visible.
|
|
891
|
+
*/
|
|
892
|
+
get imageUploader() {
|
|
893
|
+
return this.#imageUploader;
|
|
894
|
+
}
|
|
895
|
+
set imageUploader(uploader) {
|
|
896
|
+
this.#imageUploader = uploader;
|
|
422
897
|
}
|
|
423
898
|
/** Escape hatch for plugins and integrations that need the real view. */
|
|
424
899
|
get view() {
|
|
@@ -429,12 +904,59 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
429
904
|
return this.#schema;
|
|
430
905
|
}
|
|
431
906
|
/** The toolbar, for plugins pushing external state via setItemState. */
|
|
432
|
-
get
|
|
907
|
+
get toolbarInstance() {
|
|
433
908
|
return this.#toolbar;
|
|
434
909
|
}
|
|
910
|
+
/** The `toolbar` attribute. Assigning reflects, as HTML properties should. */
|
|
911
|
+
get toolbar() {
|
|
912
|
+
return this.getAttribute('toolbar');
|
|
913
|
+
}
|
|
914
|
+
set toolbar(layout) {
|
|
915
|
+
this.#reflect('toolbar', layout);
|
|
916
|
+
}
|
|
917
|
+
/** The `toolbar2` attribute. */
|
|
918
|
+
get toolbar2() {
|
|
919
|
+
return this.getAttribute('toolbar2');
|
|
920
|
+
}
|
|
921
|
+
set toolbar2(layout) {
|
|
922
|
+
this.#reflect('toolbar2', layout);
|
|
923
|
+
}
|
|
924
|
+
/** The `menubar` attribute. */
|
|
925
|
+
get menubar() {
|
|
926
|
+
return this.getAttribute('menubar');
|
|
927
|
+
}
|
|
928
|
+
set menubar(menus) {
|
|
929
|
+
this.#reflect('menubar', menus);
|
|
930
|
+
}
|
|
931
|
+
/** The `formats` attribute. */
|
|
932
|
+
get formats() {
|
|
933
|
+
return this.getAttribute('formats');
|
|
934
|
+
}
|
|
935
|
+
set formats(spec) {
|
|
936
|
+
this.#reflect('formats', spec);
|
|
937
|
+
}
|
|
938
|
+
/** The `readonly` attribute, as the boolean every framework binds it as. */
|
|
939
|
+
get readOnly() {
|
|
940
|
+
return this.hasAttribute('readonly');
|
|
941
|
+
}
|
|
942
|
+
set readOnly(value) {
|
|
943
|
+
this.#reflect('readonly', value ? '' : null);
|
|
944
|
+
}
|
|
945
|
+
#reflect(name, value) {
|
|
946
|
+
if (value === null || value === undefined)
|
|
947
|
+
this.removeAttribute(name);
|
|
948
|
+
else
|
|
949
|
+
this.setAttribute(name, value);
|
|
950
|
+
}
|
|
951
|
+
/** Whether the HTML source view is open. Assigning toggles it. */
|
|
435
952
|
get sourceMode() {
|
|
436
953
|
return this.#sourceMode;
|
|
437
954
|
}
|
|
955
|
+
set sourceMode(open) {
|
|
956
|
+
if (open === this.#sourceMode)
|
|
957
|
+
return;
|
|
958
|
+
this.#onToggleSource();
|
|
959
|
+
}
|
|
438
960
|
/* -------------------------------------------------------------- *
|
|
439
961
|
* Source view
|
|
440
962
|
* -------------------------------------------------------------- */
|
|
@@ -453,7 +975,7 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
453
975
|
if (!this.#sourceMode) {
|
|
454
976
|
const area = this.ownerDocument.createElement('textarea');
|
|
455
977
|
area.className = 'ol-source';
|
|
456
|
-
area.setAttribute('aria-label', 'HTML source');
|
|
978
|
+
area.setAttribute('aria-label', this.#localised('HTML source'));
|
|
457
979
|
area.spellcheck = false;
|
|
458
980
|
area.readOnly = this.hasAttribute('readonly');
|
|
459
981
|
area.value = serializeHtml(view.state.doc);
|
|
@@ -463,6 +985,13 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
463
985
|
this.#sourceMode = true;
|
|
464
986
|
this.#toolbar?.setItemState('source', { active: true });
|
|
465
987
|
this.#toolbar2?.setItemState('source', { active: true });
|
|
988
|
+
// Every other control goes unavailable: a formatting command here runs
|
|
989
|
+
// against the hidden document, which `#teardownSource` then reparses over
|
|
990
|
+
// the top of. The mode change is announced because moving focus into a
|
|
991
|
+
// textarea full of angle brackets, with no explanation, is disorienting.
|
|
992
|
+
this.#toolbar?.setSourceMode(true);
|
|
993
|
+
this.#toolbar2?.setSourceMode(true);
|
|
994
|
+
announce(this, this.#localised('HTML source view'));
|
|
466
995
|
// Announced before focus so an enhancer can wrap the textarea while it is
|
|
467
996
|
// still inert; focusing first would move the caret and then move the
|
|
468
997
|
// element out from under it.
|
|
@@ -470,14 +999,38 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
470
999
|
area.focus();
|
|
471
1000
|
return;
|
|
472
1001
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1002
|
+
/*
|
|
1003
|
+
* `finally`, because a half-completed teardown is unrecoverable.
|
|
1004
|
+
*
|
|
1005
|
+
* Applying the source parses it and renders the result, and that used to be
|
|
1006
|
+
* able to throw -- `<p ="v">` carried an attribute name `setAttribute`
|
|
1007
|
+
* refuses. The teardown had already removed the source box and flipped
|
|
1008
|
+
* `#sourceMode`, so the throw escaped before the content host was unhidden:
|
|
1009
|
+
* a blank rectangle, `sourceMode` reporting false while the Source button
|
|
1010
|
+
* still read pressed, nothing clickable, and toggling back throwing again.
|
|
1011
|
+
* A page reload was the only way out and the author's work was gone.
|
|
1012
|
+
*
|
|
1013
|
+
* The carried-attribute hole is fixed at its source, in the schema. This is
|
|
1014
|
+
* the other half: whatever a future failure in applying source HTML turns
|
|
1015
|
+
* out to be, it leaves a visible, usable editor. The error still propagates,
|
|
1016
|
+
* because the host should hear about it.
|
|
1017
|
+
*
|
|
1018
|
+
* `apply: false` under readonly: the source box is read-only there, so there
|
|
1019
|
+
* is nothing to write back and parsing it would be a no-op that still lands
|
|
1020
|
+
* a transaction.
|
|
1021
|
+
*/
|
|
1022
|
+
try {
|
|
1023
|
+
this.#teardownSource({ apply: !this.hasAttribute('readonly') });
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
contentHost.hidden = false;
|
|
1027
|
+
this.#toolbar?.setItemState('source', { active: false });
|
|
1028
|
+
this.#toolbar2?.setItemState('source', { active: false });
|
|
1029
|
+
this.#toolbar?.setSourceMode(false);
|
|
1030
|
+
this.#toolbar2?.setSourceMode(false);
|
|
1031
|
+
announce(this, this.#localised('Rich text view'));
|
|
1032
|
+
view.focus();
|
|
1033
|
+
}
|
|
481
1034
|
};
|
|
482
1035
|
/**
|
|
483
1036
|
* Leave source mode.
|
|
@@ -511,7 +1064,8 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
511
1064
|
* Replace the document with a transaction, so undo and change events survive.
|
|
512
1065
|
*
|
|
513
1066
|
* `onlyIfChanged` skips the dispatch when the HTML parses to the document
|
|
514
|
-
* already on screen.
|
|
1067
|
+
* already on screen. `addToHistory: false` keeps the replacement out of the
|
|
1068
|
+
* undo stack, for the mount-then-fill sequence every wrapper performs.
|
|
515
1069
|
*/
|
|
516
1070
|
#replaceDocument(html, options) {
|
|
517
1071
|
const view = this.#view;
|
|
@@ -520,36 +1074,19 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
520
1074
|
const next = parseHtml(html, { schema: this.#schema });
|
|
521
1075
|
if (options?.onlyIfChanged && next.eq(view.state.doc))
|
|
522
1076
|
return;
|
|
523
|
-
view.
|
|
1077
|
+
const tr = view.state.tr.replaceWith(0, view.state.doc.content.size, next.content);
|
|
1078
|
+
// Replacing the whole document maps every old position onto the boundary,
|
|
1079
|
+
// so the caret would jump to the top on any programmatic assignment. Put it
|
|
1080
|
+
// back where the author left it, clamped to the new document.
|
|
1081
|
+
const at = Math.min(view.state.selection.from, tr.doc.content.size);
|
|
1082
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve(at)));
|
|
1083
|
+
if (options?.addToHistory === false)
|
|
1084
|
+
tr.setMeta('addToHistory', false);
|
|
1085
|
+
view.dispatch(tr);
|
|
524
1086
|
}
|
|
525
1087
|
/* -------------------------------------------------------------- *
|
|
526
1088
|
* Textarea binding
|
|
527
1089
|
* -------------------------------------------------------------- */
|
|
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
1090
|
/**
|
|
554
1091
|
* Claim a drop or paste that carries image files.
|
|
555
1092
|
*
|
|
@@ -592,7 +1129,9 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
592
1129
|
* a property worth more: every image OpenLeaf inserts has been described or
|
|
593
1130
|
* explicitly marked decorative. Uploading in parallel would mean either
|
|
594
1131
|
* stacking modal dialogs or inserting undescribed images and asking later --
|
|
595
|
-
* and "later"
|
|
1132
|
+
* and "later" used to have no UI. The image toolbar item now edits a selected
|
|
1133
|
+
* image, including its alt text; a drop still describes each file before
|
|
1134
|
+
* insert because a drop is not an edit of whatever happens to be selected.
|
|
596
1135
|
*/
|
|
597
1136
|
async #uploadImages(view, files) {
|
|
598
1137
|
const uploader = imageUploaderFor(this);
|
|
@@ -621,6 +1160,68 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
621
1160
|
}
|
|
622
1161
|
view.focus();
|
|
623
1162
|
}
|
|
1163
|
+
/* -------------------------------------------------------------- *
|
|
1164
|
+
* Accessible semantics of the editable region
|
|
1165
|
+
* -------------------------------------------------------------- */
|
|
1166
|
+
/**
|
|
1167
|
+
* The ARIA attributes ProseMirror puts on the editable region.
|
|
1168
|
+
*
|
|
1169
|
+
* Recomputed on every update rather than frozen at construction, which is
|
|
1170
|
+
* what makes `readonly` and a changing name observable at all.
|
|
1171
|
+
*/
|
|
1172
|
+
#regionAttributes() {
|
|
1173
|
+
const attributes = {
|
|
1174
|
+
role: 'textbox',
|
|
1175
|
+
'aria-multiline': 'true',
|
|
1176
|
+
// Always written, never omitted when false. `contenteditable="false"` is
|
|
1177
|
+
// not a signal any screen reader reports, so without this a read-only
|
|
1178
|
+
// editor announced "edit multiline", the author typed, and nothing
|
|
1179
|
+
// happened.
|
|
1180
|
+
'aria-readonly': this.hasAttribute('readonly') ? 'true' : 'false',
|
|
1181
|
+
'aria-label': this.#regionName(),
|
|
1182
|
+
};
|
|
1183
|
+
if (this.#hint)
|
|
1184
|
+
attributes['aria-describedby'] = this.#hint.id;
|
|
1185
|
+
return attributes;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* What this editor is called.
|
|
1189
|
+
*
|
|
1190
|
+
* The documented integration is `<label for="body">` beside
|
|
1191
|
+
* `<textarea id="body">`, and that label names the TEXTAREA -- the editable
|
|
1192
|
+
* region is a different element entirely, so it inherited nothing. Every
|
|
1193
|
+
* integrator who followed the README and did not also duplicate the text as
|
|
1194
|
+
* `aria-label` shipped an editor called "Rich text editor", and two of them on
|
|
1195
|
+
* one page were indistinguishable.
|
|
1196
|
+
*/
|
|
1197
|
+
#regionName() {
|
|
1198
|
+
const explicit = this.getAttribute('aria-label')?.trim();
|
|
1199
|
+
if (explicit)
|
|
1200
|
+
return explicit;
|
|
1201
|
+
const inherited = this.#formBridge.textarea?.labels?.[0]?.textContent?.trim();
|
|
1202
|
+
if (inherited)
|
|
1203
|
+
return inherited;
|
|
1204
|
+
return this.#localised('Rich text editor');
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Give the host a role when, and only when, it carries `aria-label`.
|
|
1208
|
+
*
|
|
1209
|
+
* ARIA prohibits a label on a `generic` element, which is what
|
|
1210
|
+
* `<openleaf-editor>` is with no role of its own -- axe reports it as
|
|
1211
|
+
* `aria-prohibited-attr`. Adding the role unconditionally would instead have
|
|
1212
|
+
* every editor announce its name twice, once for the group and once for the
|
|
1213
|
+
* region inside it, so it is added exactly where the violation is.
|
|
1214
|
+
*/
|
|
1215
|
+
#applyHostRole() {
|
|
1216
|
+
if (this.getAttribute('aria-label')?.trim())
|
|
1217
|
+
this.setAttribute('role', 'group');
|
|
1218
|
+
else if (this.getAttribute('role') === 'group')
|
|
1219
|
+
this.removeAttribute('role');
|
|
1220
|
+
}
|
|
1221
|
+
/** A UI string in this editor's own `lang`, not the document-wide locale. */
|
|
1222
|
+
#localised(source) {
|
|
1223
|
+
return withLocale(this.getAttribute('lang'), () => t(source));
|
|
1224
|
+
}
|
|
624
1225
|
#applyReadonly() {
|
|
625
1226
|
// `editable()` already reads the attribute; the view has to be told to
|
|
626
1227
|
// re-evaluate it. Without this, adding readonly after mount leaves
|
|
@@ -644,6 +1245,15 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
644
1245
|
const lang = this.getAttribute('lang');
|
|
645
1246
|
this.#toolbar?.setLocale(lang);
|
|
646
1247
|
this.#toolbar2?.setLocale(lang);
|
|
1248
|
+
this.#floating?.setLocale(lang);
|
|
1249
|
+
if (this.#hint) {
|
|
1250
|
+
this.#hint.textContent = this.#localised('Press Alt plus F10 for the formatting toolbar.');
|
|
1251
|
+
}
|
|
1252
|
+
if (this.#sourceArea) {
|
|
1253
|
+
this.#sourceArea.setAttribute('aria-label', this.#localised('HTML source'));
|
|
1254
|
+
}
|
|
1255
|
+
// The region's own name may be the generic fallback, which is translated.
|
|
1256
|
+
this.#view?.setProps({});
|
|
647
1257
|
}
|
|
648
1258
|
#mountFloating() {
|
|
649
1259
|
const view = this.#view;
|
|
@@ -658,9 +1268,24 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
658
1268
|
this.#floating = new FloatingToolbars(this, this.ownerDocument, {
|
|
659
1269
|
selectionLayout,
|
|
660
1270
|
insertLayout,
|
|
1271
|
+
locale: this.getAttribute('lang'),
|
|
661
1272
|
});
|
|
662
1273
|
this.#floating.mount(view);
|
|
663
1274
|
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Every listener `#mountContextMenu` added, removed from where it added it.
|
|
1277
|
+
*
|
|
1278
|
+
* The keydown one used to be dropped only in `#teardown`, which was safe by
|
|
1279
|
+
* accident -- every `#destroyChrome` call site either remounts immediately or
|
|
1280
|
+
* precedes a full teardown, and re-adding the same function reference is a
|
|
1281
|
+
* no-op. Symmetry here is cheaper than that argument.
|
|
1282
|
+
*/
|
|
1283
|
+
#unmountContextMenuListeners() {
|
|
1284
|
+
this.removeEventListener('contextmenu', this.#onContextMenu);
|
|
1285
|
+
this.removeEventListener('keydown', this.#onContextKey, true);
|
|
1286
|
+
this.#contextDoc?.removeEventListener('pointerdown', this.#onContextPointer, true);
|
|
1287
|
+
this.#contextDoc = null;
|
|
1288
|
+
}
|
|
664
1289
|
#mountContextMenu() {
|
|
665
1290
|
if (this.getAttribute('contextmenu') === 'none')
|
|
666
1291
|
return;
|
|
@@ -669,7 +1294,10 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
669
1294
|
this.#contextMenu.attach(this.#view);
|
|
670
1295
|
this.appendChild(this.#contextMenu.el);
|
|
671
1296
|
this.addEventListener('contextmenu', this.#onContextMenu);
|
|
672
|
-
this.
|
|
1297
|
+
this.addEventListener('keydown', this.#onContextKey, true);
|
|
1298
|
+
// Recorded, not re-derived at removal time. See #contextDoc.
|
|
1299
|
+
this.#contextDoc = this.ownerDocument;
|
|
1300
|
+
this.#contextDoc.addEventListener('pointerdown', this.#onContextPointer, true);
|
|
673
1301
|
}
|
|
674
1302
|
#onContextPointer = (event) => {
|
|
675
1303
|
const menu = this.#contextMenu;
|
|
@@ -679,25 +1307,101 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
679
1307
|
return;
|
|
680
1308
|
menu.close();
|
|
681
1309
|
};
|
|
682
|
-
|
|
1310
|
+
/**
|
|
1311
|
+
* Open the context menu from the keyboard.
|
|
1312
|
+
*
|
|
1313
|
+
* Shift+F10 and the Menu key were assumed to arrive as a synthesized
|
|
1314
|
+
* `contextmenu` event, and the handler was left to work out the rest. They do
|
|
1315
|
+
* not, reliably: Chromium fires no `contextmenu` for Shift+F10 at all when the
|
|
1316
|
+
* key is delivered to the renderer, so the documented keyboard entry point was
|
|
1317
|
+
* a no-op that no test could see, because no test pressed the key.
|
|
1318
|
+
*
|
|
1319
|
+
* Opening straight from the key is also the only way to get the two things
|
|
1320
|
+
* that follow right -- the node the caret is in, and a position to put the
|
|
1321
|
+
* menu at -- because neither is recoverable from a synthesized mouse event.
|
|
1322
|
+
*/
|
|
1323
|
+
#onContextKey = (event) => {
|
|
1324
|
+
if (event.key !== 'ContextMenu' && !(event.key === 'F10' && event.shiftKey))
|
|
1325
|
+
return;
|
|
1326
|
+
// See `#onContextMenu`: the keyboard route to the same edit-only menu.
|
|
1327
|
+
if (this.hasAttribute('readonly'))
|
|
1328
|
+
return;
|
|
683
1329
|
const view = this.#view;
|
|
1330
|
+
if (!view)
|
|
1331
|
+
return;
|
|
1332
|
+
if (!this.#showContext(view, this.#contextItemsAtCaret(view.state), null))
|
|
1333
|
+
return;
|
|
1334
|
+
event.preventDefault();
|
|
1335
|
+
};
|
|
1336
|
+
/** Open a menu, at a point for a pointer or at the caret for a key. */
|
|
1337
|
+
#showContext(view, items, point) {
|
|
684
1338
|
const menu = this.#contextMenu;
|
|
685
|
-
if (!
|
|
1339
|
+
if (!menu || !items)
|
|
1340
|
+
return false;
|
|
1341
|
+
let x = point?.x ?? 0;
|
|
1342
|
+
let y = point?.y ?? 0;
|
|
1343
|
+
// A synthesized event carries the focused element's corner at best and 0,0
|
|
1344
|
+
// at worst; neither of them is where the caret is.
|
|
1345
|
+
if (!point || x <= 0) {
|
|
1346
|
+
const coords = view.coordsAtPos(view.state.selection.from);
|
|
1347
|
+
x = coords.left;
|
|
1348
|
+
y = coords.bottom;
|
|
1349
|
+
}
|
|
1350
|
+
menu.show(items, x, y, { label: 'Editor menu', onClose: () => view.focus() });
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* What the menu is about, read from the document rather than the DOM.
|
|
1355
|
+
*
|
|
1356
|
+
* For a pointer the answer is whatever was clicked. For the keyboard it is
|
|
1357
|
+
* emphatically NOT `event.target`: the focused element is the ProseMirror
|
|
1358
|
+
* contenteditable div, and `closest()` walks UP from it, so it never found the
|
|
1359
|
+
* `<a>` or `<img>` the caret was in and the handler returned in silence. The
|
|
1360
|
+
* selection is the only thing that knows where the caret is, and asking the
|
|
1361
|
+
* document is more direct than mapping a position back to a node and walking
|
|
1362
|
+
* the tree from there.
|
|
1363
|
+
*/
|
|
1364
|
+
#contextItemsAtCaret(state) {
|
|
1365
|
+
const selection = state.selection;
|
|
1366
|
+
const $from = selection.$from;
|
|
1367
|
+
const link = state.schema.marks['link'];
|
|
1368
|
+
if (link && link.isInSet($from.marks()))
|
|
1369
|
+
return LINK_CONTEXT_ITEMS;
|
|
1370
|
+
const node = selection instanceof NodeSelection ? selection.node : $from.nodeAfter;
|
|
1371
|
+
if (node?.type.name === 'image')
|
|
1372
|
+
return IMAGE_CONTEXT_ITEMS;
|
|
1373
|
+
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
|
1374
|
+
if ($from.node(depth).type.name === 'table')
|
|
1375
|
+
return TABLE_CONTEXT_ITEMS;
|
|
1376
|
+
}
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
#onContextMenu = (event) => {
|
|
1380
|
+
const view = this.#view;
|
|
1381
|
+
if (!view)
|
|
686
1382
|
return;
|
|
687
|
-
|
|
688
|
-
|
|
1383
|
+
// Before anything else, and before `preventDefault`: every entry in these
|
|
1384
|
+
// menus is an edit. `invoke` already refuses to run one under `readonly`, so
|
|
1385
|
+
// opening the menu offered a list of items that silently did nothing -- and
|
|
1386
|
+
// worse, opening it at all called `preventDefault()` and took away the
|
|
1387
|
+
// browser's own menu, which is the copy-and-inspect a read-only reader is
|
|
1388
|
+
// left with. The table plugin's menu makes the same check on the same event;
|
|
1389
|
+
// this is the other listener on it.
|
|
1390
|
+
if (this.hasAttribute('readonly'))
|
|
1391
|
+
return;
|
|
1392
|
+
const clicked = event.target;
|
|
1393
|
+
if (!(clicked instanceof Element) || !this.#contentHost?.contains(clicked))
|
|
689
1394
|
return;
|
|
690
|
-
const items =
|
|
1395
|
+
const items = clicked.closest('a')
|
|
691
1396
|
? LINK_CONTEXT_ITEMS
|
|
692
|
-
:
|
|
1397
|
+
: clicked.closest('img')
|
|
693
1398
|
? IMAGE_CONTEXT_ITEMS
|
|
694
|
-
:
|
|
1399
|
+
: clicked.closest('table')
|
|
695
1400
|
? TABLE_CONTEXT_ITEMS
|
|
696
1401
|
: null;
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
menu.show(items, event.clientX, event.clientY, () => view.focus());
|
|
1402
|
+
if (this.#showContext(view, items, { x: event.clientX, y: event.clientY })) {
|
|
1403
|
+
event.preventDefault();
|
|
1404
|
+
}
|
|
701
1405
|
};
|
|
702
1406
|
#mountInline() {
|
|
703
1407
|
if (!this.hasAttribute('inline'))
|
|
@@ -714,29 +1418,83 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
714
1418
|
return;
|
|
715
1419
|
this.classList.remove('ol-inline-active');
|
|
716
1420
|
};
|
|
1421
|
+
/**
|
|
1422
|
+
* Grow the canvas with the document.
|
|
1423
|
+
*
|
|
1424
|
+
* `height: auto` followed by a `scrollHeight` read is a forced synchronous
|
|
1425
|
+
* layout of the entire content, and it happens inside a `ResizeObserver`
|
|
1426
|
+
* watching the element the write resizes -- so an unbatched version relaid
|
|
1427
|
+
* out the document on every observation, and each write invited the next
|
|
1428
|
+
* observation. Coalescing into one animation frame collapses a burst into a
|
|
1429
|
+
* single relayout, and skipping an unchanged height stops the write that
|
|
1430
|
+
* would re-notify the observer for nothing.
|
|
1431
|
+
*/
|
|
717
1432
|
#mountAutoresize() {
|
|
718
1433
|
const host = this.#contentHost;
|
|
719
1434
|
const view = this.#view;
|
|
720
1435
|
if (!host || !view || !this.hasAttribute('autoresize'))
|
|
721
1436
|
return;
|
|
1437
|
+
// Cached: `querySelector` on every observation walks the whole canvas.
|
|
1438
|
+
let pm = null;
|
|
1439
|
+
let last = '';
|
|
722
1440
|
const apply = () => {
|
|
723
|
-
|
|
1441
|
+
this.#resizeFrame = 0;
|
|
1442
|
+
if (!pm?.isConnected)
|
|
1443
|
+
pm = host.querySelector('.ProseMirror');
|
|
724
1444
|
if (!pm)
|
|
725
1445
|
return;
|
|
726
1446
|
pm.style.height = 'auto';
|
|
727
|
-
|
|
1447
|
+
const height = `${pm.scrollHeight}px`;
|
|
1448
|
+
if (height === last)
|
|
1449
|
+
return;
|
|
1450
|
+
last = height;
|
|
1451
|
+
pm.style.height = height;
|
|
728
1452
|
};
|
|
729
1453
|
apply();
|
|
730
|
-
if (typeof ResizeObserver
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1454
|
+
if (typeof ResizeObserver === 'undefined')
|
|
1455
|
+
return;
|
|
1456
|
+
this.#resizeObserver = new ResizeObserver(() => {
|
|
1457
|
+
if (this.#resizeFrame)
|
|
1458
|
+
return;
|
|
1459
|
+
this.#resizeFrame = requestAnimationFrame(apply);
|
|
1460
|
+
});
|
|
1461
|
+
this.#resizeObserver.observe(host);
|
|
734
1462
|
}
|
|
735
1463
|
async #mountContentCss() {
|
|
736
1464
|
const urls = contentCssUrls(this.getAttribute('content-css'));
|
|
737
1465
|
if (urls.length === 0)
|
|
738
1466
|
return;
|
|
739
|
-
await
|
|
1467
|
+
// Captured before the await, for the reason `#boundDoc` exists: `adoptNode`
|
|
1468
|
+
// reassigns `ownerDocument`, so an editor moved across documents while the
|
|
1469
|
+
// fetch was in flight would adopt the sheet into one document and then go
|
|
1470
|
+
// looking for editors in another.
|
|
1471
|
+
const doc = this.ownerDocument;
|
|
1472
|
+
await loadContentCss(doc, urls);
|
|
1473
|
+
// Adopting a stylesheet changes the layout and dispatches no transaction, so
|
|
1474
|
+
// anything positioned in viewport coordinates is now pointing at where the
|
|
1475
|
+
// caret used to be. The floating bars are the only such thing, and an editor
|
|
1476
|
+
// that opens empty shows the insert bar immediately -- placed before this
|
|
1477
|
+
// sheet loaded, and left 215px adrift of the caret until the selection moved.
|
|
1478
|
+
//
|
|
1479
|
+
// Every editor on the document, not just this one: `loadContentCss` adopts
|
|
1480
|
+
// the sheet on the shared `Document` and `scopeContentCss` rewrites its
|
|
1481
|
+
// selectors to `.ol-editor .ol-content .ProseMirror ...`, so one editor
|
|
1482
|
+
// naming a stylesheet moves the caret in all of them.
|
|
1483
|
+
//
|
|
1484
|
+
// By class rather than by tag: `defineOpenLeafEditor(tag)` takes a name, so
|
|
1485
|
+
// a subclass can be registered as anything, and the sheet reaches it through
|
|
1486
|
+
// `.ol-editor` whatever it is called. `instanceof` keeps that honest.
|
|
1487
|
+
//
|
|
1488
|
+
// Guarded per editor, because any of them can have been torn down or rebuilt
|
|
1489
|
+
// while the fetch was in flight.
|
|
1490
|
+
for (const element of doc.querySelectorAll('.ol-editor')) {
|
|
1491
|
+
if (!(element instanceof _a))
|
|
1492
|
+
continue;
|
|
1493
|
+
const view = element.#view;
|
|
1494
|
+
if (!view || view.isDestroyed)
|
|
1495
|
+
continue;
|
|
1496
|
+
element.#floating?.update(view.state);
|
|
1497
|
+
}
|
|
740
1498
|
}
|
|
741
1499
|
#applyFullscreen(active) {
|
|
742
1500
|
this.#fullscreen = active;
|
|
@@ -777,19 +1535,52 @@ export class OpenLeafEditor extends HTMLElement {
|
|
|
777
1535
|
}
|
|
778
1536
|
this.#view?.focus();
|
|
779
1537
|
};
|
|
1538
|
+
/**
|
|
1539
|
+
* `visualaids` after mount.
|
|
1540
|
+
*
|
|
1541
|
+
* The attribute decides whether the aids exist at all, not merely whether
|
|
1542
|
+
* they are showing: the plugin that draws them is only installed when it is
|
|
1543
|
+
* on. So this reconfigures the plugin list rather than toggling a class over
|
|
1544
|
+
* a plugin that was never there -- which is the state `#onToggleVisualAids`
|
|
1545
|
+
* refuses to misreport to a screen reader.
|
|
1546
|
+
*/
|
|
1547
|
+
#applyVisualAids(active) {
|
|
1548
|
+
if (active === this.#visualAidsAvailable)
|
|
1549
|
+
return;
|
|
1550
|
+
this.#visualAidsAvailable = active;
|
|
1551
|
+
this.#visualAids = active;
|
|
1552
|
+
this.classList.toggle('ol-visual-aids', active);
|
|
1553
|
+
this.#basePlugins = this.#basePlugins.filter((plugin) => plugin !== this.#visualAidsPlugin);
|
|
1554
|
+
this.#visualAidsPlugin = null;
|
|
1555
|
+
if (active) {
|
|
1556
|
+
this.#visualAidsPlugin = visualAidsPlugin();
|
|
1557
|
+
this.#basePlugins.push(this.#visualAidsPlugin);
|
|
1558
|
+
}
|
|
1559
|
+
const view = this.#view;
|
|
1560
|
+
if (view) {
|
|
1561
|
+
view.updateState(view.state.reconfigure({
|
|
1562
|
+
plugins: [...this.#basePlugins, ...createRegisteredPlugins(this.#schema, this.#pluginCache)],
|
|
1563
|
+
}));
|
|
1564
|
+
}
|
|
1565
|
+
const aids = { active, enabled: active };
|
|
1566
|
+
this.#toolbar?.setItemState('visualAids', aids);
|
|
1567
|
+
this.#toolbar2?.setItemState('visualAids', aids);
|
|
1568
|
+
}
|
|
780
1569
|
#onToggleVisualAids = () => {
|
|
1570
|
+
// `visualaids="false"` is read once, at build time, and the plugin that
|
|
1571
|
+
// draws the aids is never installed. The toggle still flipped aria-pressed,
|
|
1572
|
+
// so the button reported a feature as on that does not exist -- a lie a
|
|
1573
|
+
// screen reader repeats, and the one kind of state error ARIA cannot
|
|
1574
|
+
// recover from. The control is disabled instead.
|
|
1575
|
+
if (!this.#visualAidsAvailable)
|
|
1576
|
+
return;
|
|
781
1577
|
this.#visualAids = !this.#visualAids;
|
|
782
1578
|
this.classList.toggle('ol-visual-aids', this.#visualAids);
|
|
783
1579
|
this.#toolbar?.setItemState('visualAids', { active: this.#visualAids });
|
|
784
1580
|
this.#toolbar2?.setItemState('visualAids', { active: this.#visualAids });
|
|
785
1581
|
};
|
|
786
|
-
#rebindTextarea() {
|
|
787
|
-
if (!this.#view)
|
|
788
|
-
return;
|
|
789
|
-
this.#textarea = this.#findTextarea();
|
|
790
|
-
this.#syncToTextarea();
|
|
791
|
-
}
|
|
792
1582
|
}
|
|
1583
|
+
_a = OpenLeafEditor;
|
|
793
1584
|
/**
|
|
794
1585
|
* Re-exported so the single-file bundle can offer paste normalization without a
|
|
795
1586
|
* second script tag. Useful for custom paste handling, and for normalizing
|
|
@@ -802,6 +1593,23 @@ export { normalizePastedHtml } from '@openleaf-editor/paste';
|
|
|
802
1593
|
* `element.imageUploader` overrides it for one editor.
|
|
803
1594
|
*/
|
|
804
1595
|
export { registerFilePicker, registerImageClasses, registerImageList, registerImageUploader, registerLinkList, registerTranslations, setUiLocale, } from '@openleaf-editor/ui';
|
|
1596
|
+
/**
|
|
1597
|
+
* Re-exported because it is the documented way to add a toolbar control and it
|
|
1598
|
+
* lived in a package no install command mentions.
|
|
1599
|
+
*
|
|
1600
|
+
* `registerToolbarItem` is exported from `@openleaf-editor/ui`, which is a
|
|
1601
|
+
* transitive dependency of this package and appears in no `npm install` line in
|
|
1602
|
+
* any README. So the one extension point an integrator is most likely to reach
|
|
1603
|
+
* for was reachable only by guessing at a package name. It is already in this
|
|
1604
|
+
* bundle; re-exporting it costs nothing and means `registerToolbarItem` is
|
|
1605
|
+
* available wherever `<openleaf-editor>` is -- including from
|
|
1606
|
+
* `window.OpenLeaf` in a plain `<script>` integration, which has no other route
|
|
1607
|
+
* to it at all.
|
|
1608
|
+
*
|
|
1609
|
+
* `t` comes with it: a custom control's label is translated by the toolbar, but
|
|
1610
|
+
* anything the control builds itself has to be translated by the control.
|
|
1611
|
+
*/
|
|
1612
|
+
export { registerIcons, registerStyles, registerToolbarItem, t, } from '@openleaf-editor/ui';
|
|
805
1613
|
/** Idempotent: safe to import twice, or alongside a bundled copy. */
|
|
806
1614
|
export function defineOpenLeafEditor(tag = 'openleaf-editor') {
|
|
807
1615
|
if (typeof customElements === 'undefined')
|