u2ui 1.5.8 → 1.5.11
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/js/rte/PLAN.md +13 -0
- package/js/rte/src/client/README.md +6 -4
- package/js/rte/src/client/contextual.js +22 -0
- package/js/rte/src/client/images.js +9 -13
- package/js/rte/src/client/link.js +14 -13
- package/js/rte/src/client/tables.js +6 -11
- package/js/rte/src/command/mark.js +39 -7
- package/js/rte/src/core/core.js +6 -8
- package/js/rte/src/input/README.md +4 -1
- package/js/rte/src/input/external-input.js +8 -3
- package/js/rte/src/mark/README.md +7 -0
- package/js/rte/src/mark/dom-adapter.js +8 -1
- package/js/rte/src/mark/standard.js +2 -0
- package/js/rte/src/sanitize/README.md +7 -0
- package/js/rte/src/sanitize/policy.js +20 -3
- package/js/rte/src/ui/place.js +5 -4
- package/package.json +1 -1
package/js/rte/PLAN.md
CHANGED
|
@@ -70,6 +70,19 @@ Nothing did that afterwards — the bold mark recognizes `<b>`, but only a mark
|
|
|
70
70
|
command ever makes an element canonical — so a strict list would otherwise have
|
|
71
71
|
turned every pasted emphasis into plain text.
|
|
72
72
|
|
|
73
|
+
Not every `text/html` flavor is html. A pdf viewer offers its selection as one
|
|
74
|
+
while putting plain text with line breaks inside it, and importing that as html
|
|
75
|
+
collapsed them all into a single line. `ExternalInput` now takes a paste over
|
|
76
|
+
only when the flavor carries markup, so text stays with the browser, which
|
|
77
|
+
inserts it with its breaks intact.
|
|
78
|
+
|
|
79
|
+
Keeping the content of an unlisted element is right for almost all of them, and
|
|
80
|
+
wrong for the few a browser never renders the children of: unwrapping a `<style>`
|
|
81
|
+
from a word processor left its stylesheet standing in the text. `SanitizePolicy`
|
|
82
|
+
now carries a `drop` list — `base head link meta noscript script style template
|
|
83
|
+
title` — that `narrow()` removes with their content, while everything else keeps
|
|
84
|
+
dissolving into what the author wrote.
|
|
85
|
+
|
|
73
86
|
A native paste now meets the attribute policy. Nothing is parsed there — the
|
|
74
87
|
browser inserts its own payload — so it was the one import path without an
|
|
75
88
|
allowlist, and layout ids, tracking attributes and inline styles came straight
|
|
@@ -94,10 +94,12 @@ arrow keys move through it, Enter takes the current entry, Escape closes the lis
|
|
|
94
94
|
before it closes the form, and a pointer does the same. One question is in flight
|
|
95
95
|
at a time, and a late answer to a word that has since changed is dropped.
|
|
96
96
|
|
|
97
|
-
Contextual UI goes with the editing session and comes back with it
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
otherwise leave its handles or its
|
|
97
|
+
Contextual UI goes with the editing session and comes back with it. `follows()`
|
|
98
|
+
owns that lifecycle for every module that draws one: selection, content, and the
|
|
99
|
+
session's return, because coming back to the selection a module was already
|
|
100
|
+
showing for is no selection change and would otherwise leave its handles or its
|
|
101
|
+
form behind. A module that has more of its own to hear ends it with the same
|
|
102
|
+
signal, the way the table handles listen for typing.
|
|
101
103
|
|
|
102
104
|
Everything this client draws goes into one `Chrome`: its toolbar, the contextual
|
|
103
105
|
handles, the link form, the source dialog. A module receives it as `chrome` in
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// When a contextual UI is drawn, for every module that draws one. It follows the
|
|
2
|
+
// selection and the content of one surface, comes back with the session and goes
|
|
3
|
+
// with it — coming back to the selection it was already showing for is no
|
|
4
|
+
// selection change, so only the session's return says it belongs on screen again.
|
|
5
|
+
//
|
|
6
|
+
// What to draw is the module's own business; this only says when.
|
|
7
|
+
export function follows(surface, draw, close) {
|
|
8
|
+
const controller = new surface.element.ownerDocument.defaultView.AbortController();
|
|
9
|
+
const listen = {signal: controller.signal};
|
|
10
|
+
for (const type of ['u2-rte-selectionchange', 'u2-rte-change', 'u2-rte-activate']) {
|
|
11
|
+
surface.addEventListener(type, draw, listen);
|
|
12
|
+
}
|
|
13
|
+
surface.addEventListener('u2-rte-deactivate', close, listen);
|
|
14
|
+
return {
|
|
15
|
+
// A module with more of its own to hear ends it with the same signal.
|
|
16
|
+
signal: controller.signal,
|
|
17
|
+
dispose() {
|
|
18
|
+
controller.abort();
|
|
19
|
+
close();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {elementAttributes, selectedElement} from '../command/element.js';
|
|
2
2
|
import {inlineUi} from '../config/config.js';
|
|
3
3
|
import {caretAfter} from '../ui/caret.js';
|
|
4
|
+
import {follows} from './contextual.js';
|
|
4
5
|
import {Handles} from '../ui/handles.js';
|
|
5
6
|
import {panelGap, place} from '../ui/place.js';
|
|
6
7
|
|
|
@@ -93,18 +94,9 @@ export function imageTools({selector = 'img', minimum = 16} = {}) {
|
|
|
93
94
|
const state = editors.get(editor);
|
|
94
95
|
const view = state?.views.get(surface);
|
|
95
96
|
if (!view) throw new DOMException('The image surface is not set up', 'InvalidStateError');
|
|
96
|
-
const
|
|
97
|
-
const listen = {signal: controller.signal};
|
|
98
|
-
const sync = () => track(state, view, match);
|
|
99
|
-
surface.addEventListener('u2-rte-selectionchange', sync, listen);
|
|
100
|
-
surface.addEventListener('u2-rte-change', sync, listen);
|
|
101
|
-
// What a session's end takes away, its beginning brings back: coming
|
|
102
|
-
// back to the same selection is no selection change to hear about.
|
|
103
|
-
surface.addEventListener('u2-rte-activate', sync, listen);
|
|
104
|
-
surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
|
|
97
|
+
const following = follows(surface, () => track(state, view, match), () => close(state, surface));
|
|
105
98
|
return {dispose() {
|
|
106
|
-
|
|
107
|
-
close(state, surface);
|
|
99
|
+
following.dispose();
|
|
108
100
|
state.views.delete(surface);
|
|
109
101
|
}};
|
|
110
102
|
},
|
|
@@ -192,8 +184,12 @@ function position(state, box = null) {
|
|
|
192
184
|
for (const handle of HANDLES) {
|
|
193
185
|
state.handles.place(handle.name, rect.left + rect.width * handle.x, rect.top + rect.height * handle.y);
|
|
194
186
|
}
|
|
195
|
-
// Below the image
|
|
196
|
-
|
|
187
|
+
// Below the image and below its handles: they sit on its edges and reach
|
|
188
|
+
// past them, so what the form hangs off is the pair of them.
|
|
189
|
+
const lowest = Math.max(rect.bottom, ...HANDLES.map(handle =>
|
|
190
|
+
state.handles.button(handle.name)?.getBoundingClientRect().bottom ?? 0));
|
|
191
|
+
place(state.alt, state.active.view.surface, {align: 'start', prefer: 'below', gap: panelGap,
|
|
192
|
+
on: {left: rect.left, width: rect.width, top: rect.top, bottom: lowest}});
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
function build(state) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {inlineUi} from '../config/config.js';
|
|
2
|
+
import {rangeRect} from '../browser/range-rect.js';
|
|
2
3
|
import {linkHtml} from '../mark/standard.js';
|
|
3
4
|
import {caretAfter} from '../ui/caret.js';
|
|
5
|
+
import {follows} from './contextual.js';
|
|
4
6
|
import {panelGap, place} from '../ui/place.js';
|
|
5
7
|
import {valueMark} from '../command/mark.js';
|
|
6
8
|
|
|
@@ -124,17 +126,9 @@ export function linkEditor({
|
|
|
124
126
|
attach({editor, surface}) {
|
|
125
127
|
const state = editors.get(editor);
|
|
126
128
|
if (!state) throw new DOMException('The link extension is not set up', 'InvalidStateError');
|
|
127
|
-
const
|
|
128
|
-
const listen = {signal: controller.signal};
|
|
129
|
-
surface.addEventListener('u2-rte-selectionchange', () => follow(state, surface), listen);
|
|
130
|
-
surface.addEventListener('u2-rte-change', () => follow(state, surface), listen);
|
|
131
|
-
// What a session's end takes away, its beginning brings back: coming
|
|
132
|
-
// back to the same selection is no selection change to hear about.
|
|
133
|
-
surface.addEventListener('u2-rte-activate', () => follow(state, surface), listen);
|
|
134
|
-
surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
|
|
129
|
+
const following = follows(surface, () => follow(state, surface), () => close(state, surface));
|
|
135
130
|
return {dispose() {
|
|
136
|
-
|
|
137
|
-
close(state, surface);
|
|
131
|
+
following.dispose();
|
|
138
132
|
state.views.delete(surface);
|
|
139
133
|
}};
|
|
140
134
|
},
|
|
@@ -420,13 +414,20 @@ function position(state) {
|
|
|
420
414
|
align: 'start',
|
|
421
415
|
prefer: 'below',
|
|
422
416
|
gap: panelGap,
|
|
423
|
-
|
|
417
|
+
// What the link covers, not the box it occupies: an inline element that
|
|
418
|
+
// holds an image is a line box of text height, nowhere near the picture
|
|
419
|
+
// someone is looking at.
|
|
420
|
+
on: active.element ? rangeRect(contents(active.element), {root: active.surface.element}) : null,
|
|
424
421
|
});
|
|
425
422
|
}
|
|
426
423
|
|
|
427
|
-
// The node the surface's saved selection starts in
|
|
424
|
+
// The node the surface's saved selection starts in — or the one it holds, when
|
|
425
|
+
// that is a single element: an image's link is on the image, not on the block the
|
|
426
|
+
// range around it starts in.
|
|
428
427
|
function at(surface) {
|
|
429
|
-
|
|
428
|
+
const range = surface.selection?.range();
|
|
429
|
+
if (!range) return null;
|
|
430
|
+
return range.startContainer.childNodes?.[range.startOffset] ?? range.startContainer;
|
|
430
431
|
}
|
|
431
432
|
|
|
432
433
|
// The contents of one element, as a range.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {Tables} from '../command/table.js';
|
|
2
2
|
import {inlineUi} from '../config/config.js';
|
|
3
3
|
import {elementOf} from '../selection/ownership/ownership.js';
|
|
4
|
+
import {follows} from './contextual.js';
|
|
4
5
|
import {Handles} from '../ui/handles.js';
|
|
5
6
|
|
|
6
7
|
|
|
@@ -54,19 +55,13 @@ export function tableTools({tag = 'table'} = {}) {
|
|
|
54
55
|
const state = editors.get(editor);
|
|
55
56
|
const view = state?.views.get(surface);
|
|
56
57
|
if (!view) throw new DOMException('The table surface is not set up', 'InvalidStateError');
|
|
57
|
-
const controller = new state.document.defaultView.AbortController();
|
|
58
|
-
const listen = {signal: controller.signal};
|
|
59
58
|
const sync = () => track(state, view);
|
|
60
|
-
surface
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
surface.addEventListener('u2-rte-activate', sync, listen);
|
|
65
|
-
surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
|
|
66
|
-
surface.element.addEventListener('input', sync, listen);
|
|
59
|
+
const following = follows(surface, sync, () => close(state, surface));
|
|
60
|
+
// A table's own shape changes with what is typed in it, wherever the
|
|
61
|
+
// caret happens to be.
|
|
62
|
+
surface.element.addEventListener('input', sync, {signal: following.signal});
|
|
67
63
|
return {dispose() {
|
|
68
|
-
|
|
69
|
-
close(state, surface);
|
|
64
|
+
following.dispose();
|
|
70
65
|
state.views.delete(surface);
|
|
71
66
|
}};
|
|
72
67
|
},
|
|
@@ -189,8 +189,8 @@ function selected(edit) {
|
|
|
189
189
|
function canApply(edit, adapter, mark) {
|
|
190
190
|
if (!selected(edit)) return false;
|
|
191
191
|
const wrapper = adapter.render(mark, edit.document);
|
|
192
|
-
for (const node of edit
|
|
193
|
-
if (
|
|
192
|
+
for (const node of markable(edit, adapter)) {
|
|
193
|
+
if (has(edit, adapter, mark, node)) continue;
|
|
194
194
|
for (let element = node.parentElement; element && element !== edit.element; element = element.parentElement) {
|
|
195
195
|
if (edit.model.block(element) || isEditingBoundary(element)) break;
|
|
196
196
|
const current = adapter.parse(element);
|
|
@@ -224,8 +224,7 @@ function markState(edit, adapter, mark) {
|
|
|
224
224
|
}
|
|
225
225
|
let active = false;
|
|
226
226
|
let inactive = false;
|
|
227
|
-
for (const node of edit
|
|
228
|
-
if (!node.data || blocked(edit, node)) continue;
|
|
227
|
+
for (const node of markable(edit, adapter)) {
|
|
229
228
|
has(edit, adapter, mark, node) ? active = true : inactive = true;
|
|
230
229
|
if (active && inactive) return 'mixed';
|
|
231
230
|
}
|
|
@@ -265,8 +264,7 @@ function marksAt(edit, adapters, node) {
|
|
|
265
264
|
function marksIn(edit, adapter) {
|
|
266
265
|
const marks = [];
|
|
267
266
|
if (!edit.range) return marks;
|
|
268
|
-
for (const node of edit
|
|
269
|
-
if (!node.data || blocked(edit, node)) continue;
|
|
267
|
+
for (const node of markable(edit, adapter)) {
|
|
270
268
|
for (let element = node.parentElement; element && element !== edit.element; element = element.parentElement) {
|
|
271
269
|
if (isEditingBoundary(element) || edit.model.block(element)) break;
|
|
272
270
|
const mark = adapter.parse(element);
|
|
@@ -290,7 +288,18 @@ function apply(edit, adapter, mark, range) {
|
|
|
290
288
|
changed.push(wrapper);
|
|
291
289
|
continue;
|
|
292
290
|
}
|
|
293
|
-
if (node.nodeType !== Node.ELEMENT_NODE || isEditingBoundary(node)
|
|
291
|
+
if (node.nodeType !== Node.ELEMENT_NODE || isEditingBoundary(node)) continue;
|
|
292
|
+
// An atomic element carries a mark the way a word does: nothing can
|
|
293
|
+
// be marked inside it, so the mark goes around it.
|
|
294
|
+
if (edit.model.atomic(node)) {
|
|
295
|
+
if (!covered(range, node) || !wrappable(edit, adapter, mark, node)) continue;
|
|
296
|
+
const wrapper = adapter.render(mark, edit.document);
|
|
297
|
+
if (!edit.model.allows(parent, wrapper)) continue;
|
|
298
|
+
edit.map.wrap([node], wrapper);
|
|
299
|
+
edit.transaction.touch(wrapper);
|
|
300
|
+
changed.push(wrapper);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
294
303
|
if (covered(range, node) && reusable(edit, adapter, node)) {
|
|
295
304
|
const current = adapter.parse(node);
|
|
296
305
|
if (current && !current.conflicts(mark)) {
|
|
@@ -491,6 +500,29 @@ function marked(edit, adapter, mark, node) {
|
|
|
491
500
|
return found;
|
|
492
501
|
}
|
|
493
502
|
|
|
503
|
+
// What a mark can sit on inside the range: the text it formats, and — for a mark
|
|
504
|
+
// about content — the atomic elements that have no text to format. Everything
|
|
505
|
+
// asking what is marked, what could be, and what to replace asks this.
|
|
506
|
+
function markable(edit, adapter) {
|
|
507
|
+
if (!edit.range) return [];
|
|
508
|
+
return [...edit.range.textNodes().filter(node => node.data && !blocked(edit, node)),
|
|
509
|
+
...atomics(edit, adapter)];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The atomic elements in the range a content mark can sit on: nothing inside one
|
|
513
|
+
// can carry a mark, so the mark is around it — and a text mark has no business
|
|
514
|
+
// with them at all.
|
|
515
|
+
function atomics(edit, adapter) {
|
|
516
|
+
if (adapter.covers !== 'content' || !edit.range) return [];
|
|
517
|
+
return edit.range.roots().filter(node => node.nodeType === Node.ELEMENT_NODE
|
|
518
|
+
&& !isEditingBoundary(node) && edit.model.atomic(node));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function wrappable(edit, adapter, mark, node) {
|
|
522
|
+
return adapter.covers === 'content' && node?.nodeType === Node.ELEMENT_NODE
|
|
523
|
+
&& !isEditingBoundary(node) && edit.model.atomic(node) && !has(edit, adapter, mark, node);
|
|
524
|
+
}
|
|
525
|
+
|
|
494
526
|
function has(edit, adapter, mark, node) {
|
|
495
527
|
for (let element = elementOf(node); element && element !== edit.element; element = element.parentElement) {
|
|
496
528
|
if (isEditingBoundary(element) || edit.model.block(element)) return false;
|
package/js/rte/src/core/core.js
CHANGED
|
@@ -12,8 +12,7 @@ export class Rte extends EventTarget {
|
|
|
12
12
|
#retained = new Set();
|
|
13
13
|
#active = null;
|
|
14
14
|
#unfocused = false;
|
|
15
|
-
#
|
|
16
|
-
#handed = false;
|
|
15
|
+
#press = null;
|
|
17
16
|
#controller;
|
|
18
17
|
|
|
19
18
|
constructor(root = document, options = {}) {
|
|
@@ -173,15 +172,15 @@ export class Rte extends EventTarget {
|
|
|
173
172
|
// on that element — a host's own handler, a css `:focus` rule — sees it
|
|
174
173
|
// too. Leaving it focused instead is worse: a caret that types into a
|
|
175
174
|
// surface with no session behind it.
|
|
176
|
-
if (surface && this.#
|
|
175
|
+
if (surface && this.#press && !this.#press.path.includes(surface.element)) {
|
|
177
176
|
this.#unfocused = true;
|
|
178
177
|
this.activate(null);
|
|
179
178
|
// Handing the focus back once per press. An engine that answers a
|
|
180
179
|
// blur by focusing again — the selection is still in there, after
|
|
181
180
|
// all — would otherwise trade focus with the editor for as long as
|
|
182
181
|
// the button is down, and the session stays refused either way.
|
|
183
|
-
if (!this.#handed) {
|
|
184
|
-
this.#handed = true;
|
|
182
|
+
if (!this.#press.handed) {
|
|
183
|
+
this.#press.handed = true;
|
|
185
184
|
editable.blur();
|
|
186
185
|
}
|
|
187
186
|
return;
|
|
@@ -207,8 +206,7 @@ export class Rte extends EventTarget {
|
|
|
207
206
|
|
|
208
207
|
#pointerDown = event => {
|
|
209
208
|
const path = event.composedPath();
|
|
210
|
-
this.#
|
|
211
|
-
this.#handed = false;
|
|
209
|
+
this.#press = {path, handed: false};
|
|
212
210
|
const editing = this.#editing(path[0]);
|
|
213
211
|
// The right button opens a menu about what is selected; moving the
|
|
214
212
|
// selection to what it was aimed at is what makes the menu useless.
|
|
@@ -233,7 +231,7 @@ export class Rte extends EventTarget {
|
|
|
233
231
|
}
|
|
234
232
|
|
|
235
233
|
#pointerDone = () => {
|
|
236
|
-
this.#
|
|
234
|
+
this.#press = null;
|
|
237
235
|
};
|
|
238
236
|
|
|
239
237
|
#selectionChange = () => {
|
|
@@ -103,7 +103,10 @@ const external = new ExternalInput(surface, {
|
|
|
103
103
|
|
|
104
104
|
For `insertFromPaste` and `insertFromDrop`, the contenteditable Input Events
|
|
105
105
|
contract supplies rich and plain data in `dataTransfer` plus the DOM range that
|
|
106
|
-
would be replaced. The adapter takes over only when `text/html` is present
|
|
106
|
+
would be replaced. The adapter takes over only when `text/html` is present and
|
|
107
|
+
carries markup: a pdf viewer labels its plain selection `text/html`, and
|
|
108
|
+
importing that as html would collapse the line breaks the text flavor keeps, so
|
|
109
|
+
a flavor without a single tag is left to the browser. It
|
|
107
110
|
prevents native insertion first, sanitizes into a detached `DocumentFragment`,
|
|
108
111
|
optionally cleans presentation cumulatively through the selected level, and
|
|
109
112
|
passes the result and exact target range to the registered command. Drop thus
|
|
@@ -97,7 +97,7 @@ export class ExternalInput {
|
|
|
97
97
|
#beforeInput = event => {
|
|
98
98
|
if (!this.#owns(event) || !INPUTS.has(event.inputType) || !event.cancelable
|
|
99
99
|
|| event.defaultPrevented || event.isComposing || isPlainTextHost(this.#root)) return;
|
|
100
|
-
if (!
|
|
100
|
+
if (!hasMarkup(event.dataTransfer)) return;
|
|
101
101
|
event.preventDefault();
|
|
102
102
|
try {
|
|
103
103
|
const html = event.dataTransfer.getData('text/html');
|
|
@@ -123,8 +123,13 @@ export class ExternalInput {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// An html flavor carrying no markup is text that was labelled html — a pdf viewer offers its
|
|
127
|
+
// selection that way. Importing it as html would collapse the line breaks it carries, while the
|
|
128
|
+
// browser inserts the text flavor with them intact, so that one stays with the browser.
|
|
129
|
+
function hasMarkup(transfer) {
|
|
127
130
|
if (!transfer || typeof transfer.getData !== 'function') return false;
|
|
128
131
|
const types = [...(transfer.types || [])].map(type => String(type).toLowerCase());
|
|
129
|
-
|
|
132
|
+
if (!types.includes('text/html')) return false;
|
|
133
|
+
if (!types.includes('text/plain')) return true;
|
|
134
|
+
return /<[a-z!/]/i.test(transfer.getData('text/html'));
|
|
130
135
|
}
|
|
@@ -17,6 +17,13 @@ an attribute, a style, or something custom. `standard.js` provides the compact
|
|
|
17
17
|
default HTML policies for bold, italic, underline, strike, code, and links. The
|
|
18
18
|
range commands apply or remove those representations on selected text.
|
|
19
19
|
|
|
20
|
+
## What a mark is about
|
|
21
|
+
|
|
22
|
+
A mark covers `text` by default: bold, italic, a content class. `covers:
|
|
23
|
+
"content"` says the other kind — a link is not about the letters but about what
|
|
24
|
+
they point at, so it also goes around content that has no text of its own. An
|
|
25
|
+
image can be a link; an emphasized image is nothing, and stays unmarked.
|
|
26
|
+
|
|
20
27
|
## Public contract
|
|
21
28
|
|
|
22
29
|
`MarkType` describes one kind of formatting. `bold`, `link`, and `color` are
|
|
@@ -10,8 +10,9 @@ export class MarkAdapter {
|
|
|
10
10
|
#write;
|
|
11
11
|
#clear;
|
|
12
12
|
#reuse;
|
|
13
|
+
#covers;
|
|
13
14
|
|
|
14
|
-
constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render} = {}) {
|
|
15
|
+
constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render, covers = 'text'} = {}) {
|
|
15
16
|
if (!(type instanceof MarkType)) throw new TypeError('A mark adapter requires a mark type');
|
|
16
17
|
if (typeof selector !== 'string' || !selector.trim()) {
|
|
17
18
|
throw new TypeError('A mark adapter requires a non-empty selector');
|
|
@@ -25,6 +26,7 @@ export class MarkAdapter {
|
|
|
25
26
|
if (reuse && !write) throw new TypeError('A reusable mark adapter requires write');
|
|
26
27
|
if (render !== undefined && typeof render !== 'function') throw new TypeError('A mark renderer must be a function');
|
|
27
28
|
if (render && tag !== undefined) throw new TypeError('A custom mark renderer cannot be combined with tag');
|
|
29
|
+
if (covers !== 'text' && covers !== 'content') throw new TypeError('A mark covers "text" or "content"');
|
|
28
30
|
if (!render && (typeof tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(tag))) {
|
|
29
31
|
throw new TypeError('A mark adapter requires a lowercase HTML tag');
|
|
30
32
|
}
|
|
@@ -34,6 +36,7 @@ export class MarkAdapter {
|
|
|
34
36
|
this.#write = write;
|
|
35
37
|
this.#clear = clear;
|
|
36
38
|
this.#reuse = reuse;
|
|
39
|
+
this.#covers = covers;
|
|
37
40
|
this.#render = render || ((document, value) => {
|
|
38
41
|
const element = document.createElement(tag);
|
|
39
42
|
write?.(element, value);
|
|
@@ -44,6 +47,10 @@ export class MarkAdapter {
|
|
|
44
47
|
get type() { return this.#type; }
|
|
45
48
|
get selector() { return this.#selector; }
|
|
46
49
|
get reusable() { return !!this.#reuse; }
|
|
50
|
+
// What the mark is about: the text it formats, or the content it applies to.
|
|
51
|
+
// A link is the second kind — an image can be one, an emphasized image is
|
|
52
|
+
// nothing — and only that kind goes around content with no text of its own.
|
|
53
|
+
get covers() { return this.#covers; }
|
|
47
54
|
get removable() { return !!this.#clear; }
|
|
48
55
|
|
|
49
56
|
parse(element) {
|
|
@@ -18,6 +18,8 @@ export const codeHtml = element(code, 'code', 'code');
|
|
|
18
18
|
export const linkHtml = new MarkAdapter(link, {
|
|
19
19
|
selector: 'a[href]',
|
|
20
20
|
tag: 'a',
|
|
21
|
+
// A link is about the content, not about its text: an image can be one.
|
|
22
|
+
covers: 'content',
|
|
21
23
|
read: readLink,
|
|
22
24
|
write: writeLink,
|
|
23
25
|
clear: clearLink,
|
|
@@ -13,8 +13,15 @@ repairs editable document shape.
|
|
|
13
13
|
- `elements`: element names the security policy can accept;
|
|
14
14
|
- `attributes`: global (`*`) and per-element attribute names;
|
|
15
15
|
- `protocols`: per-element rules for URL attributes;
|
|
16
|
+
- `drop`: element names that `narrow()` removes with their content;
|
|
16
17
|
- `comments` and `dataAttributes`: explicit booleans, both false by default.
|
|
17
18
|
|
|
19
|
+
An element outside `elements` keeps its content — dropping it would lose text the
|
|
20
|
+
author wrote. `drop` names the exception: elements a browser never renders the
|
|
21
|
+
children of, where unwrapping would turn a stylesheet, a script, or document
|
|
22
|
+
metadata into visible text. It defaults to `base head link meta noscript script
|
|
23
|
+
style template title` and, like every default, an application may replace it.
|
|
24
|
+
|
|
18
25
|
The supplied `sanitizePolicy` accepts the `document` element preset, ordinary
|
|
19
26
|
editor metadata, and conservative web URLs. An image may carry a `data:` URL,
|
|
20
27
|
because an image executes nothing — not even an SVG one, which browsers draw in
|
|
@@ -27,10 +27,18 @@ const DEFAULT_PROTOCOLS = Object.freeze({
|
|
|
27
27
|
q: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// Elements a browser never renders the children of. Unwrapping one turns source — a stylesheet, a
|
|
31
|
+
// script, document metadata — into visible text, so these go with their content. Every other unknown
|
|
32
|
+
// element keeps its content: dropping it would lose text the author wrote.
|
|
33
|
+
const DEFAULT_DROP = Object.freeze([
|
|
34
|
+
'base', 'head', 'link', 'meta', 'noscript', 'script', 'style', 'template', 'title',
|
|
35
|
+
]);
|
|
36
|
+
|
|
30
37
|
export const sanitizeDefaults = Object.freeze({
|
|
31
38
|
elements: elementPresets.document,
|
|
32
39
|
attributes: DEFAULT_ATTRIBUTES,
|
|
33
40
|
protocols: DEFAULT_PROTOCOLS,
|
|
41
|
+
drop: DEFAULT_DROP,
|
|
34
42
|
comments: false,
|
|
35
43
|
dataAttributes: false,
|
|
36
44
|
});
|
|
@@ -41,6 +49,7 @@ export class SanitizePolicy {
|
|
|
41
49
|
|
|
42
50
|
constructor(options = {}) {
|
|
43
51
|
this.elements = names(options.elements ?? sanitizeDefaults.elements, NAME, 'element');
|
|
52
|
+
this.drop = names(options.drop ?? sanitizeDefaults.drop, NAME, 'element');
|
|
44
53
|
this.#attributes = groups(options.attributes ?? sanitizeDefaults.attributes, ATTRIBUTE, 'attribute');
|
|
45
54
|
this.#protocols = protocolGroups(options.protocols ?? sanitizeDefaults.protocols);
|
|
46
55
|
this.attributeNames = Object.freeze(unique(Object.values(this.#attributes).flat()));
|
|
@@ -69,8 +78,9 @@ export class SanitizePolicy {
|
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
// Reduces a subtree to the elements this policy allows, keeping the content
|
|
72
|
-
// of the rest
|
|
73
|
-
// inserted itself has to be
|
|
81
|
+
// of the rest — except the `drop` elements, which go with theirs. Parsed input
|
|
82
|
+
// gets this from the safe sink; markup the browser inserted itself has to be
|
|
83
|
+
// narrowed afterwards.
|
|
74
84
|
//
|
|
75
85
|
// `skip` leaves an element to a later stage. Structural repair already
|
|
76
86
|
// removes what the content model rejects, and it does so knowing that
|
|
@@ -86,8 +96,15 @@ export class SanitizePolicy {
|
|
|
86
96
|
: this.elements.filter(name => elements.includes(name));
|
|
87
97
|
const changed = [];
|
|
88
98
|
for (const element of descendants(root).reverse()) {
|
|
89
|
-
if (preserve?.has(element)
|
|
99
|
+
if (preserve?.has(element)) continue;
|
|
90
100
|
if (!element.parentNode || skip?.(element)) continue;
|
|
101
|
+
if (this.drop.includes(element.localName)) {
|
|
102
|
+
if (map) map.remove(element);
|
|
103
|
+
else element.remove();
|
|
104
|
+
changed.push(element);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (allowed.includes(element.localName)) continue;
|
|
91
108
|
const equivalent = alias?.[element.localName];
|
|
92
109
|
if (equivalent && allowed.includes(equivalent)) {
|
|
93
110
|
const replacement = element.ownerDocument.createElement(equivalent);
|
package/js/rte/src/ui/place.js
CHANGED
|
@@ -6,10 +6,11 @@ import {rangeRect} from '../browser/range-rect.js';
|
|
|
6
6
|
//
|
|
7
7
|
// A UI that belongs to one element rather than to the selection passes that
|
|
8
8
|
// element's rect as `on`: what it points at should not shift with the caret.
|
|
9
|
-
// One distance for every panel that hangs off
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
9
|
+
// One distance for every panel that hangs off something, and between two of them
|
|
10
|
+
// — near enough to read as belonging to it. What must be cleared belongs in the
|
|
11
|
+
// rect a caller anchors on, not in this number: a frame's handles reach past the
|
|
12
|
+
// element they sit on, and the panel below them is told so.
|
|
13
|
+
export const panelGap = 8;
|
|
13
14
|
|
|
14
15
|
export function place(element, surface, {align = 'center', prefer = 'above', gap = 28, on = null} = {}) {
|
|
15
16
|
const range = on ? null : surface.selection?.range();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u2ui",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.11",
|
|
4
4
|
"description": "HTML Advanced: More Capabilities, Same Feel, Same Simplicity",
|
|
5
5
|
"keywords": ["web-components","pattern-library","component-library","ui-toolkit","design-system"],
|
|
6
6
|
"author": "Tobias Buschor",
|