mnfst 0.5.231 → 0.5.232
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/lib/manifest.appwrite.data.js +1 -1
- package/lib/manifest.components.js +8 -0
- package/lib/manifest.css +341 -424
- package/lib/manifest.d.ts +170 -39
- package/lib/manifest.data.js +1 -1
- package/lib/manifest.defer.js +1 -1
- package/lib/manifest.dropdowns.js +2 -0
- package/lib/manifest.edit.css +87 -150
- package/lib/manifest.edit.js +2324 -1076
- package/lib/manifest.integrity.json +10 -10
- package/lib/manifest.js +119 -25
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.rich.text.css +253 -0
- package/lib/{manifest.text.edit.js → manifest.rich.text.js} +1153 -736
- package/lib/manifest.router.js +8 -0
- package/lib/manifest.sw.js +1 -1
- package/package.json +2 -1
- package/lib/manifest.text.edit.css +0 -273
|
@@ -1,55 +1,44 @@
|
|
|
1
|
-
/* Manifest Text
|
|
2
|
-
|
|
3
|
-
One directive, two roles. Without a command modifier it marks the editable area
|
|
4
|
-
and binds its value: x-text-edit="post". With one, it turns any element into a
|
|
5
|
-
control for that area: <button x-text-edit.strong>. Controls need no wrapper and
|
|
6
|
-
no shared parent — they resolve to an area by containment, then by focus, so a
|
|
7
|
-
toolbar can sit anywhere and two dialogs in one DOM never cross wires.
|
|
8
|
-
|
|
9
|
-
Markdown by default (portable, and x-markdown renders it straight back);
|
|
10
|
-
.html stores sanitized HTML; .plain stores text.
|
|
11
|
-
|
|
12
|
-
Serialization is a closed round trip over exactly what the commands can produce —
|
|
13
|
-
headings, bold, italic, strike, code, links, lists, quote, rule. Markdown the
|
|
14
|
-
editor can't author (tables, footnotes) is not a silent lossy convert: use .html. */
|
|
1
|
+
/* Manifest Rich Text */
|
|
15
2
|
|
|
16
3
|
(function () {
|
|
17
4
|
|
|
18
5
|
/* ---- Tag tables ----
|
|
19
|
-
Commands are
|
|
20
|
-
vocabulary to map onto HTML: .blockquote makes a <blockquote>. `md` marks the
|
|
21
|
-
tags markdown can carry; the rest are available in .html mode only, where they
|
|
22
|
-
survive, rather than being written and silently dropped on the next save. */
|
|
6
|
+
Commands are tag names; `md` marks tags markdown can carry (others are .html only). */
|
|
23
7
|
const INLINE_TAGS = {
|
|
24
8
|
strong: { md: 1 }, b: { md: 1 }, em: { md: 1 }, i: { md: 1 }, s: { md: 1 }, del: { md: 1 }, code: { md: 1 },
|
|
25
|
-
|
|
9
|
+
abbr: { attr: 'title' }, bdi: {}, bdo: { attr: 'dir', def: 'rtl' }, cite: {}, data: { attr: 'value' },
|
|
10
|
+
dfn: { attr: 'title' }, ins: {}, kbd: {}, mark: {}, q: {}, samp: {}, small: {}, span: {}, sub: {}, sup: {},
|
|
11
|
+
time: { attr: 'datetime' }, u: {}, var: {}
|
|
26
12
|
};
|
|
27
13
|
const BLOCK_TAGS = {
|
|
28
14
|
p: { md: 1 }, h1: { md: 1 }, h2: { md: 1 }, h3: { md: 1 }, h4: { md: 1 }, h5: { md: 1 }, h6: { md: 1 },
|
|
29
|
-
blockquote: { md: 1 }, pre: { md: 1 },
|
|
30
|
-
address: {}, figure: {}, figcaption: {}, dl: {}, dt: {}, dd: {}
|
|
15
|
+
blockquote: { md: 1 }, pre: { md: 1 }, address: {}
|
|
31
16
|
};
|
|
32
|
-
const BLOCK = [...Object.keys(BLOCK_TAGS), 'ul', 'ol', 'li', 'hr', 'table', 'td', 'th'].map(t => t.toUpperCase());
|
|
17
|
+
const BLOCK = [...Object.keys(BLOCK_TAGS), 'figure', 'figcaption', 'dl', 'dt', 'dd', 'ul', 'ol', 'li', 'hr', 'table', 'td', 'th'].map(t => t.toUpperCase());
|
|
18
|
+
const BLOCK_SEL = BLOCK.join(',');
|
|
33
19
|
const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
|
|
34
|
-
const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'A', 'IMG']);
|
|
20
|
+
const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
|
|
35
21
|
|
|
36
|
-
/* ---- URL policy ----
|
|
37
|
-
|
|
38
|
-
URL parsers skip controls and whitespace inside a scheme, so this does too. */
|
|
22
|
+
/* ---- URL policy ---- */
|
|
23
|
+
// No script schemes; data: only as an image.
|
|
39
24
|
function unsafeUrl(url, image) {
|
|
40
25
|
const u = String(url == null ? '' : url).replace(/[\x00-\x20\x7f]/g, '');
|
|
41
26
|
const scheme = (u.match(/^([a-z][a-z0-9+.-]*):/i) || [])[1];
|
|
42
27
|
if (!scheme) return false;
|
|
43
28
|
const s = scheme.toLowerCase();
|
|
44
29
|
if (s === 'javascript' || s === 'vbscript') return true;
|
|
45
|
-
return s === 'data' && !(image && /^data:image
|
|
30
|
+
return s === 'data' && !(image && /^data:image\/(png|jpeg|gif|webp|avif)[;,]/i.test(u)); // raster only: an SVG can carry script
|
|
46
31
|
}
|
|
47
32
|
|
|
48
33
|
/* ---- HTML → Markdown ---- */
|
|
49
|
-
// < > and an entity-shaped & are escaped too, so typed markup stays text in any renderer.
|
|
50
34
|
const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
|
|
51
|
-
const esc = (t) => t.replace(/([\\`*_[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
|
|
52
|
-
//
|
|
35
|
+
const esc = (t) => t.replace(/([\\`*_~[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
|
|
36
|
+
// Line starts that would parse as a heading, list or rule
|
|
37
|
+
const escLead = (text) => text.split('\n').map(l => l
|
|
38
|
+
.replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
|
|
39
|
+
.replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
|
|
40
|
+
.replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
|
|
41
|
+
.replace(/^(\s*)(\d+)\.(?=\s)/, '$1$2\\.')).join('\n');
|
|
53
42
|
const mdUrl = (u) => u.replace(/[\s()<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')).replace(ENTITY, '\\&');
|
|
54
43
|
|
|
55
44
|
function inline(node) {
|
|
@@ -69,19 +58,18 @@
|
|
|
69
58
|
return out;
|
|
70
59
|
}
|
|
71
60
|
|
|
72
|
-
// <br> inside a <pre> is a line break; textContent alone would run the lines together.
|
|
73
61
|
const preText = (n) => [...n.childNodes].map(c =>
|
|
74
62
|
c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
|
|
75
63
|
|
|
76
64
|
function block(node, depth) {
|
|
77
|
-
if (node.nodeType === 3) { const t = node.textContent.trim(); return t ? esc(t) : ''; }
|
|
65
|
+
if (node.nodeType === 3) { const t = node.textContent.trim(); return t ? escLead(esc(t)) : ''; }
|
|
78
66
|
if (node.nodeType !== 1) return '';
|
|
79
67
|
const tag = node.tagName;
|
|
80
68
|
if (tag === 'HR') return '---';
|
|
81
69
|
if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
|
|
82
70
|
if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
|
|
83
71
|
if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
|
|
84
|
-
if (tag === 'TABLE') return tableLines(node).map(cells => cells.map(
|
|
72
|
+
if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
|
|
85
73
|
if (tag === 'UL' || tag === 'OL') {
|
|
86
74
|
const pad = ' '.repeat(depth);
|
|
87
75
|
return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
|
|
@@ -93,10 +81,11 @@
|
|
|
93
81
|
return pad + bullet + own + (sub ? '\n' + sub : '');
|
|
94
82
|
}).join('\n');
|
|
95
83
|
}
|
|
96
|
-
|
|
84
|
+
// A container of blocks (dl, figure, a cell's paragraphs) keeps them apart
|
|
85
|
+
if ([...node.children].some(c => BLOCK.includes(c.tagName))) return blocks(node, depth);
|
|
86
|
+
return escLead(inline(node).trim());
|
|
97
87
|
}
|
|
98
88
|
|
|
99
|
-
// A list item's own text is everything except its nested lists.
|
|
100
89
|
function nestedStripped(li, nested) {
|
|
101
90
|
if (!nested.length) return li;
|
|
102
91
|
const c = li.cloneNode(true);
|
|
@@ -104,18 +93,41 @@
|
|
|
104
93
|
return c;
|
|
105
94
|
}
|
|
106
95
|
|
|
96
|
+
// Consecutive inline nodes (loose text, marks) are one paragraph, formatting kept
|
|
107
97
|
function blocks(root, depth) {
|
|
108
98
|
const parts = [];
|
|
109
|
-
|
|
99
|
+
let run = null;
|
|
100
|
+
const flush = () => { if (run) { const t = escLead(inline(run).trim()); if (t) parts.push(t); run = null; } };
|
|
101
|
+
root.childNodes.forEach(n => {
|
|
102
|
+
if (n.nodeType === 1 && (BLOCK.includes(n.tagName) || [...n.children].some(c => BLOCK.includes(c.tagName)))) {
|
|
103
|
+
flush();
|
|
104
|
+
const b = block(n, depth || 0); if (b) parts.push(b);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (n.nodeType === 1 || n.nodeType === 3) (run = run || document.createElement('div')).appendChild(n.cloneNode(true));
|
|
108
|
+
});
|
|
109
|
+
flush();
|
|
110
110
|
return parts.join('\n\n');
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
|
|
114
|
+
// Single-line values never parse block syntax, so line starts need no escape
|
|
115
|
+
const inlineMarkdown = (root) => {
|
|
116
|
+
const flat = document.createElement('div');
|
|
117
|
+
flat.innerHTML = flattenLines(root.innerHTML, true);
|
|
118
|
+
return inline(flat).trim();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// A cell's blocks joined with a space
|
|
122
|
+
const cellMarkdown = (cell) => {
|
|
123
|
+
if (![...cell.children].some(c => BLOCK.includes(c.tagName))) return inline(cell).trim();
|
|
124
|
+
const c = cell.cloneNode(true);
|
|
125
|
+
[...c.children].forEach(b => { if (BLOCK.includes(b.tagName)) { b.before(' '); b.after(' '); } });
|
|
126
|
+
return inline(c).replace(/\s+/g, ' ').trim();
|
|
127
|
+
};
|
|
114
128
|
|
|
115
|
-
/* ---- Tables in markdown ----
|
|
116
|
-
|
|
117
|
-
row, cells joined by " · " — readable, and unlike pipes or tabs not syntax
|
|
118
|
-
that re-parses as something else. */
|
|
129
|
+
/* ---- Tables in markdown ---- */
|
|
130
|
+
// One line per row, cells joined by " · ".
|
|
119
131
|
const CELL_SEP = ' · ';
|
|
120
132
|
const tableLines = (table) => [...table.rows].map(r => [...r.cells])
|
|
121
133
|
.filter(cells => cells.some(c => c.textContent.trim() || c.querySelector('img')));
|
|
@@ -123,14 +135,13 @@
|
|
|
123
135
|
function flattenTables(html, blocks) {
|
|
124
136
|
const t = document.createElement('template');
|
|
125
137
|
t.innerHTML = String(html == null ? '' : html);
|
|
126
|
-
// Innermost first, so a nested table is already text when its host is read.
|
|
127
138
|
[...t.content.querySelectorAll('table')].reverse().forEach(table => {
|
|
128
139
|
const frag = document.createDocumentFragment();
|
|
129
140
|
tableLines(table).forEach((cells, i) => {
|
|
130
141
|
const line = blocks ? document.createElement('p') : frag;
|
|
131
142
|
if (!blocks && i) frag.appendChild(document.createElement('br'));
|
|
132
143
|
cells.forEach((cell, j) => {
|
|
133
|
-
cell.querySelectorAll(
|
|
144
|
+
cell.querySelectorAll(BLOCK_SEL).forEach(b => b.replaceWith(...b.childNodes, ' '));
|
|
134
145
|
cell.normalize();
|
|
135
146
|
const a = cell.firstChild, z = cell.lastChild;
|
|
136
147
|
if (a && a.nodeType === 3) a.nodeValue = a.nodeValue.replace(/^\s+/, '');
|
|
@@ -145,17 +156,37 @@
|
|
|
145
156
|
return t.innerHTML;
|
|
146
157
|
}
|
|
147
158
|
|
|
159
|
+
// Html-only blocks become paragraphs for a markdown document
|
|
160
|
+
// Markdown keeps only its own marks: other inline tags (u, mark, sub, span...) are unwrapped
|
|
161
|
+
function markdownMarks(html) {
|
|
162
|
+
const t = document.createElement('template');
|
|
163
|
+
t.innerHTML = html;
|
|
164
|
+
[...t.content.querySelectorAll('*')].reverse().forEach(el => {
|
|
165
|
+
if (INLINE_OK.has(el.tagName) && !MARKS[el.tagName] && !/^(A|IMG|BR)$/.test(el.tagName)) el.replaceWith(...el.childNodes);
|
|
166
|
+
});
|
|
167
|
+
return t.innerHTML;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function markdownBlocks(html) {
|
|
171
|
+
const t = document.createElement('template');
|
|
172
|
+
t.innerHTML = html;
|
|
173
|
+
[...t.content.querySelectorAll('dt, dd, figcaption, address')].reverse().forEach(b => {
|
|
174
|
+
if ([...b.children].some(c => BLOCK.includes(c.tagName))) b.replaceWith(...b.childNodes);
|
|
175
|
+
else retagBlock(b, 'p');
|
|
176
|
+
});
|
|
177
|
+
[...t.content.querySelectorAll('dl, figure')].reverse().forEach(b => b.replaceWith(...b.childNodes));
|
|
178
|
+
return t.innerHTML;
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
/* ---- Markdown → HTML ---- */
|
|
149
182
|
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
150
183
|
|
|
151
|
-
// Escapes are
|
|
152
|
-
|
|
153
|
-
const HOLD = '\uE000';
|
|
184
|
+
// Escapes and code spans (verbatim) are held out of the mark passes, left to right, and restored last
|
|
185
|
+
const HOLD = '';
|
|
154
186
|
function inlineHtml(s) {
|
|
155
187
|
const held = [];
|
|
156
188
|
return escHtml(s)
|
|
157
|
-
.replace(/\\(<|>|&|[\\`*_~[\]])
|
|
158
|
-
.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
|
|
189
|
+
.replace(/\\(<|>|&|[\\`*_~[\]#+.-])|`([^`]+)`/g, (_, c, code) => HOLD + (held.push(code != null ? `<code>${code}</code>` : c) - 1) + HOLD)
|
|
159
190
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
160
191
|
.replace(/~~([^~]+)~~/g, '<s>$1</s>')
|
|
161
192
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
|
@@ -165,7 +196,7 @@
|
|
|
165
196
|
.replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
|
|
166
197
|
}
|
|
167
198
|
|
|
168
|
-
//
|
|
199
|
+
// Four spaces per nesting level.
|
|
169
200
|
function renderList(items, i, depth) {
|
|
170
201
|
const tag = items[i].tag;
|
|
171
202
|
let html = `<${tag}>`;
|
|
@@ -196,7 +227,7 @@
|
|
|
196
227
|
const flushAll = () => { flushPara(); flushList(); flushQuote(); };
|
|
197
228
|
|
|
198
229
|
for (const raw of lines) {
|
|
199
|
-
const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
|
|
230
|
+
const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
|
|
200
231
|
if (fence !== null) { if (/^```/.test(line)) { out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
|
|
201
232
|
if (/^```/.test(line)) { flushAll(); fence = []; continue; }
|
|
202
233
|
if (!line.trim()) { flushAll(); continue; }
|
|
@@ -219,32 +250,30 @@
|
|
|
219
250
|
return out.join('');
|
|
220
251
|
}
|
|
221
252
|
|
|
222
|
-
/* ---- Sanitize
|
|
223
|
-
//
|
|
224
|
-
// outside the allowlist is unwrapped, which keeps a pasted document's text and
|
|
225
|
-
// marks while dropping its markup.
|
|
253
|
+
/* ---- Sanitize ---- */
|
|
254
|
+
// Payload elements are dropped; anything else outside the allowlist is unwrapped.
|
|
226
255
|
const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'MATH']);
|
|
227
256
|
|
|
228
257
|
function sanitize(html, allowBlocks, keepStyle) {
|
|
229
258
|
const t = document.createElement('template');
|
|
230
259
|
t.innerHTML = String(html == null ? '' : html);
|
|
231
|
-
const ok = new Set(allowBlocks ? [...INLINE_OK, ...BLOCK, 'INPUT', 'TR', 'TBODY', 'THEAD', 'CAPTION', '
|
|
232
|
-
// Depth-first
|
|
233
|
-
// hoisted out of a stripped wrapper escapes the walk.
|
|
260
|
+
const ok = new Set(allowBlocks ? [...INLINE_OK, ...BLOCK, 'INPUT', 'TR', 'TBODY', 'THEAD', 'CAPTION', 'COLGROUP', 'COL'] : INLINE_OK);
|
|
261
|
+
// Depth-first, so nothing hoisted out of an unwrapped parent escapes the walk
|
|
234
262
|
const walk = (node) => [...node.childNodes].forEach(c => {
|
|
235
263
|
if (c.nodeType === 8) return c.remove();
|
|
236
264
|
if (c.nodeType !== 1) return;
|
|
237
265
|
if (DROP.has(c.tagName)) return c.remove();
|
|
238
266
|
walk(c);
|
|
239
267
|
if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
|
|
268
|
+
const meta = INLINE_TAGS[c.tagName.toLowerCase()];
|
|
240
269
|
[...c.attributes].forEach(a => {
|
|
241
270
|
const n = a.name.toLowerCase();
|
|
242
271
|
if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
|
|
243
272
|
if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
|
|
244
273
|
if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
|
|
245
|
-
// Without these a merged cell comes apart on the next save
|
|
246
274
|
if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
|
|
247
|
-
if (
|
|
275
|
+
if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
|
|
276
|
+
if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
|
|
248
277
|
c.removeAttribute(a.name);
|
|
249
278
|
});
|
|
250
279
|
});
|
|
@@ -252,36 +281,83 @@
|
|
|
252
281
|
return t.innerHTML;
|
|
253
282
|
}
|
|
254
283
|
|
|
255
|
-
// Markdown → HTML bound for the DOM: always through the sanitizer.
|
|
256
284
|
const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
|
|
257
285
|
|
|
286
|
+
// Pasted spans left bare once their styles are stripped carry nothing
|
|
287
|
+
function unwrapBareSpans(html) {
|
|
288
|
+
const t = document.createElement('template');
|
|
289
|
+
t.innerHTML = html;
|
|
290
|
+
[...t.content.querySelectorAll('span')].reverse().forEach(s => { if (!s.attributes.length) s.replaceWith(...s.childNodes); });
|
|
291
|
+
return t.innerHTML;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ---- Single-line content ---- */
|
|
295
|
+
const flatText = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ');
|
|
296
|
+
|
|
297
|
+
// Blocks and breaks become spaces, then inline-only and whitespace-collapsed.
|
|
298
|
+
function flattenLines(html, keepStyle) {
|
|
299
|
+
const t = document.createElement('template');
|
|
300
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
301
|
+
t.content.querySelectorAll('br').forEach(b => b.replaceWith(' '));
|
|
302
|
+
t.content.querySelectorAll('*').forEach(el => { if (!INLINE_OK.has(el.tagName)) { el.before(' '); el.after(' '); } });
|
|
303
|
+
t.innerHTML = sanitize(t.innerHTML, false, keepStyle);
|
|
304
|
+
// One space per boundary, across element edges too
|
|
305
|
+
// Images and other replaced elements are content: the space after one stays
|
|
306
|
+
const walker = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
307
|
+
let spaced = true, last = null;
|
|
308
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
309
|
+
if (n.nodeType === 1) { if (/^(IMG|WBR)$/.test(n.tagName)) { spaced = false; last = n; } continue; }
|
|
310
|
+
let v = n.nodeValue.replace(/[ \t\n\r\f]+/g, ' ');
|
|
311
|
+
if (spaced) v = v.replace(/^ /, '');
|
|
312
|
+
if (v) { spaced = v.endsWith(' '); last = n; }
|
|
313
|
+
n.nodeValue = v;
|
|
314
|
+
}
|
|
315
|
+
if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/ $/, '');
|
|
316
|
+
return t.innerHTML;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// One line per block or <br> (.inline with breaks): boundaries become single <br>s, none at the ends.
|
|
320
|
+
const LINE = '\u2028', PARA = '\u2029';
|
|
321
|
+
function breakLines(html, keepStyle) {
|
|
322
|
+
const t = document.createElement('template');
|
|
323
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
324
|
+
t.content.querySelectorAll('br').forEach(b => b.replaceWith(LINE));
|
|
325
|
+
t.content.querySelectorAll(BLOCK_SEL).forEach(b => { b.before(PARA); b.after(PARA); });
|
|
326
|
+
return flattenLines(t.innerHTML, keepStyle)
|
|
327
|
+
.replace(/ *([\u2028\u2029]) */g, '$1')
|
|
328
|
+
.replace(/\u2029+/g, PARA).replace(/\u2028\u2029|\u2029\u2028/g, LINE)
|
|
329
|
+
.replace(/^[\u2028\u2029]+|[\u2028\u2029]+$/g, '')
|
|
330
|
+
.replace(/[\u2028\u2029]/g, '<br>');
|
|
331
|
+
}
|
|
332
|
+
const textLines = (text) => String(text == null ? '' : text).split(/\r\n?|\n/).map(l => l.trim()).filter(Boolean)
|
|
333
|
+
.map(l => sanitize(inlineHtml(l), false)).join('<br>');
|
|
334
|
+
|
|
258
335
|
/* ---- Selection primitives ---- */
|
|
259
336
|
const sel = () => window.getSelection();
|
|
260
|
-
|
|
337
|
+
// `probe` stands in for the live range while a control is judged against an area's saved one
|
|
338
|
+
let probe = null;
|
|
339
|
+
const liveRange = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
|
|
340
|
+
const range = () => probe || liveRange();
|
|
261
341
|
|
|
262
342
|
function elementAt(node) { while (node && node.nodeType !== 1) node = node.parentNode; return node; }
|
|
263
343
|
|
|
264
|
-
//
|
|
265
|
-
// that is itself a formatting element — <strong x-text-edit> is a perfectly
|
|
266
|
-
// reasonable thing to write — reports its own tag as formatting on the text
|
|
267
|
-
// inside it, and toggling that tag off unwraps the editor.
|
|
344
|
+
// Bounded by the area: an area that is itself <strong> never reports as formatting
|
|
268
345
|
function ancestor(match) {
|
|
269
346
|
const r = range(); if (!r) return null;
|
|
270
347
|
const el = elementAt(r.startContainer); if (!el) return null;
|
|
271
348
|
const found = el.closest(match); if (!found) return null;
|
|
272
|
-
const area = el.closest('[data-text
|
|
349
|
+
const area = el.closest('[data-rich-text]');
|
|
273
350
|
if (area && (found === area || !area.contains(found))) return null;
|
|
274
351
|
return found;
|
|
275
352
|
}
|
|
276
353
|
|
|
277
354
|
const withinArea = (node, found) => {
|
|
278
355
|
if (!found) return false;
|
|
279
|
-
const el = elementAt(node), area = el && el.closest('[data-text
|
|
356
|
+
const el = elementAt(node), area = el && el.closest('[data-rich-text]');
|
|
280
357
|
return !area || (found !== area && area.contains(found));
|
|
281
358
|
};
|
|
282
359
|
|
|
283
|
-
//
|
|
284
|
-
// resolves to the area itself, and a block command replaces the whole editor.
|
|
360
|
+
// Bounded by the area, so a block command never replaces the editor
|
|
285
361
|
function blockEl() {
|
|
286
362
|
let n = elementAt(range() && range().startContainer);
|
|
287
363
|
while (n && !areas.has(n)) {
|
|
@@ -291,8 +367,6 @@
|
|
|
291
367
|
return null;
|
|
292
368
|
}
|
|
293
369
|
|
|
294
|
-
// deleteContents can take the whole paragraph with it, leaving the caret loose in
|
|
295
|
-
// the area. Put it back in a block before any command reads it.
|
|
296
370
|
function ensureBlock(area) {
|
|
297
371
|
let b = blockEl();
|
|
298
372
|
if (b) return b;
|
|
@@ -309,9 +383,7 @@
|
|
|
309
383
|
r.selectNodeContents(node); s.removeAllRanges(); s.addRange(r);
|
|
310
384
|
}
|
|
311
385
|
|
|
312
|
-
//
|
|
313
|
-
// element or restoring a history snapshot both rebuild nodes, so the caret has to
|
|
314
|
-
// be addressed by position in the text rather than by node identity.
|
|
386
|
+
// Caret as a character offset, so it survives rebuilt nodes
|
|
315
387
|
function offsetIn(root) {
|
|
316
388
|
const r = range(); if (!r || !root.contains(r.startContainer)) return null;
|
|
317
389
|
const pre = document.createRange();
|
|
@@ -323,12 +395,7 @@
|
|
|
323
395
|
function caretIn(root, offset) {
|
|
324
396
|
const s = sel(), r = document.createRange();
|
|
325
397
|
if (offset == null) offset = 0;
|
|
326
|
-
// An empty block
|
|
327
|
-
// range is anchored on the element. Anchored on an empty text node instead,
|
|
328
|
-
// the browser normalizes the caret outward and the next keystroke lands as a
|
|
329
|
-
// sibling of the block rather than inside it. Verified in Chrome; the <br> is
|
|
330
|
-
// the same placeholder contenteditable inserts itself, and the serializer
|
|
331
|
-
// drops a block that holds only one.
|
|
398
|
+
// An empty block holds a caret only with a <br> and an element anchor
|
|
332
399
|
if (!root.textContent.length) {
|
|
333
400
|
[...root.childNodes].forEach(n => { if (n.nodeType === 3) n.remove(); });
|
|
334
401
|
if (!root.querySelector('br')) root.appendChild(document.createElement('br'));
|
|
@@ -351,9 +418,7 @@
|
|
|
351
418
|
s.removeAllRanges(); s.addRange(r);
|
|
352
419
|
}
|
|
353
420
|
|
|
354
|
-
//
|
|
355
|
-
// selection — wrapping node by node never crosses an element boundary, which is
|
|
356
|
-
// what keeps <code> from swallowing half a paragraph.
|
|
421
|
+
// Boundary text nodes are split so every node returned lies wholly inside the selection
|
|
357
422
|
function selectedTextNodes() {
|
|
358
423
|
const r = range(); if (!r || r.collapsed) return [];
|
|
359
424
|
if (r.endContainer.nodeType === 3 && r.endOffset < r.endContainer.length) {
|
|
@@ -375,24 +440,22 @@
|
|
|
375
440
|
return out.length ? out : (r.startContainer.nodeType === 3 ? [r.startContainer] : []);
|
|
376
441
|
}
|
|
377
442
|
|
|
378
|
-
/* ---- Inline tags ----
|
|
379
|
-
|
|
380
|
-
handful — that is what lets the command set be the tag set. */
|
|
381
|
-
function wrapInline(tag, style) {
|
|
443
|
+
/* ---- Inline tags ---- */
|
|
444
|
+
function wrapInline(tag, style, attrs) {
|
|
382
445
|
const nodes = selectedTextNodes(); if (!nodes.length) return [];
|
|
383
446
|
const made = [];
|
|
384
447
|
nodes.forEach(n => {
|
|
385
448
|
if (!n.nodeValue.length) return;
|
|
386
449
|
const el = document.createElement(tag);
|
|
387
450
|
if (style) Object.assign(el.style, style);
|
|
451
|
+
if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
388
452
|
n.parentNode.insertBefore(el, n);
|
|
389
453
|
el.appendChild(n);
|
|
390
454
|
made.push(el);
|
|
391
|
-
el.querySelectorAll(tag).forEach(dupe => dupe.replaceWith(...dupe.childNodes));
|
|
455
|
+
el.querySelectorAll(tag).forEach(dupe => dupe.replaceWith(...dupe.childNodes));
|
|
392
456
|
});
|
|
393
457
|
if (!made.length) return made;
|
|
394
|
-
//
|
|
395
|
-
// downstream — active state, setting an href — asks what the caret is in.
|
|
458
|
+
// Boundaries land inside the new elements
|
|
396
459
|
const s = sel(), r = document.createRange(), last = made[made.length - 1];
|
|
397
460
|
r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length);
|
|
398
461
|
s.removeAllRanges(); s.addRange(r);
|
|
@@ -405,9 +468,7 @@
|
|
|
405
468
|
nodes.forEach(n => { const el = elementAt(n) && elementAt(n).closest(tag); if (withinArea(n, el)) targets.add(el); });
|
|
406
469
|
const host = ancestor(tag); if (host) targets.add(host);
|
|
407
470
|
targets.forEach(el => el.replaceWith(...el.childNodes));
|
|
408
|
-
//
|
|
409
|
-
// the text and nothing selected — so turning a style off lost the selection
|
|
410
|
-
// that turning it on had kept. The same text goes back under selection.
|
|
471
|
+
// Reselect the same text
|
|
411
472
|
const live = nodes.filter(n => n.isConnected && n.length);
|
|
412
473
|
if (!live.length) return;
|
|
413
474
|
const r = document.createRange();
|
|
@@ -416,11 +477,7 @@
|
|
|
416
477
|
const s = sel(); s.removeAllRanges(); s.addRange(r);
|
|
417
478
|
}
|
|
418
479
|
|
|
419
|
-
//
|
|
420
|
-
// start container instead — as this did — gets the wrong answer whenever the
|
|
421
|
-
// selection starts on an element rather than inside the text: a triple-clicked
|
|
422
|
-
// paragraph reports "not bold", so the toggle wraps what was already wrapped and
|
|
423
|
-
// the nesting cleanup quietly undoes it. Which reads as the button doing nothing.
|
|
480
|
+
// Every selected text node carries the tag
|
|
424
481
|
function selectionMarked(tag) {
|
|
425
482
|
const r = range(); if (!r) return false;
|
|
426
483
|
if (r.collapsed) return !!ancestor(tag);
|
|
@@ -436,39 +493,68 @@
|
|
|
436
493
|
return any;
|
|
437
494
|
}
|
|
438
495
|
|
|
439
|
-
const toggleInline = (tag, style) => selectionMarked(tag) ? unwrapInline(tag) : wrapInline(tag, style);
|
|
496
|
+
const toggleInline = (tag, style, attrs) => selectionMarked(tag) ? unwrapInline(tag) : wrapInline(tag, style, attrs);
|
|
497
|
+
|
|
498
|
+
const attrsFor = (tag, v) => {
|
|
499
|
+
const m = INLINE_TAGS[tag], val = v != null && v !== '' ? String(v) : m.def;
|
|
500
|
+
return m.attr && val ? { [m.attr]: val } : null;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
function insertVoid(tag) {
|
|
504
|
+
const r = range(); if (!r) return;
|
|
505
|
+
r.deleteContents();
|
|
506
|
+
const n = document.createElement(tag);
|
|
507
|
+
r.insertNode(n);
|
|
508
|
+
const s = sel(), nr = document.createRange();
|
|
509
|
+
nr.setStartAfter(n); nr.collapse(true);
|
|
510
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ---- Block tags ---- */
|
|
514
|
+
// A cell's loose content into a <p> inside it, so block commands nest rather than replace the cell
|
|
515
|
+
function innerBlock(cell) {
|
|
516
|
+
const off = offsetIn(cell), p = document.createElement('p');
|
|
517
|
+
while (cell.firstChild) p.appendChild(cell.firstChild);
|
|
518
|
+
cell.appendChild(p);
|
|
519
|
+
caretIn(p, off);
|
|
520
|
+
return p;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// The block a command may replace: never a cell, list item, term or container
|
|
524
|
+
function freeBlock() {
|
|
525
|
+
const b = blockEl(); if (!b) return null;
|
|
526
|
+
if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return null;
|
|
527
|
+
if (/^(TD|TH)$/.test(b.tagName)) return innerBlock(b);
|
|
528
|
+
if (/^(LI|DT|DD)$/.test(b.tagName)) return liftItem(b);
|
|
529
|
+
return b;
|
|
530
|
+
}
|
|
440
531
|
|
|
441
|
-
/* ---- Block tags ----
|
|
442
|
-
Swapping the element outright covers every block tag, where formatBlock only
|
|
443
|
-
accepts a short list and disagrees between engines. */
|
|
444
532
|
function setBlock(tag) {
|
|
445
533
|
let b = blockEl(); if (!b) return;
|
|
534
|
+
if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return;
|
|
535
|
+
if (/^(TD|TH)$/.test(b.tagName)) b = innerBlock(b);
|
|
446
536
|
const want = (b.tagName === tag.toUpperCase() ? 'p' : tag).toLowerCase();
|
|
447
537
|
if (b.tagName === want.toUpperCase()) return;
|
|
448
|
-
//
|
|
449
|
-
// <ul><h2> — so lift the item out of its list first, then retag.
|
|
450
|
-
// Retagging in place serialized to nothing: the content vanished.
|
|
451
|
-
// A NESTED item outdents to the top level first, or the lift would
|
|
452
|
-
// land the new block inside the outer <li>.
|
|
538
|
+
// Out of the list first (outdenting nested items to the top), then retag
|
|
453
539
|
if (b.tagName === 'LI' && want !== 'li') {
|
|
454
|
-
const area = b.closest('[data-text
|
|
540
|
+
const area = b.closest('[data-rich-text]');
|
|
455
541
|
for (let guard = 0; guard < 8; guard++) {
|
|
456
542
|
const list = b && b.tagName === 'LI' && b.closest('ul, ol');
|
|
457
543
|
if (!list || !list.parentElement || !list.parentElement.closest('li')) break;
|
|
458
544
|
indent(-1, area);
|
|
459
545
|
b = blockEl(); if (!b) return;
|
|
460
546
|
}
|
|
461
|
-
if (b.tagName === 'LI') {
|
|
547
|
+
if (b.tagName === 'LI') { liftItem(b); b = blockEl(); if (!b) return; }
|
|
462
548
|
if (b.tagName === want.toUpperCase()) return;
|
|
463
549
|
}
|
|
550
|
+
if (/^(DT|DD)$/.test(b.tagName)) { b = liftItem(b); if (want === 'p') return; }
|
|
464
551
|
const n = document.createElement(want);
|
|
465
552
|
n.setAttribute('style', b.getAttribute('style') || '');
|
|
466
553
|
if (!n.getAttribute('style')) n.removeAttribute('style');
|
|
467
554
|
const off = offsetIn(b);
|
|
468
555
|
while (b.firstChild) n.appendChild(b.firstChild);
|
|
469
556
|
b.replaceWith(n);
|
|
470
|
-
// A sublist that rode along out
|
|
471
|
-
// heading or quote — it steps out right after the new block.
|
|
557
|
+
// A sublist that rode along steps out after the new block
|
|
472
558
|
[...n.querySelectorAll(':scope > ul, :scope > ol')].reverse().forEach(l => n.after(l));
|
|
473
559
|
caretIn(n, off);
|
|
474
560
|
}
|
|
@@ -476,28 +562,77 @@
|
|
|
476
562
|
const styleBlock = (prop, value) => { const b = blockEl(); if (b) b.style[prop] = value; };
|
|
477
563
|
const readBlockStyle = (prop) => { const b = blockEl(); return b ? getComputedStyle(b)[prop] : ''; };
|
|
478
564
|
|
|
565
|
+
function retagKeep(b, tag) { const off = offsetIn(b); caretIn(retagBlock(b, tag), off); }
|
|
566
|
+
|
|
567
|
+
/* ---- Figures and description lists ---- */
|
|
568
|
+
function toggleFigure() {
|
|
569
|
+
const existing = ancestor('figure');
|
|
570
|
+
if (existing) {
|
|
571
|
+
const caption = existing.querySelector(':scope > figcaption');
|
|
572
|
+
if (caption) caption.remove();
|
|
573
|
+
existing.replaceWith(...existing.childNodes);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const b = freeBlock(); if (!b) return;
|
|
577
|
+
const fig = document.createElement('figure');
|
|
578
|
+
b.replaceWith(fig);
|
|
579
|
+
fig.appendChild(b);
|
|
580
|
+
const caption = document.createElement('figcaption');
|
|
581
|
+
caption.appendChild(document.createElement('br'));
|
|
582
|
+
fig.appendChild(caption);
|
|
583
|
+
caretIn(caption, 0);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// One caption per figure: an existing one takes the caret instead
|
|
587
|
+
function setCaption() {
|
|
588
|
+
const fig = ancestor('figure'), b = blockEl(); if (!fig || !b) return;
|
|
589
|
+
const caption = fig.querySelector(':scope > figcaption');
|
|
590
|
+
if (caption && caption !== b && !caption.contains(b)) return caretIn(caption, caption.textContent.length);
|
|
591
|
+
if (b.parentElement === fig) setBlock('figcaption');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function toggleDl() {
|
|
595
|
+
const dl = ancestor('dl');
|
|
596
|
+
if (dl) {
|
|
597
|
+
const term = ancestor('dt, dd'), off = term ? offsetIn(term) : 0;
|
|
598
|
+
let land = null;
|
|
599
|
+
[...dl.children].forEach(c => {
|
|
600
|
+
if ([...c.children].some(x => BLOCK.includes(x.tagName))) { if (c === term) land = c.firstElementChild; c.replaceWith(...c.childNodes); }
|
|
601
|
+
else { const p = retagBlock(c, 'p'); if (c === term) land = p; }
|
|
602
|
+
});
|
|
603
|
+
dl.replaceWith(...dl.childNodes);
|
|
604
|
+
if (land) caretIn(land, off);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const b = freeBlock(); if (!b) return;
|
|
608
|
+
const list = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
|
|
609
|
+
while (b.firstChild) dt.appendChild(b.firstChild);
|
|
610
|
+
dd.appendChild(document.createElement('br'));
|
|
611
|
+
list.append(dt, dd);
|
|
612
|
+
b.replaceWith(list);
|
|
613
|
+
caretIn(dd, 0);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Retags the term itself, never a block nested in one
|
|
617
|
+
const setTerm = (tag) => () => {
|
|
618
|
+
const term = ancestor('dt, dd');
|
|
619
|
+
if (!term || term.tagName === tag.toUpperCase()) return;
|
|
620
|
+
retagKeep(term, tag);
|
|
621
|
+
};
|
|
622
|
+
const termIs = (tag) => { const t = ancestor('dt, dd'); return !!t && t.tagName === tag; };
|
|
623
|
+
|
|
479
624
|
/* ---- Lists, tables, indent ---- */
|
|
480
625
|
const inList = () => !!ancestor('li');
|
|
481
|
-
const inTable = () => !!cellAt();
|
|
482
|
-
|
|
483
|
-
/* ---- Tables ----
|
|
484
|
-
|
|
485
|
-
one has: rows and columns added and removed where the caret is, cells merged
|
|
486
|
-
and split, and arrows that move the way the grid looks rather than the way the
|
|
487
|
-
markup is ordered. */
|
|
488
|
-
let cellDrag = null; // a rectangle of cells picked with the pointer
|
|
489
|
-
// A block of cells selected by pointer has no caret in it, so the table commands
|
|
490
|
-
// ask for either — otherwise selecting empty cells and pressing Merge does
|
|
491
|
-
// nothing, which is exactly how it looked.
|
|
626
|
+
const inTable = () => !!cellAt();
|
|
627
|
+
|
|
628
|
+
/* ---- Tables ---- */
|
|
629
|
+
let cellDrag = null; // pointer-picked block of cells
|
|
492
630
|
const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
|
|
493
631
|
const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
|
|
494
632
|
const rowsOf = (table) => [...table.rows];
|
|
495
633
|
const emptyCell = () => { const td = document.createElement('td'); td.appendChild(document.createElement('br')); return td; };
|
|
496
634
|
|
|
497
|
-
|
|
498
|
-
spans rows is present in each row it covers. Counting children instead — as
|
|
499
|
-
this did — loses track the moment anything is merged, which is how rows end up
|
|
500
|
-
with different lengths and cells appear to go missing. */
|
|
635
|
+
// Occupancy grid: every slot maps to the cell covering it, spans included
|
|
501
636
|
function tableGrid(table) {
|
|
502
637
|
const grid = [];
|
|
503
638
|
rowsOf(table).forEach((row, r) => {
|
|
@@ -531,9 +666,7 @@
|
|
|
531
666
|
|
|
532
667
|
const columnCount = (table) => Math.max(0, ...tableGrid(table).map(r => r.length));
|
|
533
668
|
|
|
534
|
-
//
|
|
535
|
-
// whose cells look like they went missing — there is no way to click into a slot
|
|
536
|
-
// that does not exist, so it cannot be fixed by hand.
|
|
669
|
+
// Short rows are padded back out
|
|
537
670
|
function normalizeTables(root) {
|
|
538
671
|
root.querySelectorAll('table').forEach(table => {
|
|
539
672
|
const rows = rowsOf(table); if (!rows.length) return;
|
|
@@ -543,7 +676,7 @@
|
|
|
543
676
|
const have = (grid[r] || []).filter(Boolean).length;
|
|
544
677
|
for (let i = have; i < width; i++) row.appendChild(emptyCell());
|
|
545
678
|
});
|
|
546
|
-
if (!table.querySelector('tbody, thead')) {
|
|
679
|
+
if (!table.querySelector('tbody, thead')) {
|
|
547
680
|
const body = document.createElement('tbody');
|
|
548
681
|
while (table.firstChild) body.appendChild(table.firstChild);
|
|
549
682
|
table.appendChild(body);
|
|
@@ -566,7 +699,7 @@
|
|
|
566
699
|
const seen = new Set();
|
|
567
700
|
rowsOf(table).forEach((row, r) => {
|
|
568
701
|
const at = (grid[r] || [])[col];
|
|
569
|
-
if (!at || seen.has(at)) return;
|
|
702
|
+
if (!at || seen.has(at)) return;
|
|
570
703
|
seen.add(at);
|
|
571
704
|
const fresh = document.createElement(at.tagName === 'TH' ? 'th' : 'td');
|
|
572
705
|
fresh.appendChild(document.createElement('br'));
|
|
@@ -602,15 +735,10 @@
|
|
|
602
735
|
caretIn(p, 0);
|
|
603
736
|
}
|
|
604
737
|
|
|
605
|
-
//
|
|
606
|
-
// the one to the right, which is what a single "merge" press usually means.
|
|
607
|
-
// A merge covers a RECTANGLE, so the spans come from the block's bounds — summing
|
|
608
|
-
// the cells' own spans turns a 2x2 selection into one cell four rows tall.
|
|
738
|
+
// Selected cells merge as a rectangle; a lone caret merges with the cell to its right
|
|
609
739
|
function mergeCells() {
|
|
610
740
|
const cell = cellAt(); if (!cell) return;
|
|
611
741
|
const picked = selectedCells();
|
|
612
|
-
// With nothing selected, merge with the neighbour to the right — the reading
|
|
613
|
-
// of a single "merge" press, and the only one available in an empty cell.
|
|
614
742
|
const cells = picked.length > 1 ? picked : [cell, nextCell(cell)].filter(Boolean);
|
|
615
743
|
if (cells.length < 2) return;
|
|
616
744
|
|
|
@@ -634,8 +762,8 @@
|
|
|
634
762
|
tidyCell(first);
|
|
635
763
|
const table = first.closest('table');
|
|
636
764
|
normalizeTables(table.parentElement || table);
|
|
637
|
-
cellDrag = null;
|
|
638
|
-
paintCells(first.closest('[data-text
|
|
765
|
+
cellDrag = null;
|
|
766
|
+
paintCells(first.closest('[data-rich-text]') || table);
|
|
639
767
|
caretIn(first, first.textContent.length);
|
|
640
768
|
}
|
|
641
769
|
|
|
@@ -645,7 +773,6 @@
|
|
|
645
773
|
return (grid[row] || [])[col + span] || null;
|
|
646
774
|
};
|
|
647
775
|
|
|
648
|
-
// Joining cells leaves the placeholder breaks of the empty ones behind.
|
|
649
776
|
const tidyCell = (cell) => {
|
|
650
777
|
if (!cell.textContent.trim()) { cell.replaceChildren(document.createElement('br')); return; }
|
|
651
778
|
cell.querySelectorAll(':scope > br').forEach(br => br.remove());
|
|
@@ -670,16 +797,11 @@
|
|
|
670
797
|
caretIn(cell, 0);
|
|
671
798
|
}
|
|
672
799
|
|
|
673
|
-
|
|
674
|
-
show one. Worse, dragging INTO a trailing empty cell leaves the selection's
|
|
675
|
-
focus clamped to the last position that holds text — which is why the final
|
|
676
|
-
empty cell was always the one left unhighlighted. The pointer is tracked
|
|
677
|
-
instead, and its cell is the far corner. */
|
|
678
|
-
|
|
800
|
+
// Empty cells hold no text, so cell selection follows the pointer
|
|
679
801
|
const cellUnder = (x, y) => {
|
|
680
802
|
const el = document.elementFromPoint(x, y);
|
|
681
803
|
const cell = el && el.closest && el.closest('td, th');
|
|
682
|
-
return cell && cell.closest('[data-text
|
|
804
|
+
return cell && cell.closest('[data-rich-text]') ? cell : null;
|
|
683
805
|
};
|
|
684
806
|
|
|
685
807
|
function rectBetween(a, b) {
|
|
@@ -718,9 +840,7 @@
|
|
|
718
840
|
if (c.rowSpan > 1) next.rowSpan = c.rowSpan;
|
|
719
841
|
c.replaceWith(next);
|
|
720
842
|
});
|
|
721
|
-
//
|
|
722
|
-
// row it would land inside tbody, which is invalid — and table.rows then
|
|
723
|
-
// reports one row, so the next toggle rewrites the wrong one.
|
|
843
|
+
// <thead> is a child of the table, never of the tbody
|
|
724
844
|
const head = table.querySelector('thead');
|
|
725
845
|
if (!isHeader && !head) {
|
|
726
846
|
const t = document.createElement('thead');
|
|
@@ -733,15 +853,11 @@
|
|
|
733
853
|
body.prepend(first);
|
|
734
854
|
head.remove();
|
|
735
855
|
}
|
|
736
|
-
// Re-parenting the row detaches the caret, and without it the command reads
|
|
737
|
-
// as unavailable the next time — the header could be turned on but not off.
|
|
738
856
|
caretIn(table.rows[0].firstElementChild, 0);
|
|
739
857
|
}
|
|
740
858
|
|
|
741
859
|
/* ---- Column and row sizing ----
|
|
742
|
-
|
|
743
|
-
rather than on the first row's cells, so a merged or spanning cell in row one
|
|
744
|
-
cannot throw them off; heights live on the row. */
|
|
860
|
+
Widths on a <colgroup>, heights on the row. */
|
|
745
861
|
const EDGE = 5;
|
|
746
862
|
|
|
747
863
|
function ensureCols(table) {
|
|
@@ -750,26 +866,21 @@
|
|
|
750
866
|
const want = columnCount(table);
|
|
751
867
|
while (group.children.length < want) group.appendChild(document.createElement('col'));
|
|
752
868
|
while (group.children.length > want) group.lastElementChild.remove();
|
|
753
|
-
// Freeze
|
|
754
|
-
const widths = [...table.rows[0].cells];
|
|
869
|
+
// Freeze current widths so one drag doesn't reflow the rest
|
|
755
870
|
[...group.children].forEach((col, i) => {
|
|
756
871
|
if (col.style.width) return;
|
|
757
872
|
const at = (tableGrid(table)[0] || [])[i];
|
|
758
873
|
if (at) col.style.width = Math.round(at.getBoundingClientRect().width / (at.colSpan || 1)) + 'px';
|
|
759
|
-
void widths;
|
|
760
874
|
});
|
|
761
875
|
return group;
|
|
762
876
|
}
|
|
763
877
|
|
|
764
|
-
// Which border, if any, the pointer is over.
|
|
765
878
|
function borderAt(x, y) {
|
|
766
879
|
const el = document.elementFromPoint(x, y);
|
|
767
880
|
let cell = el && el.closest && el.closest('td, th');
|
|
768
881
|
if (!cell) {
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
// are where people aim, so both count.
|
|
772
|
-
const scope = el && el.closest && (el.closest('table') || el.closest('[data-text-edit]'));
|
|
882
|
+
// Outer edge: on the table, or just past it
|
|
883
|
+
const scope = el && el.closest && (el.closest('table') || el.closest('[data-rich-text]'));
|
|
773
884
|
if (!scope) return null;
|
|
774
885
|
const tables = scope.tagName === 'TABLE' ? [scope] : [...scope.querySelectorAll('table')];
|
|
775
886
|
for (const table of tables) {
|
|
@@ -781,7 +892,7 @@
|
|
|
781
892
|
}
|
|
782
893
|
return null;
|
|
783
894
|
}
|
|
784
|
-
if (!cell.closest('[data-text
|
|
895
|
+
if (!cell.closest('[data-rich-text]')) return null;
|
|
785
896
|
const b = cell.getBoundingClientRect();
|
|
786
897
|
if (Math.abs(x - b.right) <= EDGE) return { axis: 'x', cell };
|
|
787
898
|
if (Math.abs(x - b.left) <= EDGE) { const prev = previousCell(cell); return prev ? { axis: 'x', cell: prev } : null; }
|
|
@@ -792,9 +903,9 @@
|
|
|
792
903
|
|
|
793
904
|
function startResize(e, hit) {
|
|
794
905
|
const cell = hit.cell, table = cell.closest('table');
|
|
795
|
-
const area = cell.closest('[data-text
|
|
906
|
+
const area = cell.closest('[data-rich-text]');
|
|
796
907
|
e.preventDefault();
|
|
797
|
-
area.setAttribute('data-text-
|
|
908
|
+
area.setAttribute('data-rich-text-resizing', hit.axis);
|
|
798
909
|
|
|
799
910
|
let apply;
|
|
800
911
|
if (hit.axis === 'x') {
|
|
@@ -804,9 +915,7 @@
|
|
|
804
915
|
const target = group.children[index];
|
|
805
916
|
const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
|
|
806
917
|
const from = e.clientX;
|
|
807
|
-
// The
|
|
808
|
-
// there does nothing visible — the table still fills its width and the
|
|
809
|
-
// other columns simply absorb the difference — so the table is sized too.
|
|
918
|
+
// The outer border sizes the table too
|
|
810
919
|
const outer = index === group.children.length - 1;
|
|
811
920
|
const tableStart = table.getBoundingClientRect().width;
|
|
812
921
|
if (outer) table.style.width = Math.round(tableStart) + 'px';
|
|
@@ -825,14 +934,14 @@
|
|
|
825
934
|
const up = () => {
|
|
826
935
|
document.removeEventListener('pointermove', move);
|
|
827
936
|
document.removeEventListener('pointerup', up);
|
|
828
|
-
area.removeAttribute('data-text-
|
|
937
|
+
area.removeAttribute('data-rich-text-resizing');
|
|
829
938
|
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
830
939
|
};
|
|
831
940
|
document.addEventListener('pointermove', move);
|
|
832
941
|
document.addEventListener('pointerup', up);
|
|
833
942
|
}
|
|
834
943
|
|
|
835
|
-
// Tab walks the grid and grows it at the end
|
|
944
|
+
// Tab walks the grid and grows it at the end
|
|
836
945
|
function moveCell(dir) {
|
|
837
946
|
const cell = cellAt(); if (!cell) return false;
|
|
838
947
|
const cells = [...cell.closest('table').querySelectorAll('td, th')];
|
|
@@ -848,7 +957,6 @@
|
|
|
848
957
|
return true;
|
|
849
958
|
}
|
|
850
959
|
|
|
851
|
-
// Where a caret would land at a point, whichever API the engine offers.
|
|
852
960
|
function caretAtPoint(x, y) {
|
|
853
961
|
if (document.caretPositionFromPoint) {
|
|
854
962
|
const pos = document.caretPositionFromPoint(x, y);
|
|
@@ -872,13 +980,7 @@
|
|
|
872
980
|
|
|
873
981
|
const cellOf = (node) => { const el = elementAt(node); return el && el.closest('td, th'); };
|
|
874
982
|
|
|
875
|
-
|
|
876
|
-
is on its last or first line — a cell holding several lines has to be walkable
|
|
877
|
-
like any other text. Horizontal arrows cross the cell's text first.
|
|
878
|
-
|
|
879
|
-
Both aim at a point rather than an offset: stepping one line from where the
|
|
880
|
-
caret actually is keeps the column, which is what makes returning to a cell
|
|
881
|
-
land under where you left rather than at an arbitrary end. */
|
|
983
|
+
// Arrows move by line within a cell, then cell to cell, keeping the column
|
|
882
984
|
function arrowInTable(e) {
|
|
883
985
|
const cell = cellAt(); if (!cell) return false;
|
|
884
986
|
const r = range(); if (!r || !r.collapsed) return false;
|
|
@@ -899,9 +1001,7 @@
|
|
|
899
1001
|
const x = here.left;
|
|
900
1002
|
const probeY = down ? here.bottom + line * 0.5 : here.top - line * 0.5;
|
|
901
1003
|
|
|
902
|
-
//
|
|
903
|
-
// point just past the caret is still inside the cell's own padding. Only a
|
|
904
|
-
// position on a DIFFERENT line means there is somewhere left to go.
|
|
1004
|
+
// Another line in this cell means the native move is right
|
|
905
1005
|
const ahead = caretAtPoint(x, probeY);
|
|
906
1006
|
if (ahead && cellOf(ahead.node) === cell) {
|
|
907
1007
|
const probe = document.createRange();
|
|
@@ -919,7 +1019,6 @@
|
|
|
919
1019
|
const next = (grid[targetRow] || [])[col];
|
|
920
1020
|
if (!next) return escapeTable(table, down);
|
|
921
1021
|
|
|
922
|
-
// Land under the column the caret was in, not at an end of the target.
|
|
923
1022
|
const box = next.getBoundingClientRect();
|
|
924
1023
|
const aimY = down ? box.top + line * 0.5 : box.bottom - line * 0.5;
|
|
925
1024
|
if (placeAtPoint(Math.min(Math.max(x, box.left + 2), box.right - 2), aimY)) return true;
|
|
@@ -933,7 +1032,6 @@
|
|
|
933
1032
|
return null;
|
|
934
1033
|
};
|
|
935
1034
|
|
|
936
|
-
// A table at the very end of a document has nothing after it to click into.
|
|
937
1035
|
function escapeTable(table, down) {
|
|
938
1036
|
const sibling = down ? table.nextElementSibling : table.previousElementSibling;
|
|
939
1037
|
if (sibling) { caretIn(sibling, down ? 0 : sibling.textContent.length); return true; }
|
|
@@ -944,9 +1042,7 @@
|
|
|
944
1042
|
return true;
|
|
945
1043
|
}
|
|
946
1044
|
|
|
947
|
-
|
|
948
|
-
// puts the <ul> *inside* the paragraph rather than replacing it, which is invalid
|
|
949
|
-
// and silently loses the list on the next serialize.
|
|
1045
|
+
/* ---- Lists ---- */
|
|
950
1046
|
function retagList(list, tag) {
|
|
951
1047
|
const n = document.createElement(tag);
|
|
952
1048
|
while (list.firstChild) n.appendChild(list.firstChild);
|
|
@@ -954,35 +1050,37 @@
|
|
|
954
1050
|
return n;
|
|
955
1051
|
}
|
|
956
1052
|
|
|
957
|
-
|
|
958
|
-
|
|
1053
|
+
// An item (li, dt, dd) out of its list into a <p>; following items keep their own list
|
|
1054
|
+
function liftItem(item) {
|
|
1055
|
+
const list = item.parentElement, off = offsetIn(item);
|
|
959
1056
|
const p = document.createElement('p');
|
|
960
|
-
[...
|
|
961
|
-
const after = [...list.children].slice([...list.children].indexOf(
|
|
962
|
-
list.
|
|
963
|
-
if (after.length) {
|
|
1057
|
+
[...item.childNodes].forEach(n => { if (n.tagName !== 'INPUT') p.appendChild(n); });
|
|
1058
|
+
const after = [...list.children].slice([...list.children].indexOf(item) + 1);
|
|
1059
|
+
list.after(p);
|
|
1060
|
+
if (after.length) {
|
|
964
1061
|
const tail = document.createElement(list.tagName.toLowerCase());
|
|
965
1062
|
if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
|
|
966
1063
|
after.forEach(n => tail.appendChild(n));
|
|
967
|
-
p.
|
|
1064
|
+
p.after(tail);
|
|
968
1065
|
}
|
|
969
|
-
|
|
1066
|
+
item.remove();
|
|
970
1067
|
if (!list.children.length) list.remove();
|
|
971
1068
|
caretIn(p, off);
|
|
1069
|
+
return p;
|
|
972
1070
|
}
|
|
973
1071
|
|
|
974
1072
|
function toggleList(tag) {
|
|
975
1073
|
const li = ancestor('li');
|
|
976
1074
|
if (li) {
|
|
977
1075
|
const list = li.closest('ul, ol');
|
|
978
|
-
if (list.tagName.toLowerCase() === tag) return
|
|
1076
|
+
if (list.tagName.toLowerCase() === tag) return liftItem(li);
|
|
979
1077
|
const next = retagList(list, tag);
|
|
980
1078
|
next.removeAttribute('data-checklist');
|
|
981
1079
|
next.querySelectorAll('input[type=checkbox]').forEach(b => b.remove());
|
|
982
1080
|
caretIn(next.querySelector('li') || next, 0);
|
|
983
1081
|
return;
|
|
984
1082
|
}
|
|
985
|
-
const b =
|
|
1083
|
+
const b = freeBlock(); if (!b) return;
|
|
986
1084
|
const off = offsetIn(b);
|
|
987
1085
|
const list = document.createElement(tag), item = document.createElement('li');
|
|
988
1086
|
while (b.firstChild) item.appendChild(b.firstChild);
|
|
@@ -991,6 +1089,21 @@
|
|
|
991
1089
|
caretIn(item, off);
|
|
992
1090
|
}
|
|
993
1091
|
|
|
1092
|
+
// .li: into the list above when there is one, else a new bulleted list
|
|
1093
|
+
function toggleItem() {
|
|
1094
|
+
const li = ancestor('li');
|
|
1095
|
+
if (li) return liftItem(li);
|
|
1096
|
+
const b = freeBlock(); if (!b) return;
|
|
1097
|
+
const prev = b.previousElementSibling;
|
|
1098
|
+
if (!prev || !/^(UL|OL)$/.test(prev.tagName)) return toggleList('ul');
|
|
1099
|
+
const off = offsetIn(b), item = document.createElement('li');
|
|
1100
|
+
if (prev.hasAttribute('data-checklist')) item.appendChild(Object.assign(document.createElement('input'), { type: 'checkbox' }));
|
|
1101
|
+
while (b.firstChild) item.appendChild(b.firstChild);
|
|
1102
|
+
prev.appendChild(item);
|
|
1103
|
+
b.remove();
|
|
1104
|
+
caretIn(item, off);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
994
1107
|
function toggleChecklist() {
|
|
995
1108
|
if (!ancestor('li')) toggleList('ul');
|
|
996
1109
|
const item = ancestor('li'); if (!item) return;
|
|
@@ -1002,7 +1115,7 @@
|
|
|
1002
1115
|
if (on && !box) { const b = document.createElement('input'); b.type = 'checkbox'; row.insertBefore(b, row.firstChild); }
|
|
1003
1116
|
if (!on && box) box.remove();
|
|
1004
1117
|
});
|
|
1005
|
-
//
|
|
1118
|
+
// Caret after the checkbox, not before it
|
|
1006
1119
|
const box = item.querySelector(':scope > input[type=checkbox]');
|
|
1007
1120
|
if (!box) return caretIn(item, 0);
|
|
1008
1121
|
if (!item.textContent.length && !item.querySelector('br')) item.appendChild(document.createElement('br'));
|
|
@@ -1011,9 +1124,7 @@
|
|
|
1011
1124
|
sl.removeAllRanges(); sl.addRange(r);
|
|
1012
1125
|
}
|
|
1013
1126
|
|
|
1014
|
-
//
|
|
1015
|
-
// invalid — the sublist belongs inside the preceding item. Left alone it costs the
|
|
1016
|
-
// whole nested branch on the next serialize. Cheap enough to run after any command.
|
|
1127
|
+
// Sublists belong inside the preceding item; task items keep one leading box
|
|
1017
1128
|
function normalizeLists(root) {
|
|
1018
1129
|
root.querySelectorAll(':is(ul, ol) > :is(ul, ol)').forEach(nested => {
|
|
1019
1130
|
const prev = nested.previousElementSibling;
|
|
@@ -1022,8 +1133,6 @@
|
|
|
1022
1133
|
nested.parentNode.insertBefore(li, nested);
|
|
1023
1134
|
li.appendChild(nested);
|
|
1024
1135
|
});
|
|
1025
|
-
// Enter inside a task item clones the row's contents in whatever order the
|
|
1026
|
-
// engine feels like, so the box can land after the text or twice over.
|
|
1027
1136
|
root.querySelectorAll('[data-checklist] > li').forEach(li => {
|
|
1028
1137
|
const boxes = [...li.querySelectorAll(':scope > input[type=checkbox]')];
|
|
1029
1138
|
boxes.slice(1).forEach(b => b.remove());
|
|
@@ -1033,123 +1142,241 @@
|
|
|
1033
1142
|
});
|
|
1034
1143
|
}
|
|
1035
1144
|
|
|
1036
|
-
//
|
|
1037
|
-
// builds an empty parent item on the second level and sprinkles colour spans on
|
|
1038
|
-
// outdent. This also caps nesting at one level deeper than the item above, which
|
|
1039
|
-
// is what every list editor does.
|
|
1145
|
+
// Nest at most one level below the item above
|
|
1040
1146
|
function moveItem(by) {
|
|
1041
1147
|
const li = ancestor('li'); if (!li) return;
|
|
1042
|
-
const list = li.parentElement;
|
|
1148
|
+
const list = li.parentElement, off = offsetIn(li);
|
|
1043
1149
|
if (by > 0) {
|
|
1044
1150
|
const prev = li.previousElementSibling;
|
|
1045
|
-
if (!prev || prev.tagName !== 'LI') return;
|
|
1151
|
+
if (!prev || prev.tagName !== 'LI') return;
|
|
1046
1152
|
let sub = prev.querySelector(':scope > ul, :scope > ol');
|
|
1047
1153
|
if (!sub) { sub = document.createElement(list.tagName.toLowerCase()); prev.appendChild(sub); }
|
|
1048
1154
|
sub.appendChild(li);
|
|
1049
1155
|
} else {
|
|
1050
1156
|
const parentItem = list.parentElement;
|
|
1051
|
-
if (!parentItem || parentItem.tagName !== 'LI') return;
|
|
1157
|
+
if (!parentItem || parentItem.tagName !== 'LI') return;
|
|
1052
1158
|
parentItem.parentElement.insertBefore(li, parentItem.nextSibling);
|
|
1053
1159
|
if (!list.children.length) list.remove();
|
|
1054
1160
|
}
|
|
1161
|
+
if (off != null) caretIn(li, off); // the caret stays in the item's text, so Tab and Shift+Tab chain
|
|
1055
1162
|
}
|
|
1056
1163
|
|
|
1057
|
-
// Enabled only where the command would actually move something — a control that
|
|
1058
|
-
// is lit but inert is the same bug as one that is missing.
|
|
1059
1164
|
function canIndent(area) {
|
|
1060
1165
|
const li = ancestor('li');
|
|
1061
|
-
if (li) return !!li.previousElementSibling;
|
|
1062
|
-
return area.
|
|
1166
|
+
if (li) return !!li.previousElementSibling;
|
|
1167
|
+
return area._rt.format === 'html';
|
|
1063
1168
|
}
|
|
1064
1169
|
|
|
1065
1170
|
function canOutdent(area) {
|
|
1066
1171
|
const li = ancestor('li');
|
|
1067
1172
|
if (li) return li.closest('ul, ol').parentElement.tagName === 'LI';
|
|
1068
1173
|
const b = blockEl();
|
|
1069
|
-
return area.
|
|
1174
|
+
return area._rt.format === 'html' && !!b && parseFloat(b.style.marginInlineStart) > 0;
|
|
1070
1175
|
}
|
|
1071
1176
|
|
|
1072
1177
|
const STEP = 2; // rem per indent level
|
|
1073
|
-
//
|
|
1074
|
-
// markdown cannot carry — so the second half only runs where it would survive.
|
|
1178
|
+
// List items nest; other blocks indent by margin (.html only)
|
|
1075
1179
|
function indent(by, area) {
|
|
1076
1180
|
if (inList()) return moveItem(by);
|
|
1077
|
-
if (area && area.
|
|
1181
|
+
if (area && area._rt.format !== 'html') return;
|
|
1078
1182
|
const b = blockEl(); if (!b) return;
|
|
1079
1183
|
const now = parseFloat(b.style.marginInlineStart) || 0;
|
|
1080
1184
|
const next = Math.max(0, now + by * STEP);
|
|
1081
1185
|
if (next) b.style.marginInlineStart = next + 'rem'; else b.style.removeProperty('margin-inline-start');
|
|
1082
1186
|
}
|
|
1083
1187
|
|
|
1084
|
-
/* ---- Links ----
|
|
1085
|
-
|
|
1086
|
-
the current href, sets it on change, and clears the link when emptied. The
|
|
1087
|
-
`unlink` command is the explicit button form of clearing it. */
|
|
1088
|
-
// What counts as a link: an explicit scheme (https, mailto, tel, …), a relative
|
|
1089
|
-
// path, an anchor — or a bare domain, which gets https:// so "example.com"
|
|
1090
|
-
// does what the writer meant. Anything else (spaces, no dot, no scheme) is
|
|
1091
|
-
// not applied; the field re-syncs to the real href, showing it didn't take.
|
|
1188
|
+
/* ---- Links ---- */
|
|
1189
|
+
// Scheme, relative path, anchor, or bare domain (gets https://); anything else is refused
|
|
1092
1190
|
function normalizeUrl(url) {
|
|
1093
1191
|
if (/\s/.test(url) || unsafeUrl(url)) return null;
|
|
1094
1192
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
|
|
1095
1193
|
if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
|
|
1096
1194
|
return null;
|
|
1097
1195
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1196
|
+
// An area that is, or sits in, a link takes no links: they cannot nest
|
|
1197
|
+
// Links cannot go inside a link or a button
|
|
1198
|
+
const linkHost = (area) => !!(area && area.closest('a, button'));
|
|
1199
|
+
const unwrapEl = (el) => el.replaceWith(...el.childNodes);
|
|
1200
|
+
const textIn = (el) => { const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), out = []; for (let n = w.nextNode(); n; n = w.nextNode()) out.push(n); return out; };
|
|
1201
|
+
const sameEl = (a, b) => !!a && !!b && a.nodeType === 1 && b.nodeType === 1 && a.tagName === b.tagName
|
|
1202
|
+
&& a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
|
|
1203
|
+
// Links go outermost: a link inside formatting swaps with it (<b><a>x</a></b> → <a><b>x</b></a>);
|
|
1204
|
+
// then identical neighbours merge and links left without content go. The selection is kept.
|
|
1205
|
+
function tidyLinks(area) {
|
|
1206
|
+
const r = range(), keep = r && area.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
|
|
1207
|
+
for (let guard = 0, moved = true; moved && guard < 50; guard++) {
|
|
1208
|
+
moved = false;
|
|
1209
|
+
[...area.querySelectorAll('a')].forEach(a => {
|
|
1210
|
+
const w = a.parentElement;
|
|
1211
|
+
if (!a.isConnected || !w || w === area || !area.contains(w) || w.closest('a') || !INLINE_OK.has(w.tagName)) return;
|
|
1212
|
+
w.replaceWith(...[...w.childNodes].map(n => {
|
|
1213
|
+
const wc = w.cloneNode(false);
|
|
1214
|
+
if (n.nodeType === 1 && n.tagName === 'A') { wc.append(...n.childNodes); n.appendChild(wc); return n; }
|
|
1215
|
+
wc.appendChild(n); return wc;
|
|
1216
|
+
}));
|
|
1217
|
+
moved = true;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
const merge = (p) => { [...p.children].forEach(merge); for (let c = p.firstChild; c; c = c.nextSibling) while (c.nodeType === 1 && !/^(BR|WBR|IMG)$/.test(c.tagName) && sameEl(c, c.nextSibling)) { const n = c.nextSibling; c.append(...n.childNodes); n.remove(); } };
|
|
1221
|
+
for (let i = 0; i < 3; i++) merge(area);
|
|
1222
|
+
area.querySelectorAll('a').forEach(a => { if (!a.textContent.trim() && !a.querySelector('img')) unwrapEl(a); });
|
|
1223
|
+
if (keep && keep[0].isConnected && keep[2].isConnected) try { const nr = document.createRange(); nr.setStart(keep[0], keep[1]); nr.setEnd(keep[2], keep[3]); const s = sel(); s.removeAllRanges(); s.addRange(nr); } catch { }
|
|
1224
|
+
}
|
|
1225
|
+
function setLink(href, area) {
|
|
1226
|
+
if (linkHost(area)) return;
|
|
1227
|
+
const existing = ancestor('a'), r = range();
|
|
1100
1228
|
const raw = href == null ? '' : String(href).trim();
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1229
|
+
const url = raw ? normalizeUrl(raw) : '';
|
|
1230
|
+
if (raw && !url) return;
|
|
1231
|
+
// A caret in a link retargets or unlinks all of it
|
|
1232
|
+
if (r && r.collapsed) {
|
|
1233
|
+
if (existing) { if (url) existing.setAttribute('href', url); else unwrapEl(existing); return; }
|
|
1234
|
+
if (url) insertHTML(`<a href="${escHtml(url)}">${escHtml(url)}</a>`);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (!r) return;
|
|
1238
|
+
// A selection: each link it touches keeps its unselected text; the selection loses its link, or gets the new one
|
|
1239
|
+
const nodes = selectedTextNodes(); if (!nodes.length) return;
|
|
1240
|
+
const touched = [...new Set(nodes.map(n => elementAt(n) && elementAt(n).closest('a')).filter(a => a && withinArea(a, a)))];
|
|
1241
|
+
touched.forEach(a => {
|
|
1242
|
+
const old = a.getAttribute('href'), keep = textIn(a).filter(t => !nodes.includes(t) && t.nodeValue.length);
|
|
1243
|
+
unwrapEl(a);
|
|
1244
|
+
keep.forEach(t => { const x = document.createElement('a'); if (old != null) x.setAttribute('href', old); t.before(x); x.appendChild(t); });
|
|
1245
|
+
});
|
|
1246
|
+
const s = sel(), nr = document.createRange(), last = nodes[nodes.length - 1];
|
|
1247
|
+
nr.setStart(nodes[0], 0); nr.setEnd(last, last.length);
|
|
1248
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1249
|
+
if (url) wrapInline('a').forEach(a => a.setAttribute('href', url));
|
|
1250
|
+
const root = area || elementAt(nodes[0]).closest('[data-rich-text]');
|
|
1251
|
+
if (root) tidyLinks(root);
|
|
1108
1252
|
}
|
|
1109
1253
|
const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
|
|
1110
1254
|
|
|
1111
1255
|
/* ---- Insertions ---- */
|
|
1112
1256
|
function insertHTML(html) { document.execCommand('insertHTML', false, html); }
|
|
1113
1257
|
|
|
1114
|
-
// .plain: the engine's own plain-text editing where it has one; key and input guards otherwise.
|
|
1115
1258
|
const PLAINTEXT_ONLY = (() => {
|
|
1116
1259
|
try { const d = document.createElement('div'); d.contentEditable = 'plaintext-only'; return d.contentEditable === 'plaintext-only'; } catch { return false; }
|
|
1117
1260
|
})();
|
|
1118
1261
|
|
|
1119
|
-
// Lines
|
|
1120
|
-
// engines disagree on what it does in a plaintext-only host at all.
|
|
1262
|
+
// Lines as <br>, inserted by hand
|
|
1121
1263
|
function insertPlain(area, text) {
|
|
1122
|
-
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1123
1264
|
const frag = document.createDocumentFragment();
|
|
1124
1265
|
String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
|
|
1125
1266
|
if (i) frag.appendChild(document.createElement('br'));
|
|
1126
1267
|
if (line) frag.appendChild(document.createTextNode(line));
|
|
1127
1268
|
});
|
|
1128
|
-
|
|
1269
|
+
insertNodes(area, frag);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Inserted by hand, so the browser adds no style wrappers of its own
|
|
1273
|
+
function insertNodes(area, frag) {
|
|
1274
|
+
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1275
|
+
// Pasted or dropped links never land inside a link
|
|
1276
|
+
const at = elementAt(r.startContainer);
|
|
1277
|
+
if (at && at.closest('a')) [...frag.querySelectorAll('a')].forEach(unwrapEl);
|
|
1278
|
+
if (!frag.lastChild) return;
|
|
1129
1279
|
record(area);
|
|
1130
1280
|
r.deleteContents();
|
|
1131
|
-
|
|
1281
|
+
// An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
|
|
1282
|
+
const holder = blockEl() || area;
|
|
1283
|
+
if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
|
|
1284
|
+
if (offsetIn(holder) === 0) {
|
|
1285
|
+
const w = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT), first = w.nextNode();
|
|
1286
|
+
if (first) first.nodeValue = first.nodeValue.replace(/^\s+/, '');
|
|
1287
|
+
}
|
|
1288
|
+
let end = frag.lastChild;
|
|
1289
|
+
if (!end) return;
|
|
1290
|
+
const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
|
|
1291
|
+
// The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
|
|
1132
1292
|
const s = sel(), nr = document.createRange();
|
|
1133
|
-
|
|
1293
|
+
if (end.nodeType === 1) { const w = document.createTreeWalker(end, NodeFilter.SHOW_TEXT); let t = null; for (let n = w.nextNode(); n; n = w.nextNode()) t = n; if (t) end = t; }
|
|
1294
|
+
if (end.nodeType === 3) nr.setStart(end, end.length); else nr.setStartAfter(end);
|
|
1295
|
+
nr.collapse(true);
|
|
1134
1296
|
s.removeAllRanges(); s.addRange(nr);
|
|
1135
1297
|
record(area);
|
|
1136
1298
|
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1137
1299
|
}
|
|
1300
|
+
|
|
1301
|
+
// A selection across blocks is deleted as the browser would: the partly selected end
|
|
1302
|
+
// block merges into the start block (never across a table cell), emptied wrappers go.
|
|
1303
|
+
// A selection of everything leaves one empty paragraph.
|
|
1304
|
+
function clearSpan(area) {
|
|
1305
|
+
const r = range(); if (!r || r.collapsed || !area.contains(r.startContainer)) return;
|
|
1306
|
+
const texts = textIn(area).filter(t => r.intersectsNode(t) && t.nodeValue.length);
|
|
1307
|
+
const whole = document.createRange(); whole.selectNodeContents(area);
|
|
1308
|
+
const all = (r.compareBoundaryPoints(Range.START_TO_START, whole) <= 0 && r.compareBoundaryPoints(Range.END_TO_END, whole) >= 0)
|
|
1309
|
+
|| (texts.length && r.toString() === area.textContent && !area.querySelector('img, hr, table'));
|
|
1310
|
+
if (all) {
|
|
1311
|
+
area.innerHTML = '<p></p>'; caretIn(area.firstElementChild, 0); return;
|
|
1312
|
+
}
|
|
1313
|
+
const blockOf = (n) => { for (let e = elementAt(n); e && e !== area; e = e.parentElement) if (BLOCK.includes(e.tagName)) return e; return null; };
|
|
1314
|
+
// Boundaries on elements move into text, so the blocks they name are the ones holding text
|
|
1315
|
+
if (texts.length) {
|
|
1316
|
+
if (r.startContainer.nodeType !== 3) r.setStart(texts[0], 0);
|
|
1317
|
+
if (r.endContainer.nodeType !== 3) { const l = texts[texts.length - 1]; r.setEnd(l, l.length); }
|
|
1318
|
+
}
|
|
1319
|
+
const a = blockOf(r.startContainer), b = blockOf(r.endContainer), at = [r.startContainer, r.startOffset];
|
|
1320
|
+
r.deleteContents();
|
|
1321
|
+
if (a && b && a !== b && b.isConnected && !/^(TD|TH)$/.test(a.tagName) && !/^(TD|TH)$/.test(b.tagName) && !b.contains(a) && !a.contains(b)) {
|
|
1322
|
+
a.append(...b.childNodes);
|
|
1323
|
+
let gone = b;
|
|
1324
|
+
while (gone.parentElement && gone.parentElement !== area && !gone.parentElement.contains(a) && gone.parentElement.children.length === 1) gone = gone.parentElement;
|
|
1325
|
+
gone.remove();
|
|
1326
|
+
}
|
|
1327
|
+
// The caret stays where the selection started, inside its block (deleteContents leaves it between blocks)
|
|
1328
|
+
const c = document.createRange();
|
|
1329
|
+
if (at[0].isConnected && area.contains(at[0])) c.setStart(at[0], Math.min(at[1], at[0].nodeType === 3 ? at[0].length : at[0].childNodes.length));
|
|
1330
|
+
else if (a && a.isConnected) c.selectNodeContents(a);
|
|
1331
|
+
else c.setStart(area, 0);
|
|
1332
|
+
c.collapse(c.startContainer === a ? false : true);
|
|
1333
|
+
const s = sel(); s.removeAllRanges(); s.addRange(c);
|
|
1334
|
+
}
|
|
1335
|
+
// A <br> at the caret; with nothing after it, a second one so the new line shows
|
|
1336
|
+
function insertBreak(area) {
|
|
1337
|
+
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1338
|
+
r.deleteContents();
|
|
1339
|
+
const br = document.createElement('br');
|
|
1340
|
+
r.insertNode(br);
|
|
1341
|
+
const tail = document.createRange();
|
|
1342
|
+
tail.setStartAfter(br); tail.setEnd(area, area.childNodes.length);
|
|
1343
|
+
if (!tail.toString() && !tail.cloneContents().querySelector('br, img')) br.after(document.createElement('br'));
|
|
1344
|
+
const nr = document.createRange(); nr.setStartAfter(br); nr.collapse(true);
|
|
1345
|
+
const s = sel(); s.removeAllRanges(); s.addRange(nr);
|
|
1346
|
+
}
|
|
1138
1347
|
const insertImage = (src, alt) => { if (src && !unsafeUrl(src, true)) insertHTML(`<img src="${escHtml(src)}" alt="${escHtml(alt || '')}">`); };
|
|
1139
1348
|
|
|
1349
|
+
// Columns × rows: [3, 2], '3x2' or 3
|
|
1140
1350
|
function insertTable(spec) {
|
|
1141
|
-
//
|
|
1142
|
-
// inside a cell. Land it after the outermost table instead.
|
|
1351
|
+
// Never nest: land after the outermost table
|
|
1143
1352
|
let host = ancestor('table');
|
|
1144
1353
|
while (host && host.parentElement && withinArea(host, host.parentElement.closest('table'))) host = host.parentElement.closest('table');
|
|
1145
1354
|
if (host) { const p = document.createElement('p'); p.appendChild(document.createElement('br')); host.after(p); caretIn(p, 0); }
|
|
1146
|
-
|
|
1355
|
+
if (Array.isArray(spec) && spec.length === 1) spec = spec[0]; // table('3x2'), table([3, 2])
|
|
1356
|
+
const parts = Array.isArray(spec) ? spec : String(spec == null ? '' : spec).split(/[x×,\s]+/);
|
|
1357
|
+
const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
|
|
1358
|
+
const cols = n(parts[0]), rows = n(parts[1]);
|
|
1147
1359
|
const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
|
|
1148
1360
|
insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
|
|
1149
1361
|
}
|
|
1150
1362
|
|
|
1151
|
-
// A table,
|
|
1152
|
-
//
|
|
1363
|
+
// A trailing table, rule or similar block gets a line after it to land on
|
|
1364
|
+
// Text and inline marks sitting directly in a block field are wrapped into one <p> per run, caret
|
|
1365
|
+
// kept. Authored empty blocks (spacers, an empty figure) are left alone.
|
|
1366
|
+
function looseInline(area) {
|
|
1367
|
+
const loose = [...area.childNodes].some(n => (n.nodeType === 3 && n.nodeValue.trim()) || (n.nodeType === 1 && INLINE_OK.has(n.tagName) && n.tagName !== 'BR'));
|
|
1368
|
+
if (!loose) return;
|
|
1369
|
+
const off = offsetIn(area);
|
|
1370
|
+
let run = null;
|
|
1371
|
+
[...area.childNodes].forEach(n => {
|
|
1372
|
+
const inl = n.nodeType === 3 || (n.nodeType === 1 && INLINE_OK.has(n.tagName));
|
|
1373
|
+
if (!inl) { run = null; return; }
|
|
1374
|
+
if (!run) { if (n.nodeType === 3 && !n.nodeValue.trim()) return; run = document.createElement('p'); n.before(run); }
|
|
1375
|
+
run.appendChild(n);
|
|
1376
|
+
});
|
|
1377
|
+
if (off != null && document.activeElement === area) caretIn(area, off);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1153
1380
|
function ensureTrailingBlock(area) {
|
|
1154
1381
|
const last = area.lastElementChild;
|
|
1155
1382
|
if (!last || !/^(TABLE|HR|FIGURE|PRE|UL|OL|BLOCKQUOTE|DL)$/.test(last.tagName)) return false;
|
|
@@ -1159,81 +1386,64 @@
|
|
|
1159
1386
|
return true;
|
|
1160
1387
|
}
|
|
1161
1388
|
|
|
1389
|
+
// A value styles the selection; an empty one removes that style from it (spans left bare go)
|
|
1390
|
+
function styleSpan(prop, v) {
|
|
1391
|
+
if (v != null && v !== '') return wrapInline('span', { [prop]: v });
|
|
1392
|
+
const nodes = selectedTextNodes(), spans = new Set();
|
|
1393
|
+
nodes.forEach(n => { for (let el = elementAt(n); el && el.closest && !el.hasAttribute('data-rich-text'); el = el.parentElement) if (el.tagName === 'SPAN' && el.style[prop]) spans.add(el); });
|
|
1394
|
+
spans.forEach(s => { s.style[prop] = ''; if (!s.getAttribute('style')) s.removeAttribute('style'); if (!s.attributes.length) unwrapEl(s); });
|
|
1395
|
+
return [];
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1162
1398
|
function clearFormatting() {
|
|
1163
|
-
|
|
1164
|
-
let el = elementAt(n);
|
|
1165
|
-
while (el && !BLOCK.includes(el.tagName) && el.getAttribute && el.hasAttribute('contenteditable') === false) el = el.parentElement;
|
|
1166
|
-
});
|
|
1167
|
-
Object.keys(INLINE_TAGS).forEach(unwrapInline);
|
|
1399
|
+
Object.keys(INLINE_TAGS).forEach(t => unwrapInline(t));
|
|
1168
1400
|
unwrapInline('a');
|
|
1169
1401
|
}
|
|
1170
1402
|
|
|
1171
|
-
/* ----
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
stored value is the area's markup, and burying a wrapper in it to carry a font
|
|
1177
|
-
would be a lie about what the document says. They are written as custom
|
|
1178
|
-
properties on the area instead, which the stylesheet consumes and $text.page
|
|
1179
|
-
hands back as a plain object for the author to persist beside the document. */
|
|
1180
|
-
const PAGE_VARS = {
|
|
1181
|
-
font: '--text-edit-font', size: '--text-edit-size', leading: '--text-edit-leading',
|
|
1182
|
-
align: '--text-edit-align', color: '--text-edit-color', background: '--text-edit-background'
|
|
1403
|
+
/* ---- Document-wide styles (.all) ----
|
|
1404
|
+
Custom properties on the area, read back by $rich.all; never in the stored markup. */
|
|
1405
|
+
const ALL_VARS = {
|
|
1406
|
+
font: '--rich-text-font', size: '--rich-text-size', leading: '--rich-text-leading',
|
|
1407
|
+
align: '--rich-text-align', color: '--rich-text-color', background: '--rich-text-background'
|
|
1183
1408
|
};
|
|
1184
|
-
const
|
|
1409
|
+
const allStyle = (area) => Object.fromEntries(Object.entries(ALL_VARS)
|
|
1185
1410
|
.map(([k, v]) => [k, area.style.getPropertyValue(v).trim()]).filter(([, v]) => v));
|
|
1186
|
-
const
|
|
1411
|
+
const setAllStyle = (area, values) => Object.entries(ALL_VARS).forEach(([k, v]) => {
|
|
1187
1412
|
const next = values && values[k];
|
|
1188
1413
|
if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
|
|
1189
1414
|
});
|
|
1190
1415
|
|
|
1191
|
-
|
|
1416
|
+
/* ---- Command table ---- */
|
|
1417
|
+
const COMMANDS = Object.create(null);
|
|
1192
1418
|
Object.entries(INLINE_TAGS).forEach(([tag, meta]) => {
|
|
1193
1419
|
COMMANDS[tag] = {
|
|
1194
1420
|
md: meta.md,
|
|
1195
|
-
run: (v, area) => hasSelection() ? toggleInline(tag) : armPending(area, tag),
|
|
1196
|
-
active: (v, area) => selectionMarked(tag)
|
|
1421
|
+
run: (v, area) => hasSelection() ? toggleInline(tag, null, attrsFor(tag, v)) : armPending(area, tag, attrsFor(tag, v)),
|
|
1422
|
+
active: (v, area) => selectionMarked(tag) !== pendingHas(area, tag) // pending inside the tag ends it
|
|
1197
1423
|
};
|
|
1198
1424
|
});
|
|
1199
1425
|
Object.entries(BLOCK_TAGS).forEach(([tag, meta]) => {
|
|
1200
1426
|
COMMANDS[tag] = { md: meta.md, block: 1, run: () => setBlock(tag), active: () => blockTag() === tag.toUpperCase() };
|
|
1201
1427
|
});
|
|
1202
|
-
|
|
1203
|
-
// A <figure> is a block WITH a caption — retagging a paragraph to one looks like
|
|
1204
|
-
// nothing happened, which is exactly what it did before.
|
|
1205
|
-
COMMANDS.figure = {
|
|
1206
|
-
block: 1,
|
|
1207
|
-
active: () => !!ancestor('figure'),
|
|
1208
|
-
run: () => {
|
|
1209
|
-
const existing = ancestor('figure');
|
|
1210
|
-
if (existing) { // unwrap, dropping the caption
|
|
1211
|
-
const caption = existing.querySelector('figcaption');
|
|
1212
|
-
if (caption) caption.remove();
|
|
1213
|
-
existing.replaceWith(...existing.childNodes);
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
const b = blockEl(); if (!b) return;
|
|
1217
|
-
const fig = document.createElement('figure');
|
|
1218
|
-
b.replaceWith(fig);
|
|
1219
|
-
fig.appendChild(b);
|
|
1220
|
-
const caption = document.createElement('figcaption');
|
|
1221
|
-
caption.appendChild(document.createElement('br'));
|
|
1222
|
-
fig.appendChild(caption);
|
|
1223
|
-
caretIn(caption, 0);
|
|
1224
|
-
}
|
|
1225
|
-
};
|
|
1226
1428
|
Object.assign(COMMANDS, {
|
|
1227
|
-
a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: () => !!ancestor('a') || hasSelection() },
|
|
1228
|
-
unlink: { md: 1, run: () =>
|
|
1429
|
+
a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: (area) => !linkHost(area) && (!!ancestor('a') || hasSelection()) },
|
|
1430
|
+
unlink: { md: 1, run: (v, area) => setLink('', area), active: () => false, enabled: (area) => !linkHost(area) && (!!ancestor('a') || (hasSelection() && [...area.querySelectorAll('a')].some(a => range().intersectsNode(a)))) },
|
|
1431
|
+
br: { md: 1, run: (v, area) => area && area._rt && area._rt.breaks ? insertBreak(area) : insertHTML('<br>'), active: () => false },
|
|
1432
|
+
wbr: { run: () => insertVoid('wbr'), active: () => false },
|
|
1433
|
+
img: { md: 1, args: 2, run: (v) => Array.isArray(v) ? insertImage(v[0], v[1]) : insertImage(v), active: () => false },
|
|
1434
|
+
|
|
1229
1435
|
ul: { md: 1, block: 1, run: () => toggleList('ul'), active: () => !!ancestor('ul') && !ancestor('ul').hasAttribute('data-checklist') },
|
|
1230
1436
|
ol: { md: 1, block: 1, run: () => toggleList('ol'), active: () => !!ancestor('ol') },
|
|
1437
|
+
li: { md: 1, block: 1, run: toggleItem, active: () => !!ancestor('li') },
|
|
1231
1438
|
checklist: { md: 1, block: 1, run: toggleChecklist, active: () => !!ancestor('ul[data-checklist]') },
|
|
1232
1439
|
hr: { md: 1, block: 1, run: () => insertHTML('<hr><p><br></p>'), active: () => false },
|
|
1233
|
-
|
|
1234
|
-
|
|
1440
|
+
figure: { block: 1, run: toggleFigure, active: () => !!ancestor('figure') },
|
|
1441
|
+
figcaption: { block: 1, run: setCaption, active: () => !!ancestor('figcaption'), enabled: () => !!ancestor('figure') },
|
|
1442
|
+
dl: { block: 1, run: toggleDl, active: () => !!ancestor('dl') },
|
|
1443
|
+
dt: { block: 1, run: setTerm('dt'), active: () => termIs('DT'), enabled: () => !!ancestor('dt, dd') },
|
|
1444
|
+
dd: { block: 1, run: setTerm('dd'), active: () => termIs('DD'), enabled: () => !!ancestor('dt, dd') },
|
|
1235
1445
|
|
|
1236
|
-
table: { block: 1, run: (v) => insertTable(v), active: () => false },
|
|
1446
|
+
table: { block: 1, args: 2, run: (v) => insertTable(v), active: () => false },
|
|
1237
1447
|
'row-before': { block: 1, run: () => addRow(-1), active: () => false, enabled: () => inTable() },
|
|
1238
1448
|
'row-after': { block: 1, run: () => addRow(1), active: () => false, enabled: () => inTable() },
|
|
1239
1449
|
'row-remove': { block: 1, run: removeRow, active: () => false, enabled: () => inTable() },
|
|
@@ -1246,35 +1456,40 @@
|
|
|
1246
1456
|
split: { block: 1, run: splitCell, active: () => false, enabled: () => { const c = cellAt(); return !!c && ((c.colSpan || 1) > 1 || (c.rowSpan || 1) > 1); } },
|
|
1247
1457
|
indent: { md: 1, block: 1, run: (v, area) => indent(1, area), active: () => false, enabled: (a) => canIndent(a) },
|
|
1248
1458
|
outdent: { md: 1, block: 1, run: (v, area) => indent(-1, area), active: () => false, enabled: (a) => canOutdent(a) },
|
|
1249
|
-
align: { block: 1,
|
|
1250
|
-
color: {
|
|
1251
|
-
background: {
|
|
1252
|
-
font: {
|
|
1253
|
-
size: {
|
|
1254
|
-
leading: {
|
|
1459
|
+
align: { block: 1, all: 1, run: (v) => styleBlock('textAlign', v || 'start'), active: () => false, reflect: () => readBlockStyle('textAlign') },
|
|
1460
|
+
color: { all: 1, needsSelection: 1, run: (v) => styleSpan('color', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('color')) },
|
|
1461
|
+
background: { all: 1, needsSelection: 1, run: (v) => styleSpan('backgroundColor', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('backgroundColor')) },
|
|
1462
|
+
font: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontFamily', v), active: () => false, reflect: () => readInlineStyle('fontFamily').split(',')[0].replace(/['"]/g, '') },
|
|
1463
|
+
size: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontSize', v ? px(v) : ''), active: () => false, reflect: () => readInlineStyle('fontSize') },
|
|
1464
|
+
leading: { all: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
|
|
1255
1465
|
|
|
1256
1466
|
clear: { md: 1, run: clearFormatting, active: () => false },
|
|
1257
|
-
// A burst still waiting to be recorded is undoable, and leaves nothing to redo
|
|
1467
|
+
// A burst still waiting to be recorded is undoable, and leaves nothing to redo
|
|
1258
1468
|
undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 || !!a._history.timer },
|
|
1259
1469
|
redo: { md: 1, history: 1, run: (v, area) => step(area, 1), active: () => false, enabled: (a) => !a._history.timer && a._history.at < a._history.stack.length - 1 },
|
|
1260
|
-
block: { md: 1, block: 1, run: (v) =>
|
|
1470
|
+
block: { md: 1, block: 1, run: (v, area) => runBlock(area, v), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
|
|
1261
1471
|
});
|
|
1262
1472
|
|
|
1473
|
+
// .block="h2": any block tag command, under that command's own availability
|
|
1474
|
+
const BLOCK_CHOICES = new Set([...Object.keys(BLOCK_TAGS), 'ul', 'ol', 'li', 'figure', 'figcaption', 'dl', 'dt', 'dd']);
|
|
1475
|
+
function runBlock(area, v) {
|
|
1476
|
+
const tag = String(v || 'p').toLowerCase(), spec = COMMANDS[tag];
|
|
1477
|
+
if (!BLOCK_CHOICES.has(tag)) return;
|
|
1478
|
+
if (area._rt.format !== 'html' && !spec.md) return;
|
|
1479
|
+
if (spec.enabled && !spec.enabled(area)) return;
|
|
1480
|
+
spec.run(undefined, area);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1263
1483
|
const px = (v) => /^\d+(\.\d+)?$/.test(String(v)) ? v + 'px' : v;
|
|
1264
1484
|
const hasSelection = () => { const r = range(); return !!r && !r.collapsed; };
|
|
1265
1485
|
|
|
1266
1486
|
/* ---- Pending marks ----
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
caret inside — the browser normalizes the caret straight back out — so the mark
|
|
1270
|
-
is remembered and applied to the first characters that arrive. The anchor is
|
|
1271
|
-
checked on the way in: move the caret elsewhere and the arming lapses rather
|
|
1272
|
-
than firing somewhere unexpected. */
|
|
1273
|
-
function armPending(area, tag) {
|
|
1487
|
+
An inline command at a collapsed caret arms the tag for the next typed text. */
|
|
1488
|
+
function armPending(area, tag, attrs) {
|
|
1274
1489
|
const r = range(); if (!r || !r.collapsed) return;
|
|
1275
1490
|
const at = area._pending;
|
|
1276
|
-
const tags = at && at.node === r.startContainer && at.offset === r.startOffset ? at.tags : new
|
|
1277
|
-
tags.has(tag) ? tags.delete(tag) : tags.
|
|
1491
|
+
const tags = at && at.node === r.startContainer && at.offset === r.startOffset ? at.tags : new Map();
|
|
1492
|
+
tags.has(tag) ? tags.delete(tag) : tags.set(tag, attrs || null);
|
|
1278
1493
|
area._pending = tags.size ? { tags, node: r.startContainer, offset: r.startOffset } : null;
|
|
1279
1494
|
}
|
|
1280
1495
|
|
|
@@ -1290,17 +1505,38 @@
|
|
|
1290
1505
|
const node = r.startContainer, start = at.offset;
|
|
1291
1506
|
const span = document.createRange();
|
|
1292
1507
|
span.setStart(node, start); span.setEnd(node, r.startOffset);
|
|
1508
|
+
const text = span.toString(); // as the browser stored it (a trailing space may be U+00A0)
|
|
1293
1509
|
span.deleteContents();
|
|
1510
|
+
// A pending tag already around the caret ends there: the text goes after it, keeping other formatting inside it
|
|
1511
|
+
const off = [], on = [];
|
|
1512
|
+
at.tags.forEach((attrs, tag) => { const h = elementAt(node).closest(tag); (h && withinArea(node, h) ? off : on).push([tag, attrs]); });
|
|
1513
|
+
const offTags = new Set(off.map(([t]) => t.toUpperCase()));
|
|
1514
|
+
let pos = { node, offset: start }, keep = [];
|
|
1515
|
+
off.forEach(([tag]) => {
|
|
1516
|
+
const host = elementAt(pos.node).closest(tag); if (!host) return;
|
|
1517
|
+
for (let n = elementAt(pos.node); n && n !== host; n = n.parentElement) if (!offTags.has(n.tagName)) keep.unshift(n.cloneNode(false));
|
|
1518
|
+
const rest = document.createRange(); rest.setStart(pos.node, pos.offset); rest.setEnd(host, host.childNodes.length);
|
|
1519
|
+
const tail = host.cloneNode(false); tail.appendChild(rest.extractContents());
|
|
1520
|
+
host.after(tail);
|
|
1521
|
+
if (!tail.textContent) tail.remove();
|
|
1522
|
+
pos = { node: host.parentNode, offset: [...host.parentNode.childNodes].indexOf(host) + 1 };
|
|
1523
|
+
if (!host.textContent) { pos.offset--; host.remove(); }
|
|
1524
|
+
});
|
|
1294
1525
|
let outer = null, inner = null;
|
|
1295
|
-
|
|
1526
|
+
const wrap = (el) => { if (inner) inner.appendChild(el); else outer = el; inner = el; };
|
|
1527
|
+
keep.forEach(wrap);
|
|
1528
|
+
on.forEach(([tag, attrs]) => {
|
|
1296
1529
|
const el = document.createElement(tag);
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
1530
|
+
if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
1531
|
+
wrap(el);
|
|
1299
1532
|
});
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1533
|
+
const t = document.createTextNode(text);
|
|
1534
|
+
if (inner) inner.appendChild(t); else outer = t;
|
|
1535
|
+
const ins = document.createRange(); ins.setStart(pos.node, pos.offset); ins.collapse(true);
|
|
1536
|
+
ins.insertNode(outer);
|
|
1537
|
+
area._pending = null;
|
|
1538
|
+
const s = sel(), nr = document.createRange(); nr.setStart(t, text.length); nr.collapse(true);
|
|
1539
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1304
1540
|
return true;
|
|
1305
1541
|
}
|
|
1306
1542
|
|
|
@@ -1308,9 +1544,7 @@
|
|
|
1308
1544
|
const el = elementAt(range() && range().startContainer);
|
|
1309
1545
|
return el ? getComputedStyle(el)[prop] : '';
|
|
1310
1546
|
}
|
|
1311
|
-
// <input type=color> only speaks hex
|
|
1312
|
-
// engines return that verbatim — assigning it to fillStyle does not normalize it
|
|
1313
|
-
// either. Painting one pixel and reading it back converts anything paintable.
|
|
1547
|
+
// <input type=color> only speaks hex; painting a pixel converts any colour
|
|
1314
1548
|
let _hexCtx;
|
|
1315
1549
|
function rgbToHex(v) {
|
|
1316
1550
|
if (!v) return '#000000';
|
|
@@ -1324,13 +1558,14 @@
|
|
|
1324
1558
|
} catch { return '#000000'; }
|
|
1325
1559
|
}
|
|
1326
1560
|
|
|
1327
|
-
const MODES = new Set(['html', 'plain', '
|
|
1561
|
+
const MODES = new Set(['html', 'plain', 'inline', 'literal', 'autofocus']);
|
|
1328
1562
|
|
|
1329
|
-
// Nearest command for an unknown modifier
|
|
1563
|
+
// Nearest command for an unknown modifier (console warning only)
|
|
1330
1564
|
const HINTS = {
|
|
1331
1565
|
bold: 'strong', italic: 'em', underline: 'u', strike: 's', strikethrough: 's', link: 'a', quote: 'blockquote',
|
|
1332
1566
|
codeblock: 'pre', bullet: 'ul', bullets: 'ul', numbered: 'ol', heading: 'h2', paragraph: 'p', image: 'img',
|
|
1333
|
-
rule: 'hr', highlight: 'mark', task: 'checklist', tasks: 'checklist', subscript: 'sub', superscript: 'sup'
|
|
1567
|
+
rule: 'hr', highlight: 'mark', task: 'checklist', tasks: 'checklist', subscript: 'sub', superscript: 'sup',
|
|
1568
|
+
minimal: 'inline'
|
|
1334
1569
|
};
|
|
1335
1570
|
function suggest(m) {
|
|
1336
1571
|
if (!m) return ' — name a command, e.g. .strong';
|
|
@@ -1350,15 +1585,7 @@
|
|
|
1350
1585
|
}
|
|
1351
1586
|
|
|
1352
1587
|
/* ---- Autoformat ----
|
|
1353
|
-
|
|
1354
|
-
and the line you are leaving becomes what its markdown described. Converting
|
|
1355
|
-
mid-keystroke fought the writer — a "*" was reinterpreted the moment a second
|
|
1356
|
-
one appeared — and made literal markdown impossible to type. Deferring it also
|
|
1357
|
-
means what you see and what is stored only ever disagree within one line.
|
|
1358
|
-
|
|
1359
|
-
Without conversion the two genuinely drift: "## Title" left in a paragraph is
|
|
1360
|
-
stored as a heading and comes back as one, having looked like body text the
|
|
1361
|
-
whole time it was being written. */
|
|
1588
|
+
Block markdown converts on Enter; inline marks convert as their closing marker is typed. */
|
|
1362
1589
|
const LINE_RULES = [
|
|
1363
1590
|
{ re: /^(#{1,6})\s+/, make: (b, m) => retagBlock(b, 'h' + m[1].length) },
|
|
1364
1591
|
{ re: /^[-*+]\s+/, make: (b) => listify(b, 'ul') },
|
|
@@ -1386,7 +1613,7 @@
|
|
|
1386
1613
|
return n;
|
|
1387
1614
|
}
|
|
1388
1615
|
|
|
1389
|
-
// Consecutive lines join the list above
|
|
1616
|
+
// Consecutive lines join the list above
|
|
1390
1617
|
function listify(b, tag, checked) {
|
|
1391
1618
|
const item = document.createElement('li');
|
|
1392
1619
|
while (b.firstChild) item.appendChild(b.firstChild);
|
|
@@ -1403,7 +1630,6 @@
|
|
|
1403
1630
|
return item;
|
|
1404
1631
|
}
|
|
1405
1632
|
|
|
1406
|
-
// Strip the marker text the rule matched, without disturbing the rest of the line.
|
|
1407
1633
|
function stripLead(el, count) {
|
|
1408
1634
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1409
1635
|
let left = count;
|
|
@@ -1437,10 +1663,7 @@
|
|
|
1437
1663
|
return best;
|
|
1438
1664
|
}
|
|
1439
1665
|
|
|
1440
|
-
//
|
|
1441
|
-
// **bold**, *italic*, `code`, ~~struck~~, [text](url) — like any modern
|
|
1442
|
-
// editor, rather than waiting for Enter. Only a pattern ending exactly at
|
|
1443
|
-
// the caret is taken, so half-typed markers never convert under the caret.
|
|
1666
|
+
// Only a pattern ending exactly at the caret; earliest start wins (image over link)
|
|
1444
1667
|
function endInline(text) {
|
|
1445
1668
|
let best = null;
|
|
1446
1669
|
for (const rule of INLINE_RULES) {
|
|
@@ -1450,8 +1673,6 @@
|
|
|
1450
1673
|
if (m.index + m[0].length !== text.length) continue;
|
|
1451
1674
|
const skip = rule.pre ? m[rule.pre].length : 0;
|
|
1452
1675
|
const start = m.index + skip;
|
|
1453
|
-
// Earliest start wins, like firstInline —  must be
|
|
1454
|
-
// the image rule, not the link rule matching one char later.
|
|
1455
1676
|
if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
|
|
1456
1677
|
break;
|
|
1457
1678
|
}
|
|
@@ -1464,7 +1685,7 @@
|
|
|
1464
1685
|
const node = r.startContainer;
|
|
1465
1686
|
if (!node || node.nodeType !== 3 || !node.parentElement) return false;
|
|
1466
1687
|
const host = node.parentElement.closest('code, pre, a');
|
|
1467
|
-
if (host && area.contains(host)) return false;
|
|
1688
|
+
if (host && area.contains(host)) return false;
|
|
1468
1689
|
const hit = endInline(node.nodeValue.slice(0, r.startOffset));
|
|
1469
1690
|
if (!hit) return false;
|
|
1470
1691
|
const el = inlineEl(hit.rule, hit.m); if (!el) return false;
|
|
@@ -1480,24 +1701,77 @@
|
|
|
1480
1701
|
return true;
|
|
1481
1702
|
}
|
|
1482
1703
|
|
|
1483
|
-
|
|
1484
|
-
//
|
|
1704
|
+
const lastTextIn = (el) => { let last = null; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let t = w.nextNode(); t; t = w.nextNode()) last = t; return last; };
|
|
1705
|
+
// A collapsed caret at a mark's end edge: after it in its parent, at the start of the text after it, or at the end of its text
|
|
1706
|
+
function atMarkEnd(el, r) {
|
|
1707
|
+
if (!el || !el.isConnected || !r || !r.collapsed) return false;
|
|
1708
|
+
const n = r.startContainer, o = r.startOffset, last = lastTextIn(el);
|
|
1709
|
+
return (n === el.parentNode && n.childNodes[o - 1] === el) || (n === el && o === el.childNodes.length)
|
|
1710
|
+
|| (n.nodeType === 3 && n === el.nextSibling && o === 0) || (n.nodeType === 3 && n === last && o === n.length);
|
|
1711
|
+
}
|
|
1712
|
+
const tailOf = (el) => { let t = el.nextSibling; if (!t || t.nodeType !== 3) { t = document.createTextNode(''); el.after(t); } return t; };
|
|
1713
|
+
const caretAt = (node, offset) => { const s = sel(), nr = document.createRange(); nr.setStart(node, offset); nr.collapse(true); s.removeAllRanges(); s.addRange(nr); };
|
|
1714
|
+
const isWs = (c) => !c || /\s/.test(c);
|
|
1715
|
+
|
|
1716
|
+
// Text typed right after a conversion goes into a text node after the mark, never into it.
|
|
1717
|
+
// A space that would collapse is stored as U+00A0 until text follows it.
|
|
1718
|
+
function typeAfterMark(area, e) {
|
|
1719
|
+
const el = area._exit;
|
|
1720
|
+
if (e.inputType !== 'insertText' || !e.cancelable || !e.data || /[\r\n]/.test(e.data) || !atMarkEnd(el, range())) return false;
|
|
1721
|
+
e.preventDefault();
|
|
1722
|
+
area._exit = null; area._after = el;
|
|
1723
|
+
const last = lastTextIn(el), tail = tailOf(el);
|
|
1724
|
+
const next = tail.nodeValue[0] || '', prev = (last && last.nodeValue.slice(-1)) || '';
|
|
1725
|
+
const chars = [...e.data];
|
|
1726
|
+
const text = chars.map((c, i) => c === ' ' && (isWs(i ? chars[i - 1] : prev) || isWs(i < chars.length - 1 ? chars[i + 1] : next)) ? '\u00a0' : c).join('');
|
|
1727
|
+
tail.insertData(0, text);
|
|
1728
|
+
const nbs = []; [...text].forEach((c, i) => { if (c === '\u00a0') nbs.push(i); });
|
|
1729
|
+
area._nb = nbs.length ? { node: tail, at: nbs } : null;
|
|
1730
|
+
caretAt(tail, text.length);
|
|
1731
|
+
area.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: e.data }));
|
|
1732
|
+
return true;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// A U+00A0 placed after a mark, or just before typed text, becomes a plain space once text is on both sides of it
|
|
1736
|
+
function relaxSpaces(area, typed) {
|
|
1737
|
+
const r0 = range();
|
|
1738
|
+
if (typed && r0 && r0.collapsed && r0.startContainer.nodeType === 3) {
|
|
1739
|
+
const at = r0.startOffset - typed.length - 1;
|
|
1740
|
+
if (at >= 0 && r0.startContainer.nodeValue[at] === '\u00a0') area._nb = area._nb && area._nb.node === r0.startContainer ? { node: area._nb.node, at: [...new Set([...area._nb.at, at])] } : { node: r0.startContainer, at: [at] };
|
|
1741
|
+
}
|
|
1742
|
+
const rec = area._nb; if (!rec) return;
|
|
1743
|
+
const node = rec.node;
|
|
1744
|
+
if (!node.isConnected) { area._nb = null; return; }
|
|
1745
|
+
const r = range(), keep = r && r.startContainer === node ? r.startOffset : null;
|
|
1746
|
+
rec.at = rec.at.filter(i => {
|
|
1747
|
+
const v = node.nodeValue;
|
|
1748
|
+
if (v[i] !== '\u00a0') return false;
|
|
1749
|
+
const p = node.previousSibling, x = node.nextSibling;
|
|
1750
|
+
const prev = i ? v[i - 1] : (p ? p.textContent.slice(-1) : ''), next = i < v.length - 1 ? v[i + 1] : (x ? x.textContent[0] : '');
|
|
1751
|
+
if (isWs(prev) || isWs(next)) return true;
|
|
1752
|
+
node.replaceData(i, 1, ' ');
|
|
1753
|
+
return false;
|
|
1754
|
+
});
|
|
1755
|
+
if (keep != null) caretAt(node, keep);
|
|
1756
|
+
if (!rec.at.length) area._nb = null;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// IME text composed right after a conversion is moved out of the mark, the caret with it
|
|
1485
1760
|
function leaveMark(area, typed) {
|
|
1486
1761
|
const el = area._exit; area._exit = null;
|
|
1487
1762
|
const r = range();
|
|
1488
1763
|
if (!el || !el.isConnected || !r || !r.collapsed) return false;
|
|
1489
1764
|
const node = r.startContainer;
|
|
1490
|
-
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
if (
|
|
1765
|
+
// Chrome stores a typed trailing space as U+00A0
|
|
1766
|
+
const nb = (s) => s.replace(/\u00a0/g, ' ');
|
|
1767
|
+
if (node.nodeType !== 3 || !el.contains(node) || r.startOffset !== node.length || !nb(node.nodeValue).endsWith(nb(typed))) return false;
|
|
1768
|
+
if (lastTextIn(el) !== node || node.length === typed.length) return false;
|
|
1769
|
+
const moved = node.nodeValue.slice(node.length - typed.length);
|
|
1494
1770
|
node.deleteData(node.length - typed.length, typed.length);
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
tail
|
|
1498
|
-
|
|
1499
|
-
nr.setStart(tail, typed.length); nr.collapse(true);
|
|
1500
|
-
s.removeAllRanges(); s.addRange(nr);
|
|
1771
|
+
const tail = tailOf(el);
|
|
1772
|
+
tail.insertData(0, moved);
|
|
1773
|
+
caretAt(tail, typed.length);
|
|
1774
|
+
area._after = el;
|
|
1501
1775
|
return true;
|
|
1502
1776
|
}
|
|
1503
1777
|
|
|
@@ -1519,14 +1793,11 @@
|
|
|
1519
1793
|
});
|
|
1520
1794
|
}
|
|
1521
1795
|
|
|
1522
|
-
// Returns the element now holding the line
|
|
1523
|
-
// caller knows to continue the list rather than start a paragraph.
|
|
1796
|
+
// Returns the element now holding the line (an <li> continues the list)
|
|
1524
1797
|
function convertLine(b) {
|
|
1525
1798
|
if (!b) return b;
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
// else's <pre> (a docs code-group wrapper) still converts markdown.
|
|
1529
|
-
const area = b.closest('[data-text-edit]');
|
|
1799
|
+
// Only a <pre> inside the editor is a code block
|
|
1800
|
+
const area = b.closest('[data-rich-text]');
|
|
1530
1801
|
const pre = b.closest('pre');
|
|
1531
1802
|
if (pre && (!area || area.contains(pre))) return b;
|
|
1532
1803
|
const text = b.textContent;
|
|
@@ -1541,12 +1812,7 @@
|
|
|
1541
1812
|
return b;
|
|
1542
1813
|
}
|
|
1543
1814
|
|
|
1544
|
-
/* ---- Enter ----
|
|
1545
|
-
Owned outright rather than post-processed: the split has to know what the line
|
|
1546
|
-
became, headings and quotes must not continue themselves, and lists and code
|
|
1547
|
-
blocks need a way out that is not the mouse. */
|
|
1548
|
-
const CONTINUES = new Set(['LI', 'PRE']);
|
|
1549
|
-
|
|
1815
|
+
/* ---- Enter ---- */
|
|
1550
1816
|
function splitInto(b, tag) {
|
|
1551
1817
|
const n = document.createElement(tag);
|
|
1552
1818
|
const r = range();
|
|
@@ -1561,29 +1827,7 @@
|
|
|
1561
1827
|
return n;
|
|
1562
1828
|
}
|
|
1563
1829
|
|
|
1564
|
-
|
|
1565
|
-
const list = li.closest('ul, ol');
|
|
1566
|
-
const p = document.createElement('p');
|
|
1567
|
-
const after = [...list.children].slice([...list.children].indexOf(li) + 1);
|
|
1568
|
-
list.after(p);
|
|
1569
|
-
if (after.length) {
|
|
1570
|
-
const tail = document.createElement(list.tagName.toLowerCase());
|
|
1571
|
-
if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
|
|
1572
|
-
after.forEach(n => tail.appendChild(n));
|
|
1573
|
-
p.after(tail);
|
|
1574
|
-
}
|
|
1575
|
-
li.remove();
|
|
1576
|
-
if (!list.children.length) list.remove();
|
|
1577
|
-
caretIn(p, 0);
|
|
1578
|
-
void area;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
// Enter is only taken over where the wanted behaviour differs from the browser's.
|
|
1582
|
-
// Inside a code block, and for Shift+Enter, the native handling is already right —
|
|
1583
|
-
// and doing it by hand goes wrong, because a caret placed by script next to a <br>
|
|
1584
|
-
// or a bare newline gets normalized back over it before the next character lands.
|
|
1585
|
-
// Chrome writes <br> inside a <pre> rather than a newline, so an empty last line
|
|
1586
|
-
// is a pair of trailing breaks — not a trailing "\n" as the markup suggests.
|
|
1830
|
+
// Native Enter inside code and for Shift+Enter; Chrome writes <br> pairs in a <pre>
|
|
1587
1831
|
const codeEndsBlank = (pre) => {
|
|
1588
1832
|
const last = pre.lastChild;
|
|
1589
1833
|
if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
|
|
@@ -1597,27 +1841,22 @@
|
|
|
1597
1841
|
const ownsEnter = (e) => !e.shiftKey && (!ancestor('pre') || leavingCode());
|
|
1598
1842
|
|
|
1599
1843
|
/* ---- Backspace ----
|
|
1600
|
-
|
|
1601
|
-
paragraph above and inlines the heading's computed styles as a <span> to keep
|
|
1602
|
-
the look — so the document quietly fills with font-size and font-weight spans.
|
|
1603
|
-
Deleting the last character of a table's only cell takes the whole table with
|
|
1604
|
-
it. Both are surprising enough to be worth owning. */
|
|
1844
|
+
Owned at a block start (no style spans from merged headings) and in a table's last cell. */
|
|
1605
1845
|
const atBlockStart = () => { const b = ancestor('li') || blockEl(); return !!b && offsetIn(b) === 0; };
|
|
1606
1846
|
|
|
1607
1847
|
function cellCoversAll() {
|
|
1608
1848
|
const cell = ancestor('td, th'); if (!cell) return null;
|
|
1609
1849
|
const r = range(); if (!r) return null;
|
|
1610
|
-
const all = document.createRange(); all.selectNodeContents(cell);
|
|
1611
1850
|
const covers = r.collapsed
|
|
1612
1851
|
? cell.textContent.length <= 1
|
|
1613
1852
|
: r.toString().length >= cell.textContent.length;
|
|
1614
1853
|
return covers ? cell : null;
|
|
1615
1854
|
}
|
|
1616
1855
|
|
|
1617
|
-
function backspaceAtStart(
|
|
1856
|
+
function backspaceAtStart() {
|
|
1618
1857
|
const li = ancestor('li');
|
|
1619
1858
|
if (li) {
|
|
1620
|
-
if (li.previousElementSibling) {
|
|
1859
|
+
if (li.previousElementSibling) {
|
|
1621
1860
|
const prev = li.previousElementSibling, at = prev.textContent.length;
|
|
1622
1861
|
while (li.firstChild) { const n = li.firstChild; if (n.nodeName === 'INPUT') n.remove(); else prev.appendChild(n); }
|
|
1623
1862
|
li.remove();
|
|
@@ -1632,11 +1871,12 @@
|
|
|
1632
1871
|
caretIn(p, 0);
|
|
1633
1872
|
return;
|
|
1634
1873
|
}
|
|
1635
|
-
const b = blockEl(); if (!b) return;
|
|
1874
|
+
const b = blockEl(); if (!b || /^(TD|TH)$/.test(b.tagName)) return;
|
|
1636
1875
|
const prev = b.previousElementSibling;
|
|
1637
|
-
if (!prev
|
|
1876
|
+
if (!prev && /^(DT|DD)$/.test(b.tagName)) { liftItem(b); return; }
|
|
1877
|
+
if (!prev) { if (b.tagName !== 'P') retagBlock(b, 'p'); caretIn(blockEl() || b, 0); return; }
|
|
1638
1878
|
if (prev.tagName === 'HR') { prev.remove(); caretIn(b, 0); return; }
|
|
1639
|
-
if (/^(UL|OL)$/.test(prev.tagName)) {
|
|
1879
|
+
if (/^(UL|OL)$/.test(prev.tagName)) {
|
|
1640
1880
|
const last = prev.lastElementChild; if (!last) return;
|
|
1641
1881
|
const at = last.textContent.length;
|
|
1642
1882
|
while (b.firstChild) last.appendChild(b.firstChild);
|
|
@@ -1653,16 +1893,12 @@
|
|
|
1653
1893
|
|
|
1654
1894
|
function onEnter(area, autoformat) {
|
|
1655
1895
|
let holder = ancestor('li') || blockEl() || ensureBlock(area);
|
|
1896
|
+
// Paragraphs inside a cell, never siblings of it
|
|
1897
|
+
if (/^(TD|TH)$/.test(holder.tagName)) holder = innerBlock(holder);
|
|
1656
1898
|
|
|
1657
|
-
//
|
|
1658
|
-
// inside someone else's <pre> (a docs frame inside a code-group wrapper)
|
|
1659
|
-
// must not treat that wrapper as its own code block — the exit path below
|
|
1660
|
-
// rewrites and can remove the pre, destroying the host markup around the
|
|
1661
|
-
// editor.
|
|
1899
|
+
// An empty last line leaves the code block (bounded to the area)
|
|
1662
1900
|
const pre = holder.closest('pre');
|
|
1663
1901
|
if (pre && pre !== area && area.contains(pre)) {
|
|
1664
|
-
// An empty last line leaves the code block — the one case the browser
|
|
1665
|
-
// cannot do, and without it the block has no keyboard exit.
|
|
1666
1902
|
while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
|
|
1667
1903
|
if (/\n[^\S\n]*$/.test(pre.textContent)) pre.textContent = pre.textContent.replace(/\n[^\S\n]*$/, '');
|
|
1668
1904
|
const p = document.createElement('p');
|
|
@@ -1672,19 +1908,41 @@
|
|
|
1672
1908
|
return;
|
|
1673
1909
|
}
|
|
1674
1910
|
|
|
1675
|
-
if (holder.tagName === 'LI' && !holder.textContent.length) return
|
|
1911
|
+
if (holder.tagName === 'LI' && !holder.textContent.length) return liftItem(holder);
|
|
1676
1912
|
|
|
1677
|
-
//
|
|
1678
|
-
|
|
1913
|
+
// An empty last paragraph leaves its quote (a quote from markdown holds paragraphs)
|
|
1914
|
+
const quote = holder.parentElement;
|
|
1915
|
+
if (holder.tagName === 'P' && quote && quote.tagName === 'BLOCKQUOTE' && quote !== area && area.contains(quote) && holder === quote.lastElementChild && !holder.textContent.trim()) {
|
|
1916
|
+
quote.after(holder);
|
|
1917
|
+
if (!quote.textContent.trim() && !quote.querySelector('img')) quote.remove();
|
|
1918
|
+
caretIn(holder, 0);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Terms alternate dt → dd → dt; an empty one leaves the list
|
|
1923
|
+
if (/^(DT|DD)$/.test(holder.tagName)) {
|
|
1924
|
+
if (!holder.textContent.length) return liftItem(holder);
|
|
1925
|
+
caretIn(splitInto(holder, holder.tagName === 'DT' ? 'dd' : 'dt'), 0);
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// A caption continues after its figure
|
|
1930
|
+
if (holder.tagName === 'FIGCAPTION') {
|
|
1931
|
+
const next = splitInto(holder, 'p');
|
|
1932
|
+
holder.closest('figure').after(next);
|
|
1933
|
+
caretIn(next, 0);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Split before converting: conversion strips marker text under the caret
|
|
1679
1938
|
const next = splitInto(holder, holder.tagName === 'LI' ? 'li' : 'p');
|
|
1680
1939
|
if (autoformat) holder = convertLine(holder) || holder;
|
|
1681
1940
|
|
|
1682
1941
|
if (holder.tagName === 'HR') { caretIn(next, 0); return; }
|
|
1683
1942
|
|
|
1684
|
-
// A finished ``` line opens the code block
|
|
1943
|
+
// A finished ``` line opens the code block
|
|
1685
1944
|
if (holder.tagName === 'PRE') { next.remove(); caretIn(holder, 0); return; }
|
|
1686
1945
|
|
|
1687
|
-
// The line became a list item, so the new line continues the list.
|
|
1688
1946
|
if (holder.tagName === 'LI') {
|
|
1689
1947
|
const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
|
|
1690
1948
|
holder.after(item);
|
|
@@ -1698,58 +1956,70 @@
|
|
|
1698
1956
|
return;
|
|
1699
1957
|
}
|
|
1700
1958
|
|
|
1701
|
-
//
|
|
1702
|
-
// continue itself — the next line is body text, which is what a writer means.
|
|
1959
|
+
// Headings and quotes don't continue themselves
|
|
1703
1960
|
caretIn(next, 0);
|
|
1704
1961
|
}
|
|
1705
1962
|
|
|
1963
|
+
const MAC = /Mac|iPhone|iPad|iPod/.test((typeof navigator !== 'undefined' && (navigator.userAgentData && navigator.userAgentData.platform || navigator.platform)) || '');
|
|
1964
|
+
|
|
1706
1965
|
/* ---- History ----
|
|
1707
|
-
|
|
1708
|
-
browser's own undo stack and can leave it inconsistent. The editor keeps its
|
|
1709
|
-
own: an HTML snapshot plus the caret's character offset, coalesced while
|
|
1710
|
-
typing so a burst of keystrokes undoes as one step. */
|
|
1966
|
+
HTML snapshots plus caret offset; typing bursts coalesce into one step. */
|
|
1711
1967
|
const HISTORY_CAP = 200;
|
|
1712
1968
|
const IDLE = 400;
|
|
1713
1969
|
|
|
1970
|
+
// Every entry is a settled state (trailing block, tables), so settling never makes the field differ from its history
|
|
1714
1971
|
function record(area) {
|
|
1715
1972
|
const h = area._history; if (!h || h.restoring) return;
|
|
1716
1973
|
clearTimeout(h.timer); h.timer = null;
|
|
1974
|
+
if (area._rt && area._rt.settle) area._rt.settle();
|
|
1717
1975
|
const html = area.innerHTML;
|
|
1718
|
-
|
|
1976
|
+
// Unchanged: the entry learns where the next edit starts (`before`, for undo);
|
|
1977
|
+
// `caret` stays where the edit that produced it ended (for redo)
|
|
1978
|
+
if (h.stack[h.at] && h.stack[h.at].html === html) {
|
|
1979
|
+
const caret = offsetIn(area);
|
|
1980
|
+
if (caret != null) h.stack[h.at].before = caret;
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1719
1983
|
h.stack.length = h.at + 1;
|
|
1720
1984
|
h.stack.push({ html, caret: offsetIn(area) });
|
|
1721
1985
|
if (h.stack.length > HISTORY_CAP) h.stack.shift();
|
|
1722
1986
|
h.at = h.stack.length - 1;
|
|
1723
1987
|
}
|
|
1724
1988
|
|
|
1989
|
+
// A burst about to start: the entry it will undo to remembers where it starts (never adds a step)
|
|
1990
|
+
function markCaret(area) {
|
|
1991
|
+
const h = area._history; if (!h || h.timer || h.restoring) return;
|
|
1992
|
+
const top = h.stack[h.at], caret = offsetIn(area);
|
|
1993
|
+
if (top && caret != null && top.html === area.innerHTML) top.before = caret;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Re-sync once the burst lands, so Undo lights up
|
|
1725
1997
|
const recordSoon = (area) => {
|
|
1726
1998
|
const h = area._history; if (!h || h.restoring) return;
|
|
1727
1999
|
clearTimeout(h.timer);
|
|
1728
|
-
// Re-sync once the burst lands: undo/redo controls gate on history state,
|
|
1729
|
-
// and the reset's [aria-disabled] rule blocks pointer events — without
|
|
1730
|
-
// this, Undo stays visually dead until some unrelated event syncs.
|
|
1731
2000
|
h.timer = setTimeout(() => { record(area); sync(); }, IDLE);
|
|
1732
2001
|
};
|
|
1733
2002
|
|
|
1734
2003
|
function step(area, by) {
|
|
1735
2004
|
const h = area._history; if (!h) return;
|
|
1736
|
-
|
|
2005
|
+
// Land a pending burst; walking history alone leaves each entry's carets as recorded
|
|
2006
|
+
if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML) record(area);
|
|
1737
2007
|
const next = h.at + by;
|
|
1738
2008
|
if (next < 0 || next >= h.stack.length) return;
|
|
1739
2009
|
h.at = next; h.restoring = true;
|
|
1740
|
-
|
|
2010
|
+
const entry = h.stack[next];
|
|
2011
|
+
area.innerHTML = entry.html;
|
|
2012
|
+
if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
|
|
1741
2013
|
area.focus();
|
|
1742
|
-
caretIn(area,
|
|
2014
|
+
caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
|
|
1743
2015
|
saveRange(area);
|
|
1744
2016
|
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1745
|
-
h.restoring = false;
|
|
2017
|
+
h.restoring = false;
|
|
1746
2018
|
}
|
|
1747
2019
|
|
|
1748
2020
|
/* ---- Registry + resolution ----
|
|
1749
|
-
Controls
|
|
1750
|
-
|
|
1751
|
-
sharing a DOM from driving each other; a toolbar with no such ancestor falls
|
|
1752
|
-
through to whichever area was last focused. x-text-edit-for pins either. */
|
|
2021
|
+
Controls resolve by containment (nearest ancestor holding exactly one area), then
|
|
2022
|
+
by focus; x-rich-text-for pins either. */
|
|
1753
2023
|
const areas = new Set();
|
|
1754
2024
|
const controls = new Set();
|
|
1755
2025
|
let lastFocused = null;
|
|
@@ -1759,9 +2029,9 @@
|
|
|
1759
2029
|
const preferred = (list) => list.find(a => a === lastFocused && onScreen(a)) || list.find(onScreen) || list[0] || null;
|
|
1760
2030
|
|
|
1761
2031
|
function resolve(control) {
|
|
1762
|
-
const pin = control.closest('[x-text-
|
|
2032
|
+
const pin = control.closest('[x-rich-text-for]');
|
|
1763
2033
|
if (pin) {
|
|
1764
|
-
const target = document.querySelector(pin.getAttribute('x-text-
|
|
2034
|
+
const target = document.querySelector(pin.getAttribute('x-rich-text-for'));
|
|
1765
2035
|
if (target) return areas.has(target) ? target : preferred(inside(target));
|
|
1766
2036
|
}
|
|
1767
2037
|
for (let n = control.parentElement; n; n = n.parentElement) {
|
|
@@ -1772,35 +2042,26 @@
|
|
|
1772
2042
|
return unambiguous([...areas]);
|
|
1773
2043
|
}
|
|
1774
2044
|
|
|
1775
|
-
//
|
|
1776
|
-
// one decides; before anything has been focused there is no answer, and guessing
|
|
1777
|
-
// sends the command somewhere the writer was not looking — a panel button that
|
|
1778
|
-
// silently rewrites the first heading on the page rather than the line they are
|
|
1779
|
-
// in. Reporting the control unavailable is the honest reading.
|
|
1780
|
-
// Letting go of the target has to be announced. A menu anchored to the selection
|
|
1781
|
-
// is watching for it, and without a final null it hangs about over content it no
|
|
1782
|
-
// longer has anything to do with.
|
|
2045
|
+
// Releasing the target is announced with a null selection
|
|
1783
2046
|
function release(area) {
|
|
1784
2047
|
const was = area || lastFocused;
|
|
1785
2048
|
if (!area || area === lastFocused) lastFocused = null;
|
|
1786
2049
|
if (!was) return;
|
|
1787
|
-
was.removeAttribute('data-text-
|
|
2050
|
+
was.removeAttribute('data-rich-text-focused');
|
|
1788
2051
|
was._selection = null;
|
|
1789
|
-
was.removeAttribute('data-text-
|
|
1790
|
-
document.documentElement.removeAttribute('data-text-
|
|
1791
|
-
was.dispatchEvent(new CustomEvent('text
|
|
2052
|
+
was.removeAttribute('data-rich-text-selected');
|
|
2053
|
+
document.documentElement.removeAttribute('data-rich-text-selected');
|
|
2054
|
+
was.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: null }));
|
|
1792
2055
|
}
|
|
1793
2056
|
|
|
1794
|
-
//
|
|
1795
|
-
// the blur stands in, and only for the tick that dispatched it.
|
|
2057
|
+
// Stands in for relatedTarget during the blur a pointer caused
|
|
1796
2058
|
let pointerTarget = null;
|
|
1797
2059
|
|
|
1798
|
-
// Focus is still the area's own, or on a control that acts on it.
|
|
1799
2060
|
function keeps(area, node) {
|
|
1800
2061
|
const el = elementAt(node);
|
|
1801
2062
|
if (!el) return false;
|
|
1802
2063
|
if (area.contains(el)) return true;
|
|
1803
|
-
const control = el.closest('[data-text-
|
|
2064
|
+
const control = el.closest('[data-rich-text-control]');
|
|
1804
2065
|
return !!control && resolve(control) === area;
|
|
1805
2066
|
}
|
|
1806
2067
|
|
|
@@ -1810,6 +2071,7 @@
|
|
|
1810
2071
|
return !!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !TEXTLESS.test(el.type)));
|
|
1811
2072
|
};
|
|
1812
2073
|
|
|
2074
|
+
// Several areas in scope and none focused: no target
|
|
1813
2075
|
function unambiguous(found) {
|
|
1814
2076
|
if (lastFocused && found.includes(lastFocused) && lastFocused.isConnected && onScreen(lastFocused)) return lastFocused;
|
|
1815
2077
|
const visible = found.filter(onScreen);
|
|
@@ -1817,9 +2079,7 @@
|
|
|
1817
2079
|
}
|
|
1818
2080
|
|
|
1819
2081
|
/* ---- Caret custody ----
|
|
1820
|
-
|
|
1821
|
-
command runs — a <select>, a colour input or a URL field steals focus by
|
|
1822
|
-
design. Each area keeps its own last range so any control can put it back. */
|
|
2082
|
+
Each area keeps its last range, so a focus-taking control can put it back. */
|
|
1823
2083
|
function saveRange(area) {
|
|
1824
2084
|
const r = range();
|
|
1825
2085
|
if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
|
|
@@ -1828,141 +2088,118 @@
|
|
|
1828
2088
|
const forget = (keep) => areas.forEach(a => { if (!keep || !a.contains(keep)) a._range = null; });
|
|
1829
2089
|
|
|
1830
2090
|
function restoreRange(area) {
|
|
1831
|
-
const r =
|
|
2091
|
+
const r = liveRange();
|
|
1832
2092
|
if (r && area.contains(r.startContainer)) return;
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
//
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2093
|
+
const saved = area._range;
|
|
2094
|
+
if (!saved || !area.contains(saved.startContainer)) return;
|
|
2095
|
+
area.focus(); // its focusin saves the caret focus placed: put ours back
|
|
2096
|
+
area._range = saved;
|
|
2097
|
+
const s = sel(); s.removeAllRanges(); s.addRange(saved);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// With focus in a control (an <input> for a link, a <select>), commands read the area's saved range
|
|
2101
|
+
function judged(area, fn) {
|
|
2102
|
+
const live = liveRange();
|
|
2103
|
+
if ((live && area.contains(live.startContainer)) || !area._range || !area.contains(area._range.startContainer)) return fn();
|
|
2104
|
+
const was = probe; probe = area._range;
|
|
2105
|
+
try { return fn(); } finally { probe = was; }
|
|
2106
|
+
}
|
|
2107
|
+
// Focus or caret in another editable element (not this area, not one of the controls): stay quiet
|
|
2108
|
+
function busyElsewhere(area) {
|
|
2109
|
+
const a = document.activeElement;
|
|
2110
|
+
if (a && a !== document.body && a.closest && !area.contains(a)) {
|
|
2111
|
+
if (a.closest('[data-rich-text-control]')) return false;
|
|
2112
|
+
if (editableElsewhere(a)) return true;
|
|
2113
|
+
}
|
|
2114
|
+
const live = liveRange();
|
|
2115
|
+
return !!live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer);
|
|
1839
2116
|
}
|
|
1840
2117
|
|
|
1841
|
-
// markdown can only carry the tags it has syntax for. Rather than let a command
|
|
1842
|
-
// write something the next save would drop, it reports itself unavailable.
|
|
1843
2118
|
function allows(area, id, ctrl) {
|
|
1844
|
-
const spec = COMMANDS[id], cfg = area && area.
|
|
2119
|
+
const spec = COMMANDS[id], cfg = area && area._rt;
|
|
1845
2120
|
if (!spec || !cfg) return false;
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
if (
|
|
1854
|
-
if (spec.history) return spec.enabled(area); // every mode, .plain included
|
|
1855
|
-
if (cfg.mode === 'plain') return false;
|
|
1856
|
-
if (cfg.minimal && spec.block && !page) return false;
|
|
1857
|
-
if (cfg.mode !== 'html' && !spec.md && !page) return false;
|
|
1858
|
-
if (page) return true; // writes to the area, needs no caret
|
|
1859
|
-
// Never focused, or released to another field: no caret to act on.
|
|
2121
|
+
const all = !!(ctrl && ctrl.all && spec.all);
|
|
2122
|
+
if (all && cfg.noAll) return false;
|
|
2123
|
+
if (!all && busyElsewhere(area)) return false;
|
|
2124
|
+
if (spec.history) return spec.enabled(area);
|
|
2125
|
+
if (cfg.format === 'plain') return false;
|
|
2126
|
+
if (cfg.inline && !all && (spec.block || (id === 'br' && !cfg.breaks))) return false;
|
|
2127
|
+
if (cfg.format !== 'html' && !spec.md && !all) return false;
|
|
2128
|
+
if (all) return true;
|
|
1860
2129
|
if (!area._range || !area.contains(area._range.startContainer)) return false;
|
|
1861
|
-
|
|
1862
|
-
// but does nothing reads as broken — so say so rather than no-op.
|
|
1863
|
-
if (spec.needsSelection && !hasSelection()) return false;
|
|
1864
|
-
return !spec.enabled || spec.enabled(area);
|
|
2130
|
+
return judged(area, () => !(spec.needsSelection && !hasSelection()) && (!spec.enabled || spec.enabled(area)));
|
|
1865
2131
|
}
|
|
1866
2132
|
|
|
1867
2133
|
function run(area, id, arg, ctrl) {
|
|
1868
2134
|
const spec = COMMANDS[id]; if (!spec) return;
|
|
1869
|
-
const
|
|
1870
|
-
if (!
|
|
1871
|
-
//
|
|
1872
|
-
|
|
1873
|
-
// selection back here and the command would edit text the writer can
|
|
1874
|
-
// see is not selected. (The reset blocks pointer clicks on disabled
|
|
1875
|
-
// controls; this covers keyboard activation.)
|
|
1876
|
-
const live = range();
|
|
1877
|
-
if (live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer)) return;
|
|
2135
|
+
const all = !!(ctrl && ctrl.all && spec.all);
|
|
2136
|
+
if (!all) {
|
|
2137
|
+
// Judged before restoring, so a refused (or keyboard-activated) control never takes focus back
|
|
2138
|
+
if (!allows(area, id, ctrl)) return;
|
|
1878
2139
|
restoreRange(area);
|
|
1879
|
-
// If the caret could not be put in this area — it was never focused, or
|
|
1880
|
-
// the saved range has gone stale — do nothing. Running anyway rewrites
|
|
1881
|
-
// whichever area resolution happened to land on, which reads as the
|
|
1882
|
-
// command changing some other element's text.
|
|
1883
2140
|
const r = range();
|
|
1884
2141
|
if (!r || !area.contains(r.startContainer)) return;
|
|
1885
2142
|
}
|
|
1886
2143
|
if (!allows(area, id, ctrl)) return;
|
|
1887
|
-
if (
|
|
1888
|
-
area.style.setProperty(
|
|
1889
|
-
if (!arg) area.style.removeProperty(
|
|
1890
|
-
area.dispatchEvent(new CustomEvent('text
|
|
2144
|
+
if (all) {
|
|
2145
|
+
area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
|
|
2146
|
+
if (!arg) area.style.removeProperty(ALL_VARS[id]);
|
|
2147
|
+
area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
|
|
1891
2148
|
sync();
|
|
1892
2149
|
return;
|
|
1893
2150
|
}
|
|
1894
|
-
const arming =
|
|
1895
|
-
if (arming) { spec.run(arg, area); sync(); return; }
|
|
1896
|
-
if (!spec.history) record(area);
|
|
2151
|
+
const arming = INLINE_TAGS[id] && !hasSelection();
|
|
2152
|
+
if (arming) { spec.run(arg, area); sync(); return; }
|
|
2153
|
+
if (!spec.history) record(area);
|
|
1897
2154
|
spec.run(arg, area);
|
|
1898
2155
|
normalizeLists(area);
|
|
1899
2156
|
if (!spec.history) record(area);
|
|
1900
2157
|
saveRange(area);
|
|
1901
|
-
if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2158
|
+
if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1902
2159
|
sync();
|
|
1903
2160
|
}
|
|
1904
2161
|
|
|
1905
|
-
// Reflect caret state onto every control
|
|
2162
|
+
// Reflect caret state onto every control
|
|
1906
2163
|
function sync() {
|
|
1907
2164
|
controls.forEach(c => {
|
|
1908
|
-
const cfg = c.
|
|
2165
|
+
const cfg = c._rt, area = resolve(c);
|
|
1909
2166
|
const usable = !!area && allows(area, cfg.id, cfg);
|
|
1910
|
-
if (!area) { c.removeAttribute('data-text-
|
|
2167
|
+
if (!area) { c.removeAttribute('data-rich-text-active'); c.setAttribute('aria-disabled', 'true'); if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', 'false'); return; }
|
|
1911
2168
|
const spec = COMMANDS[cfg.id];
|
|
1912
|
-
const
|
|
1913
|
-
c.toggleAttribute('data-text-
|
|
2169
|
+
const all = !!(cfg.all && spec.all);
|
|
2170
|
+
c.toggleAttribute('data-rich-text-active', usable && !all && !!judged(area, () => spec.active(cfg.literal, area)));
|
|
1914
2171
|
c.setAttribute('aria-disabled', String(!usable));
|
|
1915
|
-
if (usable && (
|
|
1916
|
-
const v =
|
|
1917
|
-
// Never blank a <select>
|
|
1918
|
-
// computed styles report plenty that no option list would carry.
|
|
2172
|
+
if (usable && (all || spec.reflect) && 'value' in c && c !== document.activeElement) {
|
|
2173
|
+
const v = all ? area.style.getPropertyValue(ALL_VARS[cfg.id]).trim() : judged(area, () => spec.reflect());
|
|
2174
|
+
// Never blank a <select> with a value it doesn't offer
|
|
1919
2175
|
const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
|
|
1920
2176
|
if (v != null && offered && c.value !== v) c.value = v;
|
|
1921
2177
|
}
|
|
1922
|
-
if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-text-
|
|
2178
|
+
if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-rich-text-active')));
|
|
1923
2179
|
});
|
|
1924
2180
|
}
|
|
1925
2181
|
|
|
1926
|
-
/* ----
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2182
|
+
/* ---- Document listeners ---- */
|
|
2183
|
+
let listening = false;
|
|
2184
|
+
function listen() {
|
|
2185
|
+
if (listening) return;
|
|
2186
|
+
listening = true;
|
|
2187
|
+
// A link clicked in a field is content: neither the router nor the browser follows it.
|
|
2188
|
+
// A contenteditable=false island inside the field keeps its links. Window capture runs first.
|
|
2189
|
+
const blockLink = (e) => {
|
|
2190
|
+
const link = e.target && e.target.closest && e.target.closest('a[href]');
|
|
2191
|
+
const field = link && link.closest('[data-rich-text]');
|
|
2192
|
+
if (!field || !areas.has(field)) return;
|
|
2193
|
+
const off = link.closest('[contenteditable="false"]');
|
|
2194
|
+
if (off && field.contains(off)) return;
|
|
2195
|
+
e.preventDefault();
|
|
2196
|
+
};
|
|
2197
|
+
window.addEventListener('click', blockLink, true);
|
|
2198
|
+
window.addEventListener('auxclick', blockLink, true);
|
|
2199
|
+
// No inline computed styles; Enter makes <p>, not <div>
|
|
1931
2200
|
try { document.execCommand('styleWithCSS', false, false); } catch { }
|
|
1932
2201
|
try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch { }
|
|
1933
2202
|
|
|
1934
|
-
Alpine.directive('text-edit', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
|
|
1935
|
-
const id = modifiers.find(m => COMMANDS[m]);
|
|
1936
|
-
if (id) return control(el, id, modifiers, expression, evaluate, cleanup);
|
|
1937
|
-
// A control's tag never becomes a textbox; an unknown command is reported instead.
|
|
1938
|
-
if (/^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(el.tagName)) {
|
|
1939
|
-
const m = modifiers.find(x => !MODES.has(x) && x !== 'page');
|
|
1940
|
-
console.warn(`[Manifest Text Edit] unknown command${m ? ` .${m}` : ''} on <${el.tagName.toLowerCase()}>${suggest(m)}`);
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
return area(el, modifiers, expression, { effect, evaluateLater, cleanup });
|
|
1944
|
-
});
|
|
1945
|
-
|
|
1946
|
-
// x-text-edit-menu — the turnkey selection bubble: put commands inside,
|
|
1947
|
-
// and the plugin shows the element over the selection of the area it
|
|
1948
|
-
// resolves to (same rules as any control) and hides it when the
|
|
1949
|
-
// selection is gone. Positioning is the stylesheet's, off the
|
|
1950
|
-
// --text-edit-selection-* variables, so it can be restyled freely.
|
|
1951
|
-
Alpine.directive('text-edit-menu', (el, _, { cleanup }) => selectionMenu(el, cleanup));
|
|
1952
|
-
|
|
1953
|
-
// $text — the area this element sits in, resolved like a control's:
|
|
1954
|
-
// containment first (nearest ancestor holding exactly one area), the
|
|
1955
|
-
// last focused area second. Never "the first area on the page" — a
|
|
1956
|
-
// button at the bottom must not act on, and scroll to, the top one.
|
|
1957
|
-
Alpine.magic('text', (el) => {
|
|
1958
|
-
const own = el.closest('[data-text-edit]');
|
|
1959
|
-
const a = own && areas.has(own) ? own : resolve(el);
|
|
1960
|
-
return a ? a._te.api : {
|
|
1961
|
-
value: '', link: '', selection: null, page: {}, run() { }, active: () => false,
|
|
1962
|
-
can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
|
|
1963
|
-
};
|
|
1964
|
-
});
|
|
1965
|
-
|
|
1966
2203
|
document.addEventListener('pointerdown', (e) => {
|
|
1967
2204
|
pointerTarget = e.target;
|
|
1968
2205
|
setTimeout(() => { pointerTarget = null; }, 0);
|
|
@@ -1972,38 +2209,87 @@
|
|
|
1972
2209
|
const r = range(); if (!r) return;
|
|
1973
2210
|
const a = [...areas].find(x => x.contains(r.startContainer));
|
|
1974
2211
|
if (a) {
|
|
1975
|
-
if (lastFocused && lastFocused !== a) release(lastFocused);
|
|
2212
|
+
if (lastFocused && lastFocused !== a) release(lastFocused);
|
|
1976
2213
|
saveRange(a); lastFocused = a; report(a);
|
|
1977
2214
|
}
|
|
1978
2215
|
else if (editableElsewhere(r.startContainer)) { forget(); if (lastFocused) release(); }
|
|
1979
2216
|
sync();
|
|
1980
2217
|
});
|
|
1981
2218
|
|
|
1982
|
-
//
|
|
1983
|
-
// a plain contenteditable, an input. Nothing here owns it, so the controls
|
|
1984
|
-
// have no target: they must go quiet rather than keep pointing at whichever
|
|
1985
|
-
// area was last visited, which is how a command ends up styling text the
|
|
1986
|
-
// writer can see is not selected.
|
|
2219
|
+
// Caret moved into something else editable: controls lose their target
|
|
1987
2220
|
document.addEventListener('focusin', (e) => {
|
|
1988
2221
|
const t = e.target;
|
|
1989
2222
|
if (!t || !t.closest) return;
|
|
1990
|
-
if (t.closest('[data-text-
|
|
1991
|
-
// Writing somewhere else: no area's old caret may be restored by a later click.
|
|
2223
|
+
if (t.closest('[data-rich-text-control]')) return;
|
|
1992
2224
|
const elsewhere = editableElsewhere(t);
|
|
1993
2225
|
if (elsewhere) forget(t);
|
|
1994
|
-
if (t.closest('[data-text
|
|
2226
|
+
if (t.closest('[data-rich-text]')) return;
|
|
1995
2227
|
if (!lastFocused && !elsewhere) return;
|
|
1996
2228
|
if (lastFocused) release();
|
|
1997
2229
|
sync();
|
|
1998
2230
|
}, true);
|
|
1999
2231
|
}
|
|
2000
2232
|
|
|
2233
|
+
/* ---- Directive ---- */
|
|
2234
|
+
function init() {
|
|
2235
|
+
if (!window.Alpine || !Alpine.directive) return;
|
|
2236
|
+
// Alpine walks only x-data trees: a page with none still gets its fields
|
|
2237
|
+
if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
|
|
2238
|
+
listen();
|
|
2239
|
+
|
|
2240
|
+
Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
|
|
2241
|
+
const id = modifiers.find(m => COMMANDS[m]);
|
|
2242
|
+
if (id) return control(el, id, modifiers, expression, evaluate, cleanup);
|
|
2243
|
+
if (/^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(el.tagName)) {
|
|
2244
|
+
const m = modifiers.find(x => !MODES.has(x) && x !== 'all');
|
|
2245
|
+
console.warn(`[Manifest Rich Text] unknown command${m ? ` .${m}` : ''} on <${el.tagName.toLowerCase()}>${suggest(m)}`);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
return areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup });
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
// x-rich-text-menu: a popover shown over its area's selection
|
|
2252
|
+
Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
|
|
2253
|
+
|
|
2254
|
+
// $rich: the area this element sits in, else the one controls would resolve to
|
|
2255
|
+
const richMagic = (el) => {
|
|
2256
|
+
const own = el.closest('[data-rich-text]');
|
|
2257
|
+
const a = own && areas.has(own) ? own : resolve(el);
|
|
2258
|
+
return a ? a._rt.api : idleApi();
|
|
2259
|
+
};
|
|
2260
|
+
Alpine.magic('rich', richMagic);
|
|
2261
|
+
// The loader's stand-in answered $rich until now: it hands over, and effects that read it
|
|
2262
|
+
// run again once adopted areas exist
|
|
2263
|
+
window.__manifestRichMagic = richMagic;
|
|
2264
|
+
if (window.__manifestRichStub) setTimeout(() => { window.__manifestRichStub.v++; }, 0);
|
|
2265
|
+
|
|
2266
|
+
adopt();
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// Loaded after Alpine started: elements it already walked get their x-rich-text*
|
|
2270
|
+
// attributes re-set, which Alpine handles as newly added directives
|
|
2271
|
+
function adopt() {
|
|
2272
|
+
document.querySelectorAll('*').forEach(el => {
|
|
2273
|
+
if (!el._x_marker || el._rt || el._rtMenu || controls.has(el)) return;
|
|
2274
|
+
[...el.attributes].forEach(a => {
|
|
2275
|
+
if (a.name.startsWith('x-rich-text') && !a.name.startsWith('x-rich-text-for')) el.setAttribute(a.name, a.value);
|
|
2276
|
+
});
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function idleApi() {
|
|
2281
|
+
const api = {
|
|
2282
|
+
value: '', link: '', selection: null, all: {}, run() { }, active: () => false,
|
|
2283
|
+
can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
|
|
2284
|
+
};
|
|
2285
|
+
Object.keys(COMMANDS).forEach(id => { api[id] = () => { }; });
|
|
2286
|
+
return api;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2001
2289
|
/* ---- Selection reporting ----
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
The plugin renders no menu of its own — where and what that is belongs to the
|
|
2006
|
-
project, not to us. */
|
|
2290
|
+
Geometry as a rich-text:selection event and as --rich-text-selection-* properties. */
|
|
2291
|
+
const SELECTION_VARS = ['x', 'y', 'width', 'height', 'center'].map(k => '--rich-text-selection-' + k);
|
|
2292
|
+
|
|
2007
2293
|
function selectionOf(area) {
|
|
2008
2294
|
const r = range();
|
|
2009
2295
|
if (!r || !area.contains(r.startContainer)) return null;
|
|
@@ -2016,49 +2302,39 @@
|
|
|
2016
2302
|
};
|
|
2017
2303
|
}
|
|
2018
2304
|
|
|
2019
|
-
// A text selection cannot highlight an empty cell, so the block is painted here.
|
|
2020
2305
|
function paintCells(area) {
|
|
2021
|
-
area.querySelectorAll('[data-text-
|
|
2022
|
-
selectedCells().forEach(c => c.setAttribute('data-text-
|
|
2306
|
+
area.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
|
|
2307
|
+
selectedCells().forEach(c => c.setAttribute('data-rich-text-cell', ''));
|
|
2023
2308
|
}
|
|
2024
2309
|
|
|
2310
|
+
// On the root only, where a top-layer menu reads them: never in the content's own style
|
|
2025
2311
|
function place(area, shape) {
|
|
2026
2312
|
if (!shape || shape.collapsed) return;
|
|
2027
|
-
const px = (n) => Math.round(n) + 'px';
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
[area, document.documentElement].forEach(t => {
|
|
2034
|
-
t.style.setProperty('--text-edit-selection-x', px(shape.x));
|
|
2035
|
-
t.style.setProperty('--text-edit-selection-y', px(shape.y));
|
|
2036
|
-
t.style.setProperty('--text-edit-selection-width', px(shape.width));
|
|
2037
|
-
t.style.setProperty('--text-edit-selection-height', px(shape.height));
|
|
2038
|
-
t.style.setProperty('--text-edit-selection-center', px(shape.x + shape.width / 2));
|
|
2039
|
-
});
|
|
2313
|
+
const px = (n) => Math.round(n) + 'px', t = document.documentElement;
|
|
2314
|
+
t.style.setProperty('--rich-text-selection-x', px(shape.x));
|
|
2315
|
+
t.style.setProperty('--rich-text-selection-y', px(shape.y));
|
|
2316
|
+
t.style.setProperty('--rich-text-selection-width', px(shape.width));
|
|
2317
|
+
t.style.setProperty('--rich-text-selection-height', px(shape.height));
|
|
2318
|
+
t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
|
|
2040
2319
|
}
|
|
2041
2320
|
|
|
2042
2321
|
function report(area) {
|
|
2043
|
-
// A caret placed inside one cell ends any block selection.
|
|
2044
2322
|
const live = sel();
|
|
2045
2323
|
if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
|
|
2046
2324
|
if (area.querySelector('table')) paintCells(area);
|
|
2047
2325
|
const shape = selectionOf(area);
|
|
2048
2326
|
area._selection = shape;
|
|
2049
2327
|
place(area, shape);
|
|
2050
|
-
area.toggleAttribute('data-text-
|
|
2051
|
-
document.documentElement.toggleAttribute('data-text-
|
|
2052
|
-
area.dispatchEvent(new CustomEvent('text
|
|
2328
|
+
area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
2329
|
+
document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
2330
|
+
area.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: shape }));
|
|
2053
2331
|
}
|
|
2054
2332
|
|
|
2055
|
-
//
|
|
2056
|
-
// element is promoted to popover="manual" (an auto popover closes on the very
|
|
2057
|
-
// click that makes the selection); show/hide follow text-edit:selection.
|
|
2333
|
+
// popover="manual": an auto popover closes on the click that makes the selection
|
|
2058
2334
|
function selectionMenu(el, cleanup) {
|
|
2335
|
+
el._rtMenu = true;
|
|
2059
2336
|
if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
|
|
2060
|
-
el.setAttribute('data-text-
|
|
2061
|
-
// Follow the selection through scrolling and resizing while shown.
|
|
2337
|
+
el.setAttribute('data-rich-text-menu', '');
|
|
2062
2338
|
let shown = null, frame = 0;
|
|
2063
2339
|
const follow = () => {
|
|
2064
2340
|
if (frame) return;
|
|
@@ -2078,7 +2354,7 @@
|
|
|
2078
2354
|
window.removeEventListener('resize', follow);
|
|
2079
2355
|
};
|
|
2080
2356
|
const onSel = (e) => {
|
|
2081
|
-
const area = elementAt(e.target) && elementAt(e.target).closest('[data-text
|
|
2357
|
+
const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
|
|
2082
2358
|
if (!area || resolve(el) !== area) return;
|
|
2083
2359
|
const want = !!e.detail && !e.detail.collapsed;
|
|
2084
2360
|
if (want) track(area); else untrack();
|
|
@@ -2087,60 +2363,53 @@
|
|
|
2087
2363
|
if (!want && el.matches(':popover-open')) el.hidePopover();
|
|
2088
2364
|
} catch { }
|
|
2089
2365
|
};
|
|
2090
|
-
document.addEventListener('text
|
|
2366
|
+
document.addEventListener('rich-text:selection', onSel);
|
|
2091
2367
|
cleanup(() => {
|
|
2092
|
-
document.removeEventListener('text
|
|
2368
|
+
document.removeEventListener('rich-text:selection', onSel);
|
|
2093
2369
|
untrack(); if (frame) cancelAnimationFrame(frame);
|
|
2094
2370
|
try { el.hidePopover(); } catch { }
|
|
2095
2371
|
});
|
|
2096
2372
|
}
|
|
2097
2373
|
|
|
2098
2374
|
function control(el, id, modifiers, expression, evaluate, cleanup) {
|
|
2099
|
-
|
|
2100
|
-
//
|
|
2101
|
-
const
|
|
2102
|
-
const
|
|
2103
|
-
const literal = next === 'page' ? undefined : next;
|
|
2375
|
+
if (controls.has(el)) return;
|
|
2376
|
+
// `.align.center`, `.align.all.center`: the first other modifier after the command is its argument
|
|
2377
|
+
const all = modifiers.includes('all');
|
|
2378
|
+
const literal = modifiers.slice(modifiers.indexOf(id) + 1).find(m => m !== 'all');
|
|
2104
2379
|
const arg = () => literal !== undefined ? literal : (expression ? evaluate(expression) : undefined);
|
|
2105
|
-
el.
|
|
2106
|
-
if (
|
|
2107
|
-
el.setAttribute('data-text-
|
|
2380
|
+
el._rt = { id, arg, all, literal }; // the expression is evaluated only when the control runs
|
|
2381
|
+
if (all) el.setAttribute('data-rich-text-all', '');
|
|
2382
|
+
el.setAttribute('data-rich-text-control', id);
|
|
2108
2383
|
if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.type = 'button';
|
|
2109
2384
|
controls.add(el);
|
|
2110
2385
|
|
|
2111
2386
|
const valued = 'value' in el && el.tagName !== 'BUTTON';
|
|
2112
|
-
// Never take the caret
|
|
2387
|
+
// Never take the caret
|
|
2113
2388
|
const disabled = () => el.getAttribute('aria-disabled') === 'true';
|
|
2114
2389
|
el.addEventListener('mousedown', e => { if (!valued || disabled()) e.preventDefault(); });
|
|
2115
2390
|
if (valued) el.addEventListener('click', e => { if (disabled()) e.preventDefault(); });
|
|
2116
2391
|
if (valued) {
|
|
2117
|
-
const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el.
|
|
2392
|
+
const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._rt); };
|
|
2118
2393
|
if (el.type === 'color') {
|
|
2119
|
-
//
|
|
2120
|
-
// (recorded once, so undo removes the whole pass in one step) and
|
|
2121
|
-
// every later tick restyles those same spans. Works with the
|
|
2122
|
-
// browser picker, Manifest's, or anything that fires `input`.
|
|
2394
|
+
// Live preview: the first tick wraps (one undo step), later ticks restyle the same spans
|
|
2123
2395
|
const prop = id === 'background' ? 'backgroundColor' : 'color';
|
|
2124
2396
|
let live = null;
|
|
2125
|
-
// A new selection elsewhere ends the pass: without this, a
|
|
2126
|
-
// picker that never fires `change` keeps recolouring the OLD
|
|
2127
|
-
// spans after the writer has selected different text.
|
|
2128
2397
|
const dropLive = () => {
|
|
2129
2398
|
if (!live) return;
|
|
2130
2399
|
const r = range();
|
|
2131
2400
|
if (!r || !live.some(sp => sp.isConnected && sp.contains(r.startContainer))) live = null;
|
|
2132
2401
|
};
|
|
2133
|
-
document.addEventListener('text
|
|
2134
|
-
cleanup(() => document.removeEventListener('text
|
|
2402
|
+
document.addEventListener('rich-text:selection', dropLive);
|
|
2403
|
+
cleanup(() => document.removeEventListener('rich-text:selection', dropLive));
|
|
2135
2404
|
el.addEventListener('input', () => {
|
|
2136
2405
|
const a = resolve(el); if (!a) return;
|
|
2137
|
-
if (el.
|
|
2406
|
+
if (el._rt.all) { run(a, id, el.value, el._rt); return; }
|
|
2138
2407
|
if (live && live.length && live.every(s => s.isConnected)) {
|
|
2139
2408
|
live.forEach(s => { s.style[prop] = el.value; });
|
|
2140
2409
|
a.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2141
2410
|
return;
|
|
2142
2411
|
}
|
|
2143
|
-
if (!allows(a, id, el.
|
|
2412
|
+
if (!allows(a, id, el._rt)) return;
|
|
2144
2413
|
restoreRange(a);
|
|
2145
2414
|
const r = range(); if (!r || !a.contains(r.startContainer)) return;
|
|
2146
2415
|
record(a);
|
|
@@ -2149,7 +2418,7 @@
|
|
|
2149
2418
|
a.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2150
2419
|
});
|
|
2151
2420
|
el.addEventListener('change', () => {
|
|
2152
|
-
if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; }
|
|
2421
|
+
if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; }
|
|
2153
2422
|
fire();
|
|
2154
2423
|
});
|
|
2155
2424
|
} else {
|
|
@@ -2157,89 +2426,149 @@
|
|
|
2157
2426
|
}
|
|
2158
2427
|
el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
|
|
2159
2428
|
} else {
|
|
2160
|
-
el.addEventListener('pointerdown', e => e.preventDefault());
|
|
2161
|
-
el.addEventListener('click', e => { e.preventDefault(); const a = resolve(el); if (a) run(a, id, arg(), el.
|
|
2429
|
+
el.addEventListener('pointerdown', e => e.preventDefault());
|
|
2430
|
+
el.addEventListener('click', e => { e.preventDefault(); const a = resolve(el); if (a) run(a, id, arg(), el._rt); });
|
|
2162
2431
|
}
|
|
2163
2432
|
setTimeout(sync, 0);
|
|
2164
2433
|
cleanup(() => { controls.delete(el); });
|
|
2165
2434
|
}
|
|
2166
2435
|
|
|
2167
|
-
function
|
|
2168
|
-
|
|
2169
|
-
const
|
|
2170
|
-
modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text
|
|
2436
|
+
function areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
|
|
2437
|
+
if (el._rt) return;
|
|
2438
|
+
const format = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
|
|
2439
|
+
modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Rich Text] unknown modifier: .${m}${suggest(m)}`));
|
|
2171
2440
|
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2441
|
+
let last = null, setValue = null;
|
|
2442
|
+
const core = setup(el, {
|
|
2443
|
+
format, inline: modifiers.includes('inline'), literal: modifiers.includes('literal'),
|
|
2444
|
+
bound: !!expression, controlled: !!expression,
|
|
2445
|
+
onChange: expression ? (v) => { last = v; setValue(() => { }, { scope: { __richTextValue: v } }); } : null
|
|
2446
|
+
});
|
|
2447
|
+
if (expression) {
|
|
2448
|
+
const getValue = evaluateLater(expression);
|
|
2449
|
+
setValue = evaluateLater(`${expression} = __richTextValue`);
|
|
2450
|
+
effect(() => getValue(v => {
|
|
2451
|
+
const s = v == null ? '' : String(v);
|
|
2452
|
+
if (s === last) return; // our own write coming back
|
|
2453
|
+
last = s;
|
|
2454
|
+
core.load(s);
|
|
2455
|
+
}));
|
|
2456
|
+
}
|
|
2457
|
+
if (modifiers.includes('autofocus')) setTimeout(() => el.focus(), 0);
|
|
2458
|
+
cleanup(core.destroy);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/* ---- Area engine ----
|
|
2462
|
+
Shared by the directive and ManifestRichText.attach(). */
|
|
2463
|
+
function setup(el, { format = 'markdown', inline: single = false, breaks = false, all: allStyles = true, literal = false, bound = false, controlled = false, onChange = null } = {}) {
|
|
2464
|
+
// .inline html with breaks: one block of lines; <br> kept, Shift+Enter makes one
|
|
2465
|
+
const lines = single && breaks && format === 'html';
|
|
2466
|
+
const flat = (html) => lines ? breakLines(html, true) : flattenLines(html, true);
|
|
2467
|
+
const ac = new AbortController();
|
|
2468
|
+
const on = (type, fn) => el.addEventListener(type, fn, { signal: ac.signal });
|
|
2469
|
+
const prior = ['contenteditable', 'role', 'aria-multiline'].map(n => [n, el.getAttribute(n)]);
|
|
2470
|
+
const priorAll = Object.values(ALL_VARS).map(v => [v, el.style.getPropertyValue(v)]);
|
|
2471
|
+
|
|
2472
|
+
el.setAttribute('data-rich-text', format);
|
|
2473
|
+
// Tells x-edit whether the value already has an owner
|
|
2474
|
+
el.toggleAttribute('data-rich-text-bound', bound);
|
|
2475
|
+
el.toggleAttribute('data-rich-text-inline', single);
|
|
2476
|
+
el.setAttribute('contenteditable', format === 'plain' && PLAINTEXT_ONLY ? 'plaintext-only' : 'true');
|
|
2178
2477
|
el.setAttribute('role', 'textbox');
|
|
2179
|
-
el.setAttribute('aria-multiline',
|
|
2180
|
-
if (minimal) el.setAttribute('data-text-edit-minimal', '');
|
|
2478
|
+
el.setAttribute('aria-multiline', String(!single || lines));
|
|
2181
2479
|
|
|
2182
|
-
const read = () =>
|
|
2183
|
-
|
|
2184
|
-
|
|
2480
|
+
const read = () => {
|
|
2481
|
+
if (format === 'plain') return single ? el.innerText.replace(/\s*\n\s*/g, ' ').trim() : el.innerText.replace(/\n{3,}/g, '\n\n').trim();
|
|
2482
|
+
if (single) return format === 'html' ? flat(el.innerHTML) : inlineMarkdown(el);
|
|
2483
|
+
return format === 'html' ? sanitize(el.innerHTML, true, true) : toMarkdown(el);
|
|
2484
|
+
};
|
|
2185
2485
|
const write = (v) => {
|
|
2186
|
-
if (
|
|
2187
|
-
|
|
2486
|
+
if (format === 'plain') { el.textContent = single ? flatText(v).trim() : v == null ? '' : String(v); return; }
|
|
2487
|
+
if (single) { el.innerHTML = format === 'html' ? flat(v) : sanitize(inlineHtml(flatText(v).trim()), false); return; }
|
|
2488
|
+
el.innerHTML = format === 'html' ? sanitize(v, true, true) : markdownHtml(v);
|
|
2188
2489
|
if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
|
|
2189
2490
|
normalizeLists(el);
|
|
2190
2491
|
if (el.querySelector('table')) normalizeTables(el);
|
|
2191
2492
|
ensureTrailingBlock(el);
|
|
2192
2493
|
};
|
|
2193
2494
|
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2495
|
+
// Single-line insertion: clipboard or drop data flattened, at a range when given
|
|
2496
|
+
const htmlText = (html) => { const t = document.createElement('template'); t.innerHTML = html || ''; return t.content.textContent; };
|
|
2497
|
+
const mdOnly = (html) => format === 'markdown' ? markdownMarks(html) : html;
|
|
2498
|
+
const flatClip = (html, text) => html ? mdOnly(unwrapBareSpans(lines ? breakLines(flattenTables(html, false)) : flattenLines(flattenTables(html, false))))
|
|
2499
|
+
: lines ? textLines(text) : sanitize(inlineHtml(flatText(text).trim()), false);
|
|
2500
|
+
const multiline = (html, text) => {
|
|
2501
|
+
if (!html) return /[\r\n]/.test(text || '');
|
|
2502
|
+
const t = document.createElement('template'); t.innerHTML = html;
|
|
2503
|
+
return !!t.content.querySelector(BLOCK_SEL + ',br,div');
|
|
2504
|
+
};
|
|
2505
|
+
const insertFlat = (html, at, text) => {
|
|
2506
|
+
if (at) { const s = sel(); s.removeAllRanges(); s.addRange(at); }
|
|
2507
|
+
if (format === 'plain') return insertPlain(el, flatText(text != null ? text : htmlText(html)).trim());
|
|
2508
|
+
const t = document.createElement('template');
|
|
2509
|
+
t.innerHTML = html;
|
|
2510
|
+
insertNodes(el, t.content);
|
|
2511
|
+
};
|
|
2512
|
+
|
|
2513
|
+
let writing = false;
|
|
2514
|
+
const commit =() => { if (onChange) onChange(read()); };
|
|
2515
|
+
on('input', () => { if (!writing) commit(); });
|
|
2516
|
+
|
|
2517
|
+
// Uncontrolled: the markup already there is the initial content
|
|
2518
|
+
if (!controlled && format !== 'plain') {
|
|
2519
|
+
if (single) { if (el.querySelector(BLOCK_SEL)) el.innerHTML = flat(el.innerHTML); }
|
|
2520
|
+
else {
|
|
2521
|
+
if (el.firstChild && ![...el.children].some(c => BLOCK.includes(c.tagName))) el.innerHTML = `<p>${el.innerHTML}</p>`;
|
|
2522
|
+
if (!el.firstChild) el.innerHTML = '<p><br></p>';
|
|
2523
|
+
}
|
|
2215
2524
|
}
|
|
2216
2525
|
|
|
2217
2526
|
el._history = { stack: [{ html: el.innerHTML, caret: 0 }], at: 0, timer: null, restoring: false };
|
|
2527
|
+
const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0; };
|
|
2218
2528
|
|
|
2219
|
-
//
|
|
2220
|
-
// opened with a heading keeps making headings. Reset to a paragraph, and flag
|
|
2221
|
-
// emptiness for the placeholder — :empty never matches the <br> a
|
|
2222
|
-
// contenteditable keeps, and the tag it sits in is not predictable.
|
|
2529
|
+
// Empty flag for the placeholder; a wiped document restarts as a paragraph
|
|
2223
2530
|
function settle() {
|
|
2531
|
+
if (single) {
|
|
2532
|
+
// Backstop: whatever got a block or break in is flattened, caret kept
|
|
2533
|
+
if (el.querySelector(lines ? BLOCK_SEL : BLOCK_SEL + ',br')) {
|
|
2534
|
+
const off = offsetIn(el), html = flat(el.innerHTML);
|
|
2535
|
+
if (format === 'plain') { const t = document.createElement('template'); t.innerHTML = html; el.textContent = t.content.textContent; }
|
|
2536
|
+
else el.innerHTML = html;
|
|
2537
|
+
if (off != null && el.textContent.length) caretIn(el, off);
|
|
2538
|
+
}
|
|
2539
|
+
const empty = !el.textContent.trim() && !el.querySelector('img');
|
|
2540
|
+
if (empty && el.firstChild && !el.textContent.length) el.replaceChildren();
|
|
2541
|
+
el.toggleAttribute('data-rich-text-empty', empty);
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2224
2544
|
if (el.querySelector('table')) normalizeTables(el);
|
|
2225
|
-
if (
|
|
2545
|
+
if (format !== 'plain') looseInline(el);
|
|
2546
|
+
ensureTrailingBlock(el);
|
|
2226
2547
|
if (!el.textContent.trim() && !el.querySelector('img, table, hr')) {
|
|
2227
2548
|
if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
|
|
2228
2549
|
if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
|
|
2229
|
-
el.toggleAttribute('data-text-
|
|
2230
|
-
} else el.toggleAttribute('data-text-
|
|
2550
|
+
el.toggleAttribute('data-rich-text-empty', true);
|
|
2551
|
+
} else el.toggleAttribute('data-rich-text-empty', false);
|
|
2231
2552
|
}
|
|
2232
2553
|
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2554
|
+
const load = (v) => {
|
|
2555
|
+
writing = true; write(v); writing = false;
|
|
2556
|
+
resetHistory();
|
|
2557
|
+
settle();
|
|
2558
|
+
};
|
|
2559
|
+
|
|
2560
|
+
const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
|
|
2561
|
+
const enter = (shiftKey) => el.dispatchEvent(new CustomEvent('rich-text:enter', { bubbles: true, detail: { value: read(), shiftKey: !!shiftKey } }));
|
|
2562
|
+
|
|
2563
|
+
// Table borders resize; drags pick cells
|
|
2564
|
+
on('pointermove', (ev) => {
|
|
2237
2565
|
if (ev.buttons || !el.querySelector('table')) return;
|
|
2238
2566
|
const hit = borderAt(ev.clientX, ev.clientY);
|
|
2239
|
-
el.setAttribute('data-text-
|
|
2240
|
-
if (!hit) el.removeAttribute('data-text-
|
|
2567
|
+
el.setAttribute('data-rich-text-border', hit ? hit.axis : '');
|
|
2568
|
+
if (!hit) el.removeAttribute('data-rich-text-border');
|
|
2241
2569
|
});
|
|
2242
|
-
|
|
2570
|
+
on('pointerdown', (ev) => {
|
|
2571
|
+
el._exit = null; el._after = null;
|
|
2243
2572
|
if (ev.button !== 0) return;
|
|
2244
2573
|
if (el.querySelector('table')) {
|
|
2245
2574
|
const hit = borderAt(ev.clientX, ev.clientY);
|
|
@@ -2248,107 +2577,136 @@
|
|
|
2248
2577
|
if (from) cellDrag = { anchor: from, focus: from };
|
|
2249
2578
|
}
|
|
2250
2579
|
});
|
|
2251
|
-
|
|
2252
|
-
el.addEventListener('pointermove', (ev) => {
|
|
2580
|
+
on('pointermove', (ev) => {
|
|
2253
2581
|
if (!cellDrag || !ev.buttons) return;
|
|
2254
2582
|
const over = cellUnder(ev.clientX, ev.clientY);
|
|
2255
2583
|
if (!over || over === cellDrag.focus) return;
|
|
2256
2584
|
cellDrag.focus = over;
|
|
2257
2585
|
paintCells(el);
|
|
2258
2586
|
});
|
|
2259
|
-
|
|
2260
|
-
el.addEventListener('pointerup', () => {
|
|
2587
|
+
on('pointerup', () => {
|
|
2261
2588
|
if (cellDrag && cellDrag.anchor === cellDrag.focus) cellDrag = null;
|
|
2262
2589
|
paintCells(el);
|
|
2263
2590
|
sync();
|
|
2264
2591
|
});
|
|
2265
2592
|
|
|
2266
|
-
//
|
|
2267
|
-
|
|
2268
|
-
// the href and the page changes out from under the writer.
|
|
2269
|
-
el.addEventListener('click', (e) => {
|
|
2593
|
+
// Links are content here, not navigation
|
|
2594
|
+
on('click', (e) => {
|
|
2270
2595
|
const a = e.target && e.target.closest && e.target.closest('a');
|
|
2271
|
-
|
|
2596
|
+
const island = a && a.closest('[contenteditable="false"]');
|
|
2597
|
+
if (a && el.contains(a) && !(island && el.contains(island))) e.preventDefault();
|
|
2272
2598
|
});
|
|
2273
2599
|
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
el.addEventListener('focusout', (e) => {
|
|
2600
|
+
on('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-rich-text-focused', ''); saveRange(el); sync(); });
|
|
2601
|
+
on('focusout', (e) => {
|
|
2277
2602
|
if (keeps(el, e.relatedTarget || pointerTarget)) return;
|
|
2278
2603
|
release(el);
|
|
2279
2604
|
sync();
|
|
2280
2605
|
});
|
|
2281
|
-
|
|
2282
|
-
|
|
2606
|
+
|
|
2607
|
+
const autoformat = format !== 'plain' && !literal;
|
|
2608
|
+
on('input', (e) => {
|
|
2609
|
+
// Mid-composition the IME owns the text
|
|
2283
2610
|
if (!writing && e.data && !e.isComposing) {
|
|
2284
|
-
if (applyPending(el, e.data))
|
|
2285
|
-
if (leaveMark(el, e.data))
|
|
2286
|
-
if (autoformat && convertInlineAtCaret(el))
|
|
2611
|
+
if (applyPending(el, e.data)) commit();
|
|
2612
|
+
if (leaveMark(el, e.data)) commit();
|
|
2613
|
+
if (autoformat && convertInlineAtCaret(el)) commit();
|
|
2287
2614
|
}
|
|
2288
|
-
|
|
2615
|
+
// A deletion of text typed after a mark, back to its edge, is right after the mark again
|
|
2616
|
+
if (el._delOut && /^delete/.test(e.inputType || '') && atMarkEnd(el._after, range())) el._exit = el._after;
|
|
2617
|
+
el._delOut = false;
|
|
2618
|
+
if (!writing && !e.isComposing) relaxSpaces(el, e.inputType === 'insertText' ? e.data : null);
|
|
2619
|
+
if (!writing) recordSoon(el);
|
|
2289
2620
|
settle(); sync();
|
|
2290
2621
|
});
|
|
2291
|
-
|
|
2292
|
-
if (!writing && e.data && applyPending(el, e.data)) {
|
|
2622
|
+
on('compositionend', (e) => {
|
|
2623
|
+
if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
|
|
2624
|
+
else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
|
|
2625
|
+
if (!writing) relaxSpaces(el);
|
|
2293
2626
|
});
|
|
2294
|
-
|
|
2627
|
+
on('change', e => {
|
|
2295
2628
|
if (e.target.type !== 'checkbox') return;
|
|
2296
|
-
//
|
|
2629
|
+
// Only the attribute survives serialization
|
|
2297
2630
|
e.target.toggleAttribute('checked', e.target.checked);
|
|
2298
2631
|
record(el);
|
|
2299
2632
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2300
2633
|
});
|
|
2301
|
-
|
|
2302
|
-
// A finished line resolves to what its markdown described, unless .literal.
|
|
2303
|
-
const autoformat = mode !== 'plain' && !modifiers.includes('literal');
|
|
2304
|
-
el.addEventListener('input', (e) => {
|
|
2634
|
+
on('input', (e) => {
|
|
2305
2635
|
if (writing || e.isComposing) return;
|
|
2306
|
-
if (ancestor('li')) normalizeLists(el);
|
|
2636
|
+
if (ancestor('li')) normalizeLists(el);
|
|
2307
2637
|
});
|
|
2308
2638
|
|
|
2309
|
-
// Native undo
|
|
2310
|
-
|
|
2639
|
+
// Native undo would desync the plugin's stack
|
|
2640
|
+
on('beforeinput', (e) => {
|
|
2311
2641
|
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
2312
2642
|
e.preventDefault();
|
|
2313
2643
|
run(el, e.inputType === 'historyUndo' ? 'undo' : 'redo');
|
|
2314
2644
|
return;
|
|
2315
2645
|
}
|
|
2316
|
-
|
|
2646
|
+
markCaret(el);
|
|
2647
|
+
if (typeAfterMark(el, e)) return;
|
|
2648
|
+
if (/^delete/.test(e.inputType || '')) { const m = el._after, r = range(); el._delOut = !!(m && m.isConnected && r && !m.contains(r.startContainer) && !atMarkEnd(m, r)); }
|
|
2649
|
+
if (lines && e.inputType === 'insertLineBreak') { e.preventDefault(); lineBreak(); return; }
|
|
2650
|
+
if (single && /^insert(Paragraph|LineBreak)$/.test(e.inputType || '')) { e.preventDefault(); enter(false); return; }
|
|
2651
|
+
// Text carrying line breaks (autocorrect, IME, replacement) lands as one line
|
|
2652
|
+
if (single && e.cancelable && /^insert(Text|ReplacementText|CompositionText)$/.test(e.inputType || '')) {
|
|
2653
|
+
const d = e.data != null ? e.data : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
|
|
2654
|
+
if (/[\r\n]/.test(d)) {
|
|
2655
|
+
e.preventDefault();
|
|
2656
|
+
const tr = e.getTargetRanges ? e.getTargetRanges()[0] : null;
|
|
2657
|
+
let at = null;
|
|
2658
|
+
if (tr) { at = document.createRange(); at.setStart(tr.startContainer, tr.startOffset); at.setEnd(tr.endContainer, tr.endOffset); }
|
|
2659
|
+
insertFlat(lines ? textLines(d) : escHtml(flatText(d).trim()), at, d);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
if (format === 'plain' && /^format/.test(e.inputType || '')) e.preventDefault();
|
|
2317
2664
|
});
|
|
2318
2665
|
|
|
2319
|
-
|
|
2666
|
+
// A multi-line drop is flattened at the drop point
|
|
2667
|
+
on('drop', (e) => {
|
|
2668
|
+
const dt = e.dataTransfer; if (!single || !dt) return;
|
|
2669
|
+
const html = dt.getData('text/html'), text = dt.getData('text/plain');
|
|
2670
|
+
if (!multiline(html, text)) return;
|
|
2671
|
+
e.preventDefault();
|
|
2672
|
+
const hit = caretAtPoint(e.clientX, e.clientY);
|
|
2673
|
+
let at = null;
|
|
2674
|
+
if (hit && el.contains(hit.node)) { at = document.createRange(); at.setStart(hit.node, hit.offset); at.collapse(true); }
|
|
2675
|
+
el.focus();
|
|
2676
|
+
insertFlat(flatClip(html, text), at, text || htmlText(html));
|
|
2677
|
+
});
|
|
2320
2678
|
|
|
2321
|
-
|
|
2322
|
-
if (e.isComposing || e.keyCode === 229) return;
|
|
2323
|
-
if (
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
if (e.key === 'Enter' &&
|
|
2327
|
-
if (!ownsEnter(e)) return;
|
|
2679
|
+
on('keydown', (e) => {
|
|
2680
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
2681
|
+
if (e.key === 'Enter' && e.target !== el && e.target.matches && e.target.matches('a[href]') && e.target.closest('[contenteditable="false"]') && el.contains(e.target.closest('[contenteditable="false"]'))) return;
|
|
2682
|
+
if ((e.key || '').length > 1 && !/^(Shift|Alt|Control|Meta|CapsLock)$/.test(e.key)) { el._exit = null; if (!/^(Backspace|Delete)$/.test(e.key)) el._after = null; }
|
|
2683
|
+
if (e.key === 'Enter' && single) { e.preventDefault(); if (lines && e.shiftKey) lineBreak(); else enter(e.shiftKey); return; }
|
|
2684
|
+
if (e.key === 'Enter' && format !== 'plain') {
|
|
2685
|
+
if (!ownsEnter(e)) return;
|
|
2328
2686
|
e.preventDefault();
|
|
2329
2687
|
record(el);
|
|
2330
2688
|
onEnter(el, autoformat);
|
|
2331
2689
|
normalizeLists(el);
|
|
2332
2690
|
record(el);
|
|
2333
|
-
|
|
2691
|
+
commit();
|
|
2334
2692
|
sync();
|
|
2335
2693
|
return;
|
|
2336
2694
|
}
|
|
2337
|
-
if (e.key === 'Backspace' &&
|
|
2695
|
+
if (e.key === 'Backspace' && format !== 'plain' && !single) {
|
|
2338
2696
|
const cell = cellCoversAll();
|
|
2339
2697
|
if (cell) { // clear the cell, keep the table
|
|
2340
2698
|
e.preventDefault(); record(el);
|
|
2341
2699
|
cell.replaceChildren(document.createElement('br'));
|
|
2342
2700
|
caretIn(cell, 0);
|
|
2343
|
-
record(el);
|
|
2701
|
+
record(el); commit(); sync();
|
|
2344
2702
|
return;
|
|
2345
2703
|
}
|
|
2346
2704
|
const r = range();
|
|
2347
2705
|
if (r && r.collapsed && atBlockStart()) {
|
|
2348
2706
|
e.preventDefault(); record(el);
|
|
2349
|
-
backspaceAtStart(
|
|
2707
|
+
backspaceAtStart();
|
|
2350
2708
|
normalizeLists(el);
|
|
2351
|
-
record(el);
|
|
2709
|
+
record(el); commit(); sync();
|
|
2352
2710
|
return;
|
|
2353
2711
|
}
|
|
2354
2712
|
}
|
|
@@ -2356,53 +2714,66 @@
|
|
|
2356
2714
|
if (arrowInTable(e)) { e.preventDefault(); sync(); return; }
|
|
2357
2715
|
return;
|
|
2358
2716
|
}
|
|
2717
|
+
// Tab indents (single-line fields keep native focus order); Escape is the way out
|
|
2359
2718
|
if (e.key === 'Tab') {
|
|
2360
|
-
if (
|
|
2719
|
+
if (format === 'plain' || single) return;
|
|
2361
2720
|
e.preventDefault();
|
|
2362
|
-
if (moveCell(e.shiftKey ? -1 : 1)) return;
|
|
2721
|
+
if (moveCell(e.shiftKey ? -1 : 1)) return;
|
|
2363
2722
|
run(el, e.shiftKey ? 'outdent' : 'indent');
|
|
2364
2723
|
return;
|
|
2365
2724
|
}
|
|
2366
|
-
// Tab indents, so Escape is the way out. Stop it here so the first press
|
|
2367
|
-
// leaves the editor and only a second one closes an enclosing dialog.
|
|
2368
2725
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); return; }
|
|
2369
2726
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
|
|
2370
2727
|
e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
|
|
2371
2728
|
}
|
|
2372
|
-
//
|
|
2373
|
-
|
|
2729
|
+
// Ctrl+Y redoes off the Mac (where it is yank)
|
|
2730
|
+
if (e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'y' && !MAC) {
|
|
2731
|
+
e.preventDefault(); run(el, 'redo'); return;
|
|
2732
|
+
}
|
|
2733
|
+
// No Cmd/Ctrl+K: that chord belongs to site search
|
|
2374
2734
|
if (!(e.metaKey || e.ctrlKey)) return;
|
|
2375
2735
|
const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
|
|
2376
2736
|
if (!id) return;
|
|
2377
|
-
// Never the browser's own formatting
|
|
2737
|
+
// Never the browser's own formatting
|
|
2378
2738
|
e.preventDefault();
|
|
2379
2739
|
if (allows(el, id)) run(el, id);
|
|
2380
2740
|
});
|
|
2381
2741
|
|
|
2382
|
-
// Paste as our own subset
|
|
2383
|
-
|
|
2742
|
+
// Paste as our own subset, never the source app's markup or styles
|
|
2743
|
+
on('paste', (e) => {
|
|
2384
2744
|
e.preventDefault();
|
|
2385
2745
|
const dt = e.clipboardData; if (!dt) return;
|
|
2386
|
-
|
|
2746
|
+
const text = dt.getData('text/plain');
|
|
2747
|
+
if (format === 'plain') return insertPlain(el, single ? flatText(text).trim() : text);
|
|
2387
2748
|
const html = dt.getData('text/html');
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2749
|
+
if (single) return insertFlat(flatClip(html, text), null, text);
|
|
2750
|
+
const clip = html ? unwrapBareSpans(format === 'markdown' ? markdownMarks(markdownBlocks(sanitize(flattenTables(html, true), true))) : sanitize(html, true))
|
|
2751
|
+
: markdownHtml(text.replace(/([^\n])\n(?!\n)/g, '$1 \n'));
|
|
2752
|
+
// Inline content (or a single paragraph of it) goes in at the caret, inside the caret's block,
|
|
2753
|
+
// leading space and formatting kept; the browser's insertHTML would place it beside the block
|
|
2754
|
+
const t = document.createElement('template'); t.innerHTML = clip;
|
|
2755
|
+
const only = t.content.children.length === 1 && t.content.firstElementChild.tagName === 'P' && !t.content.textContent.replace(t.content.firstElementChild.textContent, '').trim() ? t.content.firstElementChild : null;
|
|
2756
|
+
if (only) t.innerHTML = only.innerHTML;
|
|
2757
|
+
if (!t.content.querySelector(BLOCK_SEL)) { clearSpan(el); return insertNodes(el, t.content); }
|
|
2758
|
+
// Its own undo step: a pending burst lands first, the paste records after
|
|
2759
|
+
record(el);
|
|
2760
|
+
insertHTML(clip);
|
|
2761
|
+
record(el);
|
|
2392
2762
|
});
|
|
2393
2763
|
|
|
2394
2764
|
const api = {
|
|
2395
2765
|
get value() { return read(); },
|
|
2766
|
+
// The value it already holds keeps the markup and caret; history restarts either way
|
|
2396
2767
|
set value(v) {
|
|
2397
|
-
write(v);
|
|
2398
|
-
|
|
2768
|
+
if (v !== read()) write(v);
|
|
2769
|
+
resetHistory();
|
|
2399
2770
|
settle();
|
|
2400
2771
|
el._history.restoring = true;
|
|
2401
2772
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2402
2773
|
el._history.restoring = false;
|
|
2403
2774
|
},
|
|
2404
|
-
get
|
|
2405
|
-
set
|
|
2775
|
+
get all() { return allStyle(el); },
|
|
2776
|
+
set all(v) { if (!allStyles) return; setAllStyle(el, v); sync(); },
|
|
2406
2777
|
get selection() { return el._selection || null; },
|
|
2407
2778
|
get link() { return linkHref(); },
|
|
2408
2779
|
set link(v) { run(el, 'a', v); },
|
|
@@ -2411,21 +2782,67 @@
|
|
|
2411
2782
|
active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
|
|
2412
2783
|
can: (id) => allows(el, id),
|
|
2413
2784
|
selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
|
|
2414
|
-
|
|
2415
|
-
|
|
2785
|
+
// The newest step in this field's history (a pending typing burst lands first)
|
|
2786
|
+
newest: () => { const h = el._history; if (h.timer || h.stack[h.at].html !== el.innerHTML) record(el); return h.stack[h.stack.length - 1].html; },
|
|
2787
|
+
markdown: () => single ? inlineMarkdown(el) : toMarkdown(el),
|
|
2788
|
+
html: () => sanitize(el.innerHTML, !single, true)
|
|
2416
2789
|
};
|
|
2417
|
-
|
|
2790
|
+
// $rich.strong(), $rich.table(3, 2)
|
|
2791
|
+
Object.keys(COMMANDS).forEach(id => { api[id] = (...args) => run(el, id, COMMANDS[id].args > 1 ? args : args[0]); });
|
|
2792
|
+
|
|
2793
|
+
el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
|
|
2418
2794
|
areas.add(el);
|
|
2419
2795
|
settle();
|
|
2420
|
-
|
|
2421
|
-
if (modifiers.includes('autofocus')) setTimeout(() => el.focus(), 0);
|
|
2796
|
+
el._history.stack[0].html = el.innerHTML;
|
|
2422
2797
|
setTimeout(sync, 0);
|
|
2423
2798
|
|
|
2424
|
-
|
|
2799
|
+
const destroy = () => {
|
|
2800
|
+
if (!areas.has(el)) return;
|
|
2801
|
+
ac.abort();
|
|
2802
|
+
clearTimeout(el._history && el._history.timer);
|
|
2803
|
+
if (lastFocused === el) release(el);
|
|
2804
|
+
areas.delete(el);
|
|
2805
|
+
prior.forEach(([n, v]) => v == null ? el.removeAttribute(n) : el.setAttribute(n, v));
|
|
2806
|
+
[...el.attributes].forEach(a => { if (a.name.startsWith('data-rich-text')) el.removeAttribute(a.name); });
|
|
2807
|
+
el.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
|
|
2808
|
+
SELECTION_VARS.forEach(v => el.style.removeProperty(v));
|
|
2809
|
+
priorAll.forEach(([v, was]) => { if (was) el.style.setProperty(v, was); else el.style.removeProperty(v); });
|
|
2810
|
+
if (!areas.size) {
|
|
2811
|
+
SELECTION_VARS.forEach(v => document.documentElement.style.removeProperty(v));
|
|
2812
|
+
document.documentElement.removeAttribute('data-rich-text-selected');
|
|
2813
|
+
if (document.documentElement.getAttribute('style') === '') document.documentElement.removeAttribute('style');
|
|
2814
|
+
}
|
|
2815
|
+
if (el.getAttribute('style') === '') el.removeAttribute('style');
|
|
2816
|
+
['_rt', '_history', '_range', '_pending', '_exit', '_after', '_nb', '_delOut', '_selection'].forEach(k => { delete el[k]; });
|
|
2817
|
+
sync();
|
|
2818
|
+
};
|
|
2819
|
+
|
|
2820
|
+
return { api, load, destroy };
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/* ---- Engine API ---- */
|
|
2824
|
+
const FORMATS = new Set(['markdown', 'html', 'plain']);
|
|
2825
|
+
function attach(el, opts = {}) {
|
|
2826
|
+
if (!el || el.nodeType !== 1) return null;
|
|
2827
|
+
if (el._rt) return el._rt.handle || null;
|
|
2828
|
+
listen();
|
|
2829
|
+
const controlled = opts.value !== undefined;
|
|
2830
|
+
const core = setup(el, {
|
|
2831
|
+
format: FORMATS.has(opts.format) ? opts.format : 'markdown',
|
|
2832
|
+
inline: opts.mode === 'inline', breaks: !!opts.breaks, all: opts.all !== false, literal: !!opts.literal,
|
|
2833
|
+
bound: typeof opts.onChange === 'function', controlled,
|
|
2834
|
+
onChange: typeof opts.onChange === 'function' ? opts.onChange : null
|
|
2835
|
+
});
|
|
2836
|
+
if (controlled) core.load(opts.value);
|
|
2837
|
+
const handle = Object.create(core.api);
|
|
2838
|
+
handle.el = el;
|
|
2839
|
+
handle.detach = core.destroy;
|
|
2840
|
+
el._rt.handle = handle;
|
|
2841
|
+
return handle;
|
|
2425
2842
|
}
|
|
2426
2843
|
|
|
2427
2844
|
document.addEventListener('alpine:init', init);
|
|
2428
2845
|
if (window.Alpine && Alpine.directive) init();
|
|
2429
2846
|
|
|
2430
|
-
window.
|
|
2847
|
+
window.ManifestRichText = { toMarkdown, fromMarkdown: markdownHtml, sanitize, commands: () => Object.keys(COMMANDS), attach };
|
|
2431
2848
|
})();
|