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