@xoong/richtext 0.0.0-stage → 0.0.35

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @xoong/richtext
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small rich text editor for xoong forms: sanitised HTML in and out, an accessible toolbar in two sizes, a media hook. Only depends on `@xoong/core`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/richtext
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```js
14
+ import '@xoong/richtext';
15
+ ```
16
+ ```html
17
+ <xo-richtext name="body" label="Body" media></xo-richtext>
18
+ ```
19
+ `@xoong/richtext/sanitize` exports the sanitiser, so your server can clean the HTML again on save.
20
+
21
+ ## More
22
+
23
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
24
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/richtext
25
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
26
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
27
+
28
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@xoong/richtext",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.35",
4
+ "description": "A small rich text editor for xoong forms: contenteditable, no dependencies besides @xoong/core, sanitised HTML in and out. Toolbar, headings, bold, italic, lists, quote, links (http, https, mailto, tel only), media hook.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./src/index.js"
10
+ },
11
+ "./sanitize": "./src/sanitize.js",
12
+ "./richtext.css": "./src/richtext.css"
13
+ },
14
+ "sideEffects": true,
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "keywords": [
19
+ "xoong",
20
+ "richtext",
21
+ "editor",
22
+ "contenteditable",
23
+ "sanitize"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
30
+ "directory": "packages/richtext"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
34
+ },
35
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
36
+ "dependencies": {
37
+ "@xoong/core": "0.0.35"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.12"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Importing the package registers <xo-richtext>.
2
+ import './richtext.js';
3
+ export { sanitizeHtml, isSafeLink, isSafeImage, textOf } from './sanitize.js';
@@ -0,0 +1,45 @@
1
+ /* Optional styles for @xoong/richtext, in one @layer, using the --xo-* tokens of @xoong/ui with fallbacks. */
2
+ @layer xoong-richtext {
3
+ xo-richtext { display: block; }
4
+ .xo-richtext-box { border: 1px solid var(--xo-color-control-border, #838c9b); border-radius: var(--xo-radius, 6px); background: var(--xo-color-surface, #fff); }
5
+ .xo-richtext-box:has(.xo-richtext-editable:focus) { outline: 2px solid var(--xo-color-accent, #0969da); outline-offset: 1px; }
6
+ /* one row, never wrapped and never scrolled sideways: the full toolbar fits from 580px of the editor's width, the compact one below it (the More menu holds the rest) */
7
+ .xo-richtext-toolbar { display: flex; flex-wrap: nowrap; align-items: center; gap: 2px; padding: var(--xo-space-1, .25rem); border-bottom: 1px solid var(--xo-color-border, #d8dee4); }
8
+ .xo-richtext-toolbar [hidden] { display: none !important; }
9
+ .xo-richtext-toolbar select { height: 2rem; border: 1px solid transparent; border-radius: 4px; background: none; font: inherit; color: inherit; cursor: pointer; max-width: 9rem; }
10
+ .xo-richtext-toolbar select:hover:not(:disabled) { background: var(--xo-color-hover, rgb(127 127 127 / .18)); }
11
+ .xo-richtext-toolbar button { flex: none; }
12
+ .xo-richtext-toolbar[data-size="compact"] select { width: 4.25rem; } /* P, H2, H3, Mixed */
13
+ .xo-richtext-toolbar button[data-face]::before { content: attr(data-face); }
14
+ .xo-richtext-more [data-face]::before { content: attr(data-face) " "; white-space: pre; }
15
+ .xo-richtext-morewrap { position: relative; flex: none; }
16
+ .xo-richtext-more { position: absolute; inset-inline-end: 0; top: 100%; z-index: 10; min-width: 11rem; padding: var(--xo-space-1, .25rem); background: var(--xo-color-surface, #fff); border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); box-shadow: 0 6px 18px rgb(0 0 0 / .15); }
17
+ .xo-richtext-more [role^="menuitem"]:focus-visible { outline: var(--xo-focus-thin, 2px solid var(--xo-color-accent, #0969da)); outline-offset: -2px; }
18
+ .xo-richtext-more [role^="menuitem"] { display: block; width: 100%; height: auto; padding: var(--xo-space-2, .5rem) var(--xo-space-3, .75rem); text-align: start; font-weight: 400; white-space: nowrap; }
19
+ .xo-richtext-toolbar button { min-width: 1.75rem; min-height: 0; height: 2rem; padding-block: 0; border: 0; border-radius: 4px; background: none; cursor: pointer; font: inherit; font-weight: 600; color: inherit; }
20
+ .xo-richtext-toolbar button:hover:not(:disabled) { background: var(--xo-color-hover, rgb(127 127 127 / .18)); }
21
+ .xo-richtext-toolbar button[aria-pressed="true"], .xo-richtext-more [aria-checked="true"] { background: var(--xo-color-hover, rgb(127 127 127 / .3)); }
22
+ .xo-richtext-toolbar button:disabled { opacity: .4; cursor: not-allowed; }
23
+ .xo-richtext-link { display: flex; flex-wrap: wrap; gap: var(--xo-space-2, .5rem); align-items: center; padding: var(--xo-space-2, .5rem); border-bottom: 1px solid var(--xo-color-border, #d8dee4); }
24
+ .xo-richtext-link[hidden] { display: none; }
25
+ .xo-richtext-link input { flex: 1 1 14rem; font: inherit; }
26
+ .xo-richtext-error { flex-basis: 100%; color: var(--xo-color-danger, #cf222e); }
27
+ .xo-richtext-error:empty { display: none; }
28
+ .xo-richtext-editable { min-height: 10rem; padding: var(--xo-space-3, .75rem); outline: none; overflow-wrap: anywhere; }
29
+ .xo-richtext-editable[data-empty]::before { content: attr(data-placeholder); opacity: .55; pointer-events: none; position: absolute; }
30
+ .xo-richtext-editable > :first-child { margin-top: 0; }
31
+ .xo-richtext-editable > :last-child { margin-bottom: 0; }
32
+ .xo-richtext-editable blockquote { margin-inline: 0; padding-inline-start: 1rem; border-inline-start: 3px solid var(--xo-color-border, #d8dee4); }
33
+ .xo-richtext-editable img { max-width: 100%; height: auto; }
34
+ xo-richtext[data-invalid] .xo-richtext-box { border-color: var(--xo-color-danger, #cf222e); }
35
+
36
+ /* expanded: the same editor in a dialog, 960px wide, the whole screen on narrow ones */
37
+ .xo-richtext-dialog { box-sizing: border-box; width: min(960px, calc(100vw - 2rem)); max-width: none; max-height: calc(100dvh - 2rem); padding: 0; display: none; flex-direction: column; border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); background: var(--xo-color-surface, #fff); color: inherit; }
38
+ .xo-richtext-dialog[open] { display: flex; }
39
+ .xo-richtext-dialog-bar { display: flex; justify-content: flex-end; padding: var(--xo-space-2, .5rem); border-bottom: 1px solid var(--xo-color-border, #d8dee4); }
40
+ .xo-richtext-dialog .xo-richtext-box { border: 0; border-radius: 0; flex: 1; display: flex; flex-direction: column; min-height: 0; }
41
+ .xo-richtext-dialog .xo-richtext-editable { flex: 1; min-height: 50dvh; overflow: auto; }
42
+ @media (max-width: 767px) {
43
+ .xo-richtext-dialog { width: 100vw; height: 100dvh; max-height: none; margin: 0; border: 0; border-radius: 0; }
44
+ }
45
+ }
@@ -0,0 +1,637 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-richtext> is a small rich text editor and a form control: its value is sanitised HTML.
4
+ *
5
+ * <xo-richtext name="body" label="Body" placeholder="Write something"></xo-richtext>
6
+ * <xo-richtext name="body" media tools="bold italic h2 ul ol quote link image undo redo"></xo-richtext>
7
+ *
8
+ * - A `contenteditable` area with a toolbar (`role="toolbar"`, one tab stop, Left/Right/Home/End move inside it). Tools: undo, redo, style (a select: Paragraph,
9
+ * Heading 2, Heading 3), bold, italic, underline, ul, ol, quote, link, image (h2 as a toggle is also available); the `tools` attribute picks and orders them.
10
+ * `image` is only shown with the `media` attribute.
11
+ * - The toolbar has two sizes and never wraps or scrolls sideways, chosen by the editor's own width (not the viewport's): Full from 580px, Compact below it
12
+ * (style, bold, italic, bulleted list, link, image and a More menu that holds underline, numbered list, quote, undo, redo). `toolbar="full"` or
13
+ * `"compact"` forces one.
14
+ * - `expand()` opens the same editor in a 960px dialog (the whole screen under 768px); Done and Escape close it, `collapse()` too. Events `xo-richtext-expand`
15
+ * and `xo-richtext-done`. Nothing is saved by it: the host places its own Expand button and calls `expand()`.
16
+ * - Typing and the toolbar use the browser's own editing commands, so undo, redo, IME, spell checking, Ctrl/Cmd+B and +I and the platform's
17
+ * shortcuts all work. Ctrl/Cmd+K opens the link box.
18
+ * - The value is `sanitizeHtml()` of the content (see sanitize.js): p, h2, h3, strong, em, ul, ol, li, blockquote, br, a, img only. Paste and drop are
19
+ * sanitised before they enter the editor; formatting from Word or a web page does not come along.
20
+ * - Links: the link box accepts http, https, mailto and tel (an address without a scheme becomes https:// or mailto:); anything else is refused.
21
+ * - Media: the image tool fires a cancelable `xo-pick-media` event with `detail.insert({ src, alt })`. Open your media library in a listener, call
22
+ * `preventDefault()`, and call `insert` when the person has chosen (now or later). `el.insertImage(src, alt)` does the same from code.
23
+ * - It is an `XoControl`: `name`, `required` (an empty editor is empty), `disabled`, `readonly`, `dirty`, `touched`, validity. `maxlength` counts text characters.
24
+ */
25
+ import { XoControl, define, text } from '@xoong/core';
26
+ import { sanitizeHtml, isSafeLink, textOf } from './sanitize.js';
27
+
28
+ let counter = 0;
29
+
30
+ const TOOLS = {
31
+ bold: { label: () => text('xoong.richtext.bold', 'Bold'), face: 'B', state: () => query('bold') },
32
+ italic: { label: () => text('xoong.richtext.italic', 'Italic'), face: 'I', state: () => query('italic') },
33
+ underline: { label: () => text('xoong.richtext.underline', 'Underline'), face: 'U', state: () => query('underline') },
34
+ style: { label: () => text('xoong.richtext.style', 'Text style') },
35
+ more: { label: () => text('xoong.richtext.more', 'More'), face: '⋯' },
36
+ h2: { label: () => text('xoong.richtext.h2', 'Heading'), face: 'H2', state: () => block() === 'h2' },
37
+ ul: { label: () => text('xoong.richtext.ul', 'Bulleted list'), face: '•', state: () => query('insertUnorderedList') },
38
+ ol: { label: () => text('xoong.richtext.ol', 'Numbered list'), face: '1.', state: () => query('insertOrderedList') },
39
+ quote: { label: () => text('xoong.richtext.quote', 'Quote'), face: '“', state: () => block() === 'blockquote' },
40
+ link: { label: () => text('xoong.richtext.link', 'Link'), face: '🔗', state: () => !!anchorAtSelection() },
41
+ image: { label: () => text('xoong.richtext.image', 'Image'), face: '🖼' },
42
+ undo: { label: () => text('xoong.richtext.undo', 'Undo'), face: '↶' },
43
+ redo: { label: () => text('xoong.richtext.redo', 'Redo'), face: '↷' },
44
+ };
45
+ const DEFAULT_TOOLS = 'undo redo style bold italic underline ul ol quote link image';
46
+ /** In the compact toolbar these tools move into the "More" menu (in this order). */
47
+ const MORE_ITEMS = ['underline', 'ol', 'quote', 'undo', 'redo'];
48
+ /** The editor is "full" from this width (its own width, not the viewport's). */
49
+ const FULL_FROM = 580;
50
+ const STYLE_OPTIONS = /** @type {[string, () => string, string][]} */ ([
51
+ ['p', () => text('xoong.richtext.paragraph', 'Paragraph'), 'P'],
52
+ ['h2', () => text('xoong.richtext.heading2', 'Heading 2'), 'H2'],
53
+ ['h3', () => text('xoong.richtext.heading3', 'Heading 3'), 'H3'],
54
+ ['mixed', () => text('xoong.richtext.mixed', 'Mixed'), 'Mixed'],
55
+ ]);
56
+
57
+ const query = (/** @type {string} */ command) => { try { return document.queryCommandState?.(command) ?? false; } catch { return false; } };
58
+ const block = () => { try { return String(document.queryCommandValue?.('formatBlock') ?? '').toLowerCase().replace(/[<>]/g, ''); } catch { return ''; } };
59
+ const exec = (/** @type {string} */ command, /** @type {string} */ value = '') => { try { return document.execCommand?.(command, false, value) ?? false; } catch { return false; } };
60
+ const esc = (/** @type {string} */ s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
61
+ /** The position of a node inside `root` as child indexes, and back: a selection that survives the editor being moved (a moved node loses its ranges). */
62
+ const pathOf = (/** @type {Node} */ root, /** @type {Node | null} */ node) => { /** @type {number[]} */ const path = []; for (let n = node; n && n !== root; n = n.parentNode) path.unshift([.../** @type {Node} */ (n.parentNode).childNodes].indexOf(/** @type {ChildNode} */ (n))); return path; };
63
+ const nodeAt = (/** @type {Node} */ root, /** @type {number[]} */ path) => path.reduce((/** @type {Node | undefined} */ n, i) => n?.childNodes[i], root);
64
+ function anchorAtSelection() {
65
+ const node = getSelection()?.anchorNode;
66
+ const el = node instanceof Element ? node : node?.parentElement;
67
+ return el?.closest('a') ?? null;
68
+ }
69
+
70
+ class XoRichtext extends XoControl {
71
+ static props = { ...XoControl.props, label: String, placeholder: String, tools: String, toolbar: String, media: Boolean, maxlength: Number };
72
+
73
+ /** @type {HTMLElement | null} */ #editor = null;
74
+ /** @type {HTMLElement | null} */ #toolbar = null;
75
+ /** @type {HTMLElement | null} */ #box = null;
76
+ /** @type {HTMLElement | null} */ #more = null;
77
+ /** @type {ResizeObserver | null} */ #resize = null;
78
+ #size = '';
79
+ #moving = false;
80
+ /** @type {HTMLDialogElement | null} */ #dialog = null;
81
+ /** @type {Comment | null} */ #marker = null;
82
+ /** @type {HTMLElement | null} */ #linkBox = null;
83
+ /** @type {HTMLInputElement | null} */ #linkInput = null;
84
+ /** @type {HTMLElement | null} */ #linkError = null;
85
+ /** @type {Range | null} */ #saved = null;
86
+ #fromUser = false;
87
+ #built = false;
88
+ #id = `xo-richtext-${++counter}`;
89
+ #onPointer = (/** @type {Event} */ event) => { if (this.#more && !this.#more.hidden && !/** @type {Element} */ (event.target).closest('.xo-richtext-morewrap')) this.#closeMore(); };
90
+ #onSelection = () => { if (this.#editor && this.#box?.contains(getSelection()?.anchorNode ?? null)) this.#refreshState(); };
91
+
92
+ get focusTarget() { return this.#editor; }
93
+
94
+ /** Moving the editor into or out of its dialog moves the focus too, which is not the person leaving the field. */
95
+ markTouched() {
96
+ if (this.#dialog || this.#moving) return;
97
+ super.markTouched();
98
+ }
99
+
100
+ setValue(v) {
101
+ const clean = sanitizeHtml(typeof v === 'string' ? v : v == null ? '' : String(v));
102
+ super.setValue(clean);
103
+ if (this.#built && !this.#fromUser && this.#editor) this.#show(clean);
104
+ }
105
+
106
+ /** Back to the start value, in the editor too (the base class resets the value without going through setValue). */
107
+ reset() {
108
+ super.reset();
109
+ if (this.#built) this.#show(String(this.value));
110
+ }
111
+
112
+ validate(value) {
113
+ const limit = this.maxlength;
114
+ if (limit > 0 && textOf(String(value)).length > limit) return text('xoong.richtext.tooLong', 'Use at most {max} characters.', { max: limit });
115
+ return '';
116
+ }
117
+
118
+ isEmpty(v) { return !v || (textOf(String(v)).trim() === '' && !/<img\b/.test(String(v))); }
119
+
120
+ // ---- lifecycle -----------------------------------------------------------------------------------------------------------
121
+
122
+ connected() {
123
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this.#build(), { once: true });
124
+ else queueMicrotask(() => this.#build());
125
+ document.addEventListener('selectionchange', this.#onSelection);
126
+ document.addEventListener('pointerdown', this.#onPointer);
127
+ }
128
+
129
+ disconnected() { document.removeEventListener('selectionchange', this.#onSelection); document.removeEventListener('pointerdown', this.#onPointer); this.#resize?.disconnect(); }
130
+
131
+ #build() {
132
+ if (this.#built || !this.isConnected) return;
133
+ const initial = this.value !== '' ? String(this.value) : this.innerHTML.trim(); // markup inside the element is the start value
134
+ this.replaceChildren();
135
+
136
+ const box = document.createElement('div');
137
+ box.className = 'xo-richtext-box';
138
+ const toolbar = document.createElement('div');
139
+ toolbar.className = 'xo-richtext-toolbar';
140
+ toolbar.setAttribute('role', 'toolbar');
141
+ toolbar.setAttribute('aria-label', text('xoong.richtext.toolbar', 'Formatting'));
142
+ toolbar.setAttribute('aria-controls', `${this.#id}-editor`);
143
+ const names = (this.tools || DEFAULT_TOOLS).split(/[\s,]+/).filter((n) => n in TOOLS && n !== 'more' && (n !== 'image' || this.media));
144
+ names.forEach((name, i) => toolbar.append(this.#item(name, i === 0)));
145
+ const moved = MORE_ITEMS.filter((n) => names.includes(n));
146
+ if (moved.length) { // the compact toolbar keeps these in a menu
147
+ const wrap = document.createElement('div');
148
+ wrap.className = 'xo-richtext-morewrap';
149
+ wrap.hidden = true;
150
+ const more = this.#item('more', false);
151
+ const menu = document.createElement('div');
152
+ menu.className = 'xo-richtext-more';
153
+ menu.id = `${this.#id}-more`;
154
+ menu.setAttribute('role', 'menu');
155
+ menu.setAttribute('aria-label', TOOLS.more.label());
156
+ menu.hidden = true;
157
+ more.setAttribute('aria-haspopup', 'menu');
158
+ more.setAttribute('aria-expanded', 'false');
159
+ more.setAttribute('aria-controls', menu.id);
160
+ for (const name of moved) menu.append(this.#item(name, false, true));
161
+ wrap.append(more, menu);
162
+ toolbar.append(wrap);
163
+ this.#more = menu;
164
+ }
165
+
166
+ const linkBox = document.createElement('div');
167
+ linkBox.className = 'xo-richtext-link';
168
+ linkBox.hidden = true;
169
+ const linkInput = document.createElement('input');
170
+ linkInput.type = 'text';
171
+ linkInput.inputMode = 'url';
172
+ linkInput.setAttribute('aria-label', text('xoong.richtext.linkUrl', 'Link address'));
173
+ linkInput.placeholder = 'https://';
174
+ const apply = document.createElement('button');
175
+ apply.type = 'button'; apply.dataset.action = 'apply'; apply.textContent = text('xoong.richtext.apply', 'Apply');
176
+ const remove = document.createElement('button');
177
+ remove.type = 'button'; remove.dataset.action = 'remove'; remove.textContent = text('xoong.richtext.removeLink', 'Remove link');
178
+ const error = document.createElement('small');
179
+ error.className = 'xo-richtext-error';
180
+ error.setAttribute('role', 'alert');
181
+ linkBox.append(linkInput, apply, remove, error);
182
+
183
+ const editor = document.createElement('div');
184
+ editor.id = `${this.#id}-editor`;
185
+ editor.className = 'xo-richtext-editable';
186
+ editor.setAttribute('role', 'textbox');
187
+ editor.setAttribute('aria-multiline', 'true');
188
+ editor.setAttribute('aria-label', this.label || this.name || text('xoong.richtext.editor', 'Rich text'));
189
+ if (this.placeholder) editor.dataset.placeholder = this.placeholder;
190
+
191
+ box.append(toolbar, linkBox, editor);
192
+ this.append(box);
193
+ this.#box = box;
194
+ this.#toolbar = toolbar; this.#linkBox = linkBox; this.#linkInput = linkInput; this.#linkError = error; this.#editor = editor;
195
+ this.#built = true;
196
+ this.#wire(box, toolbar, linkBox, editor);
197
+ this.#applySize();
198
+ if (typeof ResizeObserver !== 'undefined') { this.#resize = new ResizeObserver(() => this.#applySize()); this.#resize.observe(box); }
199
+ this.setValue(initial);
200
+ this.#enable();
201
+ this.markPristine();
202
+ }
203
+
204
+ attributeChangedCallback(attr, old, value) {
205
+ super.attributeChangedCallback(attr, old, value);
206
+ if (this.#built && (attr === 'disabled' || attr === 'readonly')) this.#enable();
207
+ if (this.#built && attr === 'placeholder' && this.#editor) this.#editor.dataset.placeholder = value ?? '';
208
+ }
209
+
210
+ /** One toolbar item: a button, or the text style select. */
211
+ #item(/** @type {string} */ name, /** @type {boolean} */ first, /** @type {boolean} */ inMenu = false) {
212
+ const tool = /** @type {any} */ (TOOLS)[name];
213
+ const caption = tool.label();
214
+ if (name === 'style') {
215
+ const select = document.createElement('select');
216
+ select.dataset.tool = name;
217
+ select.tabIndex = first ? 0 : -1;
218
+ select.setAttribute('aria-label', caption);
219
+ select.title = caption;
220
+ for (const [value, label, short] of STYLE_OPTIONS) {
221
+ const option = document.createElement('option');
222
+ option.value = value;
223
+ option.dataset.short = short;
224
+ option.textContent = label();
225
+ if (value === 'mixed') option.disabled = true; // shown when the selection spans different blocks; not a choice
226
+ select.append(option);
227
+ }
228
+ return select;
229
+ }
230
+ const button = document.createElement('button');
231
+ button.type = 'button';
232
+ button.dataset.tool = name;
233
+ button.tabIndex = first ? 0 : -1;
234
+ button.dataset.face = tool.face; // drawn by CSS (::before): the symbol is not text, so the accessible name is the caption alone (WCAG 2.5.3)
235
+ button.setAttribute('aria-label', caption);
236
+ button.title = caption;
237
+ if (inMenu) { button.setAttribute('role', tool.state ? 'menuitemcheckbox' : 'menuitem'); button.tabIndex = -1; button.textContent = caption; } // a menu item is checked, a toolbar button is pressed
238
+ if (tool.state) button.setAttribute(inMenu ? 'aria-checked' : 'aria-pressed', 'false');
239
+ return button;
240
+ }
241
+
242
+ /** The toolbar items that are shown, in order (the ones inside the More menu are not part of the toolbar's tab order). */
243
+ #items() {
244
+ return /** @type {HTMLElement[]} */ ([...(this.#toolbar?.querySelectorAll('[data-tool]') ?? [])]).filter((el) => !el.closest('.xo-richtext-more') && !el.hidden && !el.closest('[hidden]'));
245
+ }
246
+
247
+ /**
248
+ * Full (every tool) from 580px of the editor's own width, compact (the common tools and a More menu) below: never wrapped, never scrolled sideways.
249
+ * `toolbar="full"` or `toolbar="compact"` forces one; a width of 0 (not laid out) counts as full.
250
+ */
251
+ #applySize() {
252
+ const toolbar = this.#toolbar;
253
+ if (!toolbar || !this.#box) return;
254
+ const width = this.#box.getBoundingClientRect().width;
255
+ const size = this.toolbar === 'compact' || this.toolbar === 'full' ? this.toolbar : width > 0 && width < FULL_FROM ? 'compact' : 'full';
256
+ if (size === this.#size) return;
257
+ this.#size = size;
258
+ toolbar.dataset.size = size;
259
+ const compact = size === 'compact';
260
+ for (const el of /** @type {HTMLElement[]} */ ([...toolbar.children])) {
261
+ if (el.classList.contains('xo-richtext-morewrap')) el.hidden = !compact;
262
+ else el.hidden = compact && MORE_ITEMS.includes(/** @type {string} */ (el.dataset.tool));
263
+ }
264
+ for (const option of toolbar.querySelectorAll('select[data-tool=style] option')) {
265
+ const o = /** @type {HTMLOptionElement} */ (option);
266
+ const full = STYLE_OPTIONS.find(([v]) => v === o.value)?.[1]();
267
+ o.textContent = compact ? (o.dataset.short ?? '') : (full ?? '');
268
+ }
269
+ if (!compact) this.#closeMore();
270
+ const items = this.#items();
271
+ if (!items.some((el) => el.tabIndex === 0) && items[0]) items[0].tabIndex = 0; // always one tab stop
272
+ for (const el of items) if (el !== items.find((i) => i.tabIndex === 0)) el.tabIndex = -1;
273
+ }
274
+
275
+ #enable() {
276
+ const on = !this.isDisabled && !this.readonly;
277
+ this.#editor?.setAttribute('contenteditable', String(on));
278
+ this.#editor?.toggleAttribute('aria-readonly', !on);
279
+ for (const b of /** @type {NodeListOf<HTMLButtonElement>} */ (this.#box?.querySelectorAll('.xo-richtext-toolbar button, .xo-richtext-toolbar select') ?? [])) b.disabled = !on;
280
+ }
281
+
282
+ #show(/** @type {string} */ html) {
283
+ const editor = this.#editor;
284
+ if (!editor) return;
285
+ if (sanitizeHtml(editor.innerHTML) !== html) editor.innerHTML = html; // rewriting the content moves the caret: only when it really differs
286
+ editor.toggleAttribute('data-empty', this.isEmpty(html));
287
+ }
288
+
289
+ // ---- events ------------------------------------------------------------------------------------------------------------
290
+
291
+ #wire(/** @type {HTMLElement} */ box, /** @type {HTMLElement} */ toolbar, /** @type {HTMLElement} */ linkBox, /** @type {HTMLElement} */ editor) {
292
+ editor.addEventListener('focus', () => { exec('defaultParagraphSeparator', 'p'); exec('styleWithCSS', 'false'); });
293
+ // the editor's own input and change events are not the control's: only userInput speaks for the control
294
+ editor.addEventListener('input', (event) => { event.stopPropagation(); this.#commit(); });
295
+ editor.addEventListener('change', (event) => event.stopPropagation());
296
+ editor.addEventListener('paste', (event) => { event.preventDefault(); this.#insertFrom(/** @type {ClipboardEvent} */ (event).clipboardData); });
297
+ editor.addEventListener('drop', (event) => { event.preventDefault(); this.#insertFrom(/** @type {DragEvent} */ (event).dataTransfer); });
298
+ editor.addEventListener('dragover', (event) => event.preventDefault());
299
+ editor.addEventListener('keydown', (event) => {
300
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k' && this.#tool('link')) { event.preventDefault(); this.#openLink(); }
301
+ });
302
+ // while expanded, focus leaving is the dialog closing, not the person leaving the field
303
+ editor.addEventListener('blur', () => { if (!this.#dialog && !this.contains(document.activeElement) && !this.#moving) this.markTouched(); });
304
+ editor.addEventListener('keyup', () => this.#refreshState());
305
+ editor.addEventListener('mouseup', () => this.#refreshState());
306
+
307
+ toolbar.addEventListener('mousedown', (event) => { if (/** @type {Element} */ (event.target).closest('button')) event.preventDefault(); }); // keep the selection in the editor
308
+ toolbar.addEventListener('click', (event) => {
309
+ const button = /** @type {Element} */ (event.target).closest('button');
310
+ if (!(button instanceof HTMLButtonElement) || button.disabled) return;
311
+ const name = /** @type {string} */ (button.dataset.tool);
312
+ if (name === 'more') { this.#more?.hidden ? this.#openMore() : this.#closeMore(); return; }
313
+ if (button.closest('.xo-richtext-more')) this.#closeMore();
314
+ this.#run(name);
315
+ });
316
+ // the text style select keeps the editor's selection from when it was opened, and applies the choice there
317
+ const style = /** @type {HTMLSelectElement | null} */ (toolbar.querySelector('select[data-tool=style]'));
318
+ const saveSelection = () => { const sel = getSelection(); if (sel?.rangeCount && editor.contains(sel.anchorNode)) this.#saved = sel.getRangeAt(0).cloneRange(); };
319
+ style?.addEventListener('mousedown', saveSelection);
320
+ style?.addEventListener('focus', saveSelection);
321
+ style?.addEventListener('change', () => {
322
+ const sel = getSelection();
323
+ if (this.#saved && sel) { sel.removeAllRanges(); sel.addRange(this.#saved); }
324
+ editor.focus();
325
+ exec('formatBlock', style.value);
326
+ this.#commit();
327
+ this.#refreshState();
328
+ });
329
+ toolbar.addEventListener('keydown', (event) => this.#toolbarKey(event));
330
+
331
+ linkBox.addEventListener('keydown', (event) => {
332
+ if (event.key === 'Enter') { event.preventDefault(); this.#applyLink(); }
333
+ else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.#closeLink(); }
334
+ });
335
+ linkBox.addEventListener('click', (event) => {
336
+ const action = /** @type {HTMLElement | null} */ (/** @type {Element} */ (event.target).closest('button'))?.dataset.action;
337
+ if (action === 'apply') this.#applyLink();
338
+ else if (action === 'remove') { this.#restore(); exec('unlink'); this.#closeLink(); this.#commit(); }
339
+ });
340
+ void box;
341
+ }
342
+
343
+ #tool(/** @type {string} */ name) { return this.#toolbar?.querySelector(`[data-tool="${name}"]`) ?? null; }
344
+
345
+ #toolbarKey(/** @type {KeyboardEvent} */ event) {
346
+ const target = /** @type {HTMLElement} */ (event.target);
347
+ if (target.closest('.xo-richtext-more')) { this.#menuKey(event); return; }
348
+ const items = this.#items();
349
+ const at = items.indexOf(target);
350
+ if (at < 0) return;
351
+ if (target.dataset.tool === 'more' && event.key === 'ArrowDown') { event.preventDefault(); this.#openMore(true); return; }
352
+ if (target.localName === 'select' && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) return; // the select's own keys
353
+ const next = event.key === 'ArrowRight' ? (at + 1) % items.length : event.key === 'ArrowLeft' ? (at - 1 + items.length) % items.length
354
+ : event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : -1;
355
+ if (next < 0) return;
356
+ event.preventDefault();
357
+ items.forEach((b, i) => { b.tabIndex = i === next ? 0 : -1; });
358
+ items[next].focus();
359
+ }
360
+
361
+ // ---- the More menu (compact toolbar) -------------------------------------------------------------------------------------
362
+
363
+ #openMore(focusFirst = false) {
364
+ const menu = this.#more;
365
+ if (!menu) return;
366
+ if (menu.hidden) { menu.hidden = false; menu.previousElementSibling?.setAttribute('aria-expanded', 'true'); }
367
+ if (focusFirst) /** @type {HTMLElement | null} */ (menu.firstElementChild)?.focus();
368
+ }
369
+
370
+ #closeMore(focus = false) {
371
+ const menu = this.#more;
372
+ if (!menu || menu.hidden) return;
373
+ menu.hidden = true;
374
+ const trigger = /** @type {HTMLElement | null} */ (menu.previousElementSibling);
375
+ trigger?.setAttribute('aria-expanded', 'false');
376
+ if (focus) trigger?.focus();
377
+ }
378
+
379
+ #menuKey(/** @type {KeyboardEvent} */ event) {
380
+ const items = /** @type {HTMLElement[]} */ ([...(this.#more?.children ?? [])]);
381
+ const at = items.indexOf(/** @type {HTMLElement} */ (event.target));
382
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.#closeMore(true); return; }
383
+ if (event.key === 'Tab') { this.#closeMore(); return; }
384
+ const next = event.key === 'ArrowDown' ? (at + 1) % items.length : event.key === 'ArrowUp' ? (at - 1 + items.length) % items.length
385
+ : event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : -1;
386
+ if (next < 0) return;
387
+ event.preventDefault();
388
+ items[next].focus();
389
+ }
390
+
391
+ #run(/** @type {string} */ name) {
392
+ const editor = this.#editor;
393
+ if (!editor) return;
394
+ if (name === 'link') { this.#openLink(); return; }
395
+ if (name === 'image') { this.#pickMedia(); return; }
396
+ editor.focus();
397
+ if (name === 'bold') exec('bold');
398
+ else if (name === 'italic') exec('italic');
399
+ else if (name === 'underline') exec('underline');
400
+ else if (name === 'ul') exec('insertUnorderedList');
401
+ else if (name === 'ol') exec('insertOrderedList');
402
+ else if (name === 'undo') exec('undo');
403
+ else if (name === 'redo') exec('redo');
404
+ else if (name === 'h2') exec('formatBlock', block() === 'h2' ? 'p' : 'h2');
405
+ else if (name === 'quote') exec('formatBlock', block() === 'blockquote' ? 'p' : 'blockquote');
406
+ this.#commit();
407
+ this.#refreshState();
408
+ }
409
+
410
+ /** The editor's content became the value (sanitised); the editor itself is left alone so the caret stays. */
411
+ #commit() {
412
+ const editor = this.#editor;
413
+ if (!editor) return;
414
+ const clean = this.isEmpty(editor.innerHTML) ? '' : sanitizeHtml(editor.innerHTML);
415
+ editor.toggleAttribute('data-empty', clean === '');
416
+ if (clean === this.value) return;
417
+ this.#fromUser = true;
418
+ try { this.userInput(clean); } finally { this.#fromUser = false; }
419
+ }
420
+
421
+ #refreshState() {
422
+ for (const button of /** @type {NodeListOf<HTMLButtonElement>} */ (this.#box?.querySelectorAll('.xo-richtext-toolbar button[aria-pressed], .xo-richtext-more [aria-checked]') ?? [])) {
423
+ const tool = /** @type {any} */ (TOOLS)[/** @type {string} */ (button.dataset.tool)];
424
+ button.setAttribute(button.hasAttribute('aria-checked') ? 'aria-checked' : 'aria-pressed', String(!!tool?.state?.()));
425
+ }
426
+ const style = /** @type {HTMLSelectElement | null} */ (this.#box?.querySelector('select[data-tool=style]') ?? null);
427
+ if (style) style.value = this.#blockStyle();
428
+ }
429
+
430
+ /** The text style at the selection: p, h2 or h3, or `mixed` when it spans different blocks (or a list or a quote). */
431
+ #blockStyle() {
432
+ const editor = this.#editor;
433
+ const selection = getSelection();
434
+ if (!editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) return 'p';
435
+ const range = selection.getRangeAt(0);
436
+ const blocks = /** @type {Element[]} */ ([...editor.children]).filter((c) => (range.intersectsNode ? range.intersectsNode(c) : c.contains(selection.anchorNode)));
437
+ const kinds = new Set(blocks.map((c) => /** @type {Record<string, string>} */ ({ P: 'p', H2: 'h2', H3: 'h3' })[c.tagName] ?? 'other'));
438
+ if (!kinds.size) return 'p';
439
+ return kinds.size === 1 && !kinds.has('other') ? /** @type {string} */ ([...kinds][0]) : 'mixed';
440
+ }
441
+
442
+ // ---- expanding into a dialog -------------------------------------------------------------------------------------------
443
+
444
+ /** Whether the editor is open in its dialog. */
445
+ get expanded() { return !!this.#dialog; }
446
+
447
+ /**
448
+ * Open the same editor in a large dialog (960px; the whole screen under 768px). The editor moves, it is not copied: the value, the selection and the undo
449
+ * history are the same. Done and Escape close it. Nothing is saved by this: it only changes where the editor is shown. Fires `xo-richtext-expand`.
450
+ */
451
+ expand() {
452
+ const box = this.#box;
453
+ if (!box || this.#dialog) return;
454
+ const dialog = document.createElement('dialog');
455
+ dialog.className = 'xo-richtext-dialog';
456
+ dialog.setAttribute('aria-label', this.label || this.name || text('xoong.richtext.editor', 'Rich text'));
457
+ const bar = document.createElement('div');
458
+ bar.className = 'xo-richtext-dialog-bar';
459
+ const done = document.createElement('button');
460
+ done.type = 'button';
461
+ done.className = 'xo-richtext-done';
462
+ done.textContent = text('xoong.richtext.done', 'Done');
463
+ done.addEventListener('click', () => this.collapse());
464
+ bar.append(done);
465
+ const caret = this.#saveCaret();
466
+ this.#marker = document.createComment('xo-richtext');
467
+ this.#moving = true;
468
+ box.replaceWith(this.#marker);
469
+ dialog.append(bar, box);
470
+ document.body.append(dialog);
471
+ this.#dialog = dialog;
472
+ dialog.addEventListener('close', () => this.#restoreBox()); // Escape closes a native dialog, and so does collapse()
473
+ if (dialog.showModal) dialog.showModal(); else dialog.setAttribute('open', '');
474
+ this.#moving = false;
475
+ this.#applySize();
476
+ this.#editor?.focus();
477
+ this.#restoreCaret(caret);
478
+ this.dispatchEvent(new CustomEvent('xo-richtext-expand', { bubbles: true }));
479
+ }
480
+
481
+ /** Close the dialog and put the editor back (what Done and Escape do). Fires `xo-richtext-done`. */
482
+ collapse() {
483
+ const dialog = this.#dialog;
484
+ if (!dialog) return;
485
+ if (dialog.close) dialog.close(); else this.#restoreBox();
486
+ }
487
+
488
+ /** Where the caret or selection is in the editor, as paths, or null. */
489
+ #saveCaret() {
490
+ const editor = this.#editor;
491
+ const sel = getSelection();
492
+ if (!editor || !sel?.rangeCount || !editor.contains(sel.anchorNode) || !editor.contains(sel.focusNode)) return null;
493
+ return { a: pathOf(editor, sel.anchorNode), ao: sel.anchorOffset, f: pathOf(editor, sel.focusNode), fo: sel.focusOffset };
494
+ }
495
+
496
+ #restoreCaret(/** @type {{ a: number[], ao: number, f: number[], fo: number } | null} */ caret) {
497
+ const editor = this.#editor;
498
+ if (!caret || !editor) return;
499
+ const anchor = nodeAt(editor, caret.a);
500
+ const focus = nodeAt(editor, caret.f);
501
+ if (anchor && focus) getSelection()?.setBaseAndExtent(anchor, caret.ao, focus, caret.fo);
502
+ }
503
+
504
+ #restoreBox() {
505
+ const dialog = this.#dialog;
506
+ const box = this.#box;
507
+ if (!dialog || !box || !this.#marker) return;
508
+ this.#dialog = null;
509
+ this.#moving = true;
510
+ const caret = this.#saveCaret();
511
+ this.#marker.replaceWith(box);
512
+ this.#marker = null;
513
+ dialog.remove();
514
+ this.#moving = false;
515
+ this.#applySize();
516
+ this.#editor?.focus();
517
+ this.#restoreCaret(caret);
518
+ this.dispatchEvent(new CustomEvent('xo-richtext-done', { bubbles: true }));
519
+ }
520
+
521
+ // ---- paste, drop ---------------------------------------------------------------------------------------------------------
522
+
523
+ #insertFrom(/** @type {DataTransfer | null} */ data) {
524
+ if (!data || !this.#editor || this.readonly || this.isDisabled) return;
525
+ const html = data.getData('text/html');
526
+ const plain = data.getData('text/plain');
527
+ this.#editor.focus();
528
+ if (html) { exec('insertHTML', sanitizeHtml(html)); }
529
+ else if (plain) {
530
+ const paragraphs = plain.replace(/\r\n?/g, '\n').split(/\n{2,}/);
531
+ if (paragraphs.length === 1 && !plain.includes('\n')) exec('insertText', plain);
532
+ else exec('insertHTML', paragraphs.map((p) => `<p>${esc(p).replace(/\n/g, '<br>')}</p>`).join(''));
533
+ }
534
+ this.#commit();
535
+ }
536
+
537
+ // ---- links ---------------------------------------------------------------------------------------------------------------
538
+
539
+ #openLink() {
540
+ const input = this.#linkInput;
541
+ if (!input || !this.#linkBox) return;
542
+ const selection = getSelection();
543
+ this.#saved = selection && selection.rangeCount && this.#editor?.contains(selection.anchorNode) ? selection.getRangeAt(0).cloneRange() : null;
544
+ input.value = anchorAtSelection()?.getAttribute('href') ?? '';
545
+ this.#setLinkError('');
546
+ this.#linkBox.hidden = false;
547
+ input.focus();
548
+ input.select();
549
+ }
550
+
551
+ #closeLink() {
552
+ if (!this.#linkBox || this.#linkBox.hidden) return;
553
+ this.#linkBox.hidden = true;
554
+ this.#restore();
555
+ this.#editor?.focus();
556
+ }
557
+
558
+ #restore() {
559
+ const selection = getSelection();
560
+ if (this.#saved && selection) { selection.removeAllRanges(); selection.addRange(this.#saved); }
561
+ this.#editor?.focus();
562
+ const link = anchorAtSelection();
563
+ if (link && selection?.isCollapsed) selection.selectAllChildren(link); // a caret inside a link means the whole link
564
+ }
565
+
566
+ #setLinkError(/** @type {string} */ message) {
567
+ if (this.#linkError) this.#linkError.textContent = message;
568
+ if (message) this.#linkInput?.setAttribute('aria-invalid', 'true'); else this.#linkInput?.removeAttribute('aria-invalid');
569
+ }
570
+
571
+ /** A typed address made into a link target: "a@b.test" -> mailto:, "example.com" -> https://. */
572
+ static linkTarget(/** @type {string} */ raw) {
573
+ const url = raw.trim();
574
+ if (!url) return '';
575
+ if (/^[^\s@:/]+@[^\s@:/]+\.[^\s@:/]+$/.test(url)) return `mailto:${url}`;
576
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && /^[^\s/]+\.[a-z]{2,}([/?#]|$)/i.test(url)) return `https://${url}`;
577
+ return url;
578
+ }
579
+
580
+ #applyLink() {
581
+ const url = XoRichtext.linkTarget(this.#linkInput?.value ?? '');
582
+ if (!url) { this.#restore(); exec('unlink'); this.#closeLink(); this.#commit(); return; }
583
+ if (!isSafeLink(url)) { this.#setLinkError(text('xoong.richtext.linkInvalid', 'Use an address that starts with http://, https://, mailto: or tel:.')); this.#linkInput?.focus(); return; }
584
+ if (this.#linkBox) this.#linkBox.hidden = true;
585
+ this.#restore();
586
+ const selection = getSelection();
587
+ if (selection && selection.isCollapsed && !anchorAtSelection()) exec('insertHTML', `<a href="${esc(url)}">${esc(url)}</a>`);
588
+ else exec('createLink', url);
589
+ this.#commit();
590
+ this.#refreshState();
591
+ }
592
+
593
+ // ---- media ---------------------------------------------------------------------------------------------------------------
594
+
595
+ #pickMedia() {
596
+ const selection = getSelection();
597
+ const saved = selection && selection.rangeCount && this.#editor?.contains(selection.anchorNode) ? selection.getRangeAt(0).cloneRange() : null;
598
+ const insert = (/** @type {{ src: string, alt?: string } | string} */ media, /** @type {string} */ alt = '') => {
599
+ const { src, alt: a = alt } = typeof media === 'string' ? { src: media, alt } : media;
600
+ this.insertImage(src, a, saved);
601
+ };
602
+ this.dispatchEvent(new CustomEvent('xo-pick-media', { bubbles: true, cancelable: true, detail: { insert } }));
603
+ }
604
+
605
+ /** Insert an image (http, https or a relative URL) at the caret. */
606
+ insertImage(/** @type {string} */ src, alt = '', /** @type {Range | null} */ at = null) {
607
+ if (!this.#editor || this.readonly || this.isDisabled) return false;
608
+ const html = sanitizeHtml(`<img src="${esc(src)}" alt="${esc(alt)}">`);
609
+ if (!html.includes('<img')) return false;
610
+ this.#editor.focus();
611
+ const selection = getSelection();
612
+ if (at && selection) { selection.removeAllRanges(); selection.addRange(at); }
613
+ // A Range, not execCommand: Chromium's insertHTML copies computed styles into a style="" attribute, which a strict CSP reports.
614
+ const template = document.createElement('template');
615
+ template.innerHTML = html.replace(/^<p>|<\/p>$/g, '');
616
+ const img = template.content.firstChild;
617
+ const editor = this.#editor;
618
+ const range = selection && selection.rangeCount && editor.contains(selection.anchorNode) ? selection.getRangeAt(0) : null;
619
+ if (!img) return false;
620
+ let heading = range?.startContainer ?? null;
621
+ while (heading && heading.parentNode !== editor) heading = heading.parentNode;
622
+ if (range && heading && /^H[1-6]$/.test(heading.nodeName)) { // an image in a heading is rarely wanted: a paragraph of its own after it
623
+ const p = document.createElement('p');
624
+ p.append(img);
625
+ heading.after(p);
626
+ range.setStartAfter(img);
627
+ } else if (range) {
628
+ range.deleteContents();
629
+ range.insertNode(img);
630
+ range.setStartAfter(img);
631
+ } else editor.append(img);
632
+ if (selection && range) { range.collapse(true); selection.removeAllRanges(); selection.addRange(range); }
633
+ this.#commit();
634
+ return true;
635
+ }
636
+ }
637
+ define('xo-richtext', XoRichtext);
@@ -0,0 +1,143 @@
1
+ // @ts-check
2
+ /**
3
+ * The HTML that <xo-richtext> keeps. Anything else is removed, whether it comes from a paste, from `value`, or from the server.
4
+ *
5
+ * Allowed: p, h2, h3, strong, em, u, ul, ol, li, blockquote, br, a (href, title) and img (src, alt, width, height).
6
+ * Mapped: b -> strong, i -> em, h1 -> h2, h4 to h6 -> h3, div -> p. Everything else is unwrapped (its text stays); script, style, iframe, object,
7
+ * embed, svg, math, template and noscript go with their content. No attribute other than the ones above survives (no style, class, id, on*).
8
+ * Links keep only http, https, mailto and tel; images keep only http, https and relative URLs. Loose text at the top level is wrapped in <p>.
9
+ * Non-breaking spaces become plain spaces; empty paragraphs at the very start or end are removed; a line break at the very end of a block
10
+ * (a browser's placeholder) is removed.
11
+ *
12
+ * The result is built as a string with its own escaping from an inert parse (`<template>`), so what is returned never goes through the parser again
13
+ * in a different shape than the one that was checked.
14
+ */
15
+
16
+ const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'TEMPLATE', 'NOSCRIPT', 'HEAD', 'TITLE', 'META', 'LINK', 'BASE', 'INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'CANVAS', 'AUDIO', 'VIDEO']);
17
+ const RENAME = { B: 'strong', STRONG: 'strong', I: 'em', EM: 'em', U: 'u', H1: 'h2', H2: 'h2', H3: 'h3', H4: 'h3', H5: 'h3', H6: 'h3', DIV: 'p', P: 'p', UL: 'ul', OL: 'ol', LI: 'li', BLOCKQUOTE: 'blockquote' };
18
+ const BLOCK = new Set(['p', 'h2', 'h3', 'ul', 'ol', 'blockquote', 'li']);
19
+ const LINK_SCHEMES = /^(https?:|mailto:|tel:)/i;
20
+
21
+ const esc = (/** @type {string} */ s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
22
+
23
+ /** A link target the editor allows: http, https, mailto or tel. */
24
+ export function isSafeLink(/** @type {string} */ url) {
25
+ return LINK_SCHEMES.test(url.replace(/[\u0000- \u007f-\u009f]/g, ''));
26
+ }
27
+
28
+ /** An image source the editor allows: http, https, or a URL without a scheme (relative or root-relative). */
29
+ export function isSafeImage(/** @type {string} */ url) {
30
+ const u = url.replace(/[\u0000- \u007f-\u009f]/g, '');
31
+ return /^https?:/i.test(u) || (!/^[a-z][a-z0-9+.-]*:/i.test(u) && !u.startsWith('//'));
32
+ }
33
+
34
+ /**
35
+ * @param {string} html
36
+ * @param {{ images?: boolean }} [options] `images: false` removes <img>
37
+ * @returns {string}
38
+ */
39
+ export function sanitizeHtml(html, { images = true } = {}) {
40
+ const template = document.createElement('template');
41
+ template.innerHTML = String(html ?? '');
42
+
43
+ /** A line break at the very end of a block is a browser's placeholder, not content (`<li>text<br></li>`); a block with only a break is a blank line and keeps it. */
44
+ const trimBreak = (/** @type {string} */ body) => (body.endsWith('<br>') && body !== '<br>' ? body.slice(0, -4) : body);
45
+
46
+ /** Inline content of a node, as a string (no block tags inside: they are unwrapped). */
47
+ function inline(/** @type {Node} */ node) {
48
+ let out = '';
49
+ for (const child of node.childNodes) out += one(child, true);
50
+ return out;
51
+ }
52
+
53
+ /** @returns {string} */
54
+ function one(/** @type {Node} */ node, /** @type {boolean} */ inInline) {
55
+ if (node.nodeType === 3) return esc((node.nodeValue ?? '').replace(/\u00a0/g, ' ')); // browsers type non-breaking spaces and Word pastes them everywhere: plain spaces
56
+ if (node.nodeType !== 1) return '';
57
+ const el = /** @type {Element} */ (node);
58
+ const tag = el.tagName.toUpperCase();
59
+ if (DROP.has(tag)) return '';
60
+ if (tag === 'BR') return '<br>';
61
+ if (tag === 'IMG') {
62
+ const src = el.getAttribute('src') ?? '';
63
+ if (!images || !src || !isSafeImage(src)) return '';
64
+ const size = ['width', 'height'].map((k) => { const v = el.getAttribute(k); return v && /^\d{1,5}$/.test(v) ? ` ${k}="${v}"` : ''; }).join('');
65
+ return `<img src="${esc(src)}" alt="${esc(el.getAttribute('alt') ?? '')}"${size}>`;
66
+ }
67
+ if (tag === 'A') {
68
+ const href = el.getAttribute('href') ?? '';
69
+ const body = inline(el);
70
+ if (!href || !isSafeLink(href)) return body;
71
+ const title = el.getAttribute('title');
72
+ return `<a href="${esc(href.trim())}"${title ? ` title="${esc(title)}"` : ''} rel="noopener noreferrer">${body}</a>`;
73
+ }
74
+ const name = /** @type {Record<string, string>} */ (RENAME)[tag];
75
+ if (name === 'strong' || name === 'em' || name === 'u') { const body = inline(el); return body ? `<${name}>${body}</${name}>` : ''; }
76
+ if (name && BLOCK.has(name)) {
77
+ if (inInline) return inline(el); // a block inside inline content (a heading in a link, a list in a paragraph) becomes its text
78
+ return block(el, name);
79
+ }
80
+ return inline(el); // unknown element: keep what is inside
81
+ }
82
+
83
+ /** A block element with its content. */
84
+ function block(/** @type {Element} */ el, /** @type {string} */ name) {
85
+ if (name === 'ul' || name === 'ol') {
86
+ const items = [...el.childNodes].map((c) => (c.nodeType === 1 && /** @type {Element} */ (c).tagName === 'LI' ? block(/** @type {Element} */ (c), 'li') : '')).join('');
87
+ return items ? `<${name}>${items}</${name}>` : '';
88
+ }
89
+ if (name === 'li') {
90
+ let out = '';
91
+ for (const child of el.childNodes) {
92
+ const tag = child.nodeType === 1 ? /** @type {Element} */ (child).tagName : '';
93
+ out += tag === 'UL' || tag === 'OL' ? one(child, false) : one(child, true); // nested lists stay; other blocks flatten to text
94
+ }
95
+ return `<li>${trimBreak(out)}</li>`;
96
+ }
97
+ if (name === 'blockquote') {
98
+ const inner = blocks(el);
99
+ return inner ? `<blockquote>${inner}</blockquote>` : '';
100
+ }
101
+ const body = trimBreak(inline(el));
102
+ return body.replace(/<br>$/, '') === '' && !body.includes('<img') ? (name === 'p' ? '<p><br></p>' : '') : `<${name}>${body}</${name}>`;
103
+ }
104
+
105
+ /** The children of a container as blocks: loose inline runs are wrapped in a paragraph. */
106
+ function blocks(/** @type {Node} */ container) {
107
+ let out = '';
108
+ let run = '';
109
+ const flush = () => { if (run.replace(/<br>/g, '').trim() !== '' || run.includes('<img')) out += `<p>${run}</p>`; run = ''; };
110
+ for (const child of container.childNodes) {
111
+ const tag = child.nodeType === 1 ? /** @type {Element} */ (child).tagName.toUpperCase() : '';
112
+ const name = /** @type {Record<string, string>} */ (RENAME)[tag];
113
+ if (name && BLOCK.has(name) && name !== 'li') { flush(); out += one(child, false); }
114
+ else if (DROP.has(tag)) continue;
115
+ else if (tag && !name && hasBlock(child)) { flush(); out += blocks(child); } // an unknown wrapper around blocks: look inside
116
+ else run += one(child, true);
117
+ }
118
+ flush();
119
+ return out;
120
+ }
121
+
122
+ /** Whether an element has a block element somewhere inside (so an unknown wrapper such as <section> must not hide it). */
123
+ function hasBlock(/** @type {Node} */ node) {
124
+ for (const child of node.childNodes) {
125
+ if (child.nodeType !== 1) continue;
126
+ const tag = /** @type {Element} */ (child).tagName.toUpperCase();
127
+ if (DROP.has(tag)) continue;
128
+ const name = /** @type {Record<string, string>} */ (RENAME)[tag];
129
+ if ((name && BLOCK.has(name)) || hasBlock(child)) return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ // blank lines at the very start or end are editor leftovers (a browser leaves them around a list); blank lines inside the text are the author's
135
+ return blocks(template.content).replace(/^(?:<p><br><\/p>)+|(?:<p><br><\/p>)+$/g, '');
136
+ }
137
+
138
+ /** The text of sanitised or raw HTML, for "is it empty" checks and length limits. */
139
+ export function textOf(/** @type {string} */ html) {
140
+ const template = document.createElement('template');
141
+ template.innerHTML = html;
142
+ return template.content.textContent ?? '';
143
+ }