mnfst 0.5.231 → 0.5.233
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 +172 -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 +2396 -1082
- package/lib/manifest.integrity.json +10 -10
- package/lib/manifest.js +160 -25
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.rich.text.css +253 -0
- package/lib/manifest.rich.text.js +3231 -0
- 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
- package/lib/manifest.text.edit.js +0 -2431
|
@@ -0,0 +1,3231 @@
|
|
|
1
|
+
/* Manifest Rich Text */
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
|
|
5
|
+
/* ---- Tag tables ----
|
|
6
|
+
Commands are tag names; `md` marks tags markdown can carry (others are .html only). */
|
|
7
|
+
const INLINE_TAGS = {
|
|
8
|
+
strong: { md: 1 }, b: { md: 1 }, em: { md: 1 }, i: { md: 1 }, s: { md: 1 }, del: { md: 1 }, code: { md: 1 },
|
|
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: {}
|
|
12
|
+
};
|
|
13
|
+
const BLOCK_TAGS = {
|
|
14
|
+
p: { md: 1 }, h1: { md: 1 }, h2: { md: 1 }, h3: { md: 1 }, h4: { md: 1 }, h5: { md: 1 }, h6: { md: 1 },
|
|
15
|
+
blockquote: { md: 1 }, pre: { md: 1 }, address: {}
|
|
16
|
+
};
|
|
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(',');
|
|
19
|
+
const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
|
|
20
|
+
const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
|
|
21
|
+
|
|
22
|
+
/* ---- Adjacent marks ----
|
|
23
|
+
Neighbouring inline elements alike (same tag and attributes; for markdown, the same
|
|
24
|
+
delimiter) are one: merged, children first, the selection kept on the nodes it was in. */
|
|
25
|
+
const sameInline = (a, b) => !!b && b.nodeType === 1 && a.tagName === b.tagName
|
|
26
|
+
&& a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
|
|
27
|
+
const sameDelimiter = (a, b) => !!b && b.nodeType === 1 && (a.tagName === 'A' ? sameInline(a, b) : !!MARKS[a.tagName] && MARKS[a.tagName] === MARKS[b.tagName]);
|
|
28
|
+
function mergeAdjacent(root, same = sameInline) {
|
|
29
|
+
const r = root.ownerDocument === document && root.isConnected ? range() : null;
|
|
30
|
+
const keep = r && root.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
|
|
31
|
+
let any = false;
|
|
32
|
+
const walk = (p) => {
|
|
33
|
+
let merged = false;
|
|
34
|
+
[...p.children].forEach(walk);
|
|
35
|
+
for (let c = p.firstChild; c; c = c.nextSibling) {
|
|
36
|
+
while (c.nodeType === 1 && INLINE_OK.has(c.tagName) && !/^(BR|WBR|IMG)$/.test(c.tagName)) {
|
|
37
|
+
const gaps = [];
|
|
38
|
+
let n = c.nextSibling;
|
|
39
|
+
while (n && n.nodeType === 3 && !n.length) { gaps.push(n); n = n.nextSibling; } // empty text between them is nothing
|
|
40
|
+
if (!same(c, n)) break;
|
|
41
|
+
const base = c.childNodes.length;
|
|
42
|
+
if (keep) for (const i of [0, 2]) { if (gaps.includes(keep[i])) { keep[i] = c; keep[i + 1] = base; } else if (keep[i] === n) { keep[i] = c; keep[i + 1] += base; } }
|
|
43
|
+
gaps.forEach(g => g.remove()); c.append(...n.childNodes); n.remove(); merged = any = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (merged) walk(p);
|
|
47
|
+
};
|
|
48
|
+
walk(root);
|
|
49
|
+
if (any && 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 { }
|
|
50
|
+
return any;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ---- URL policy ---- */
|
|
54
|
+
// No script schemes; data: only as an image.
|
|
55
|
+
function unsafeUrl(url, image) {
|
|
56
|
+
const u = String(url == null ? '' : url).replace(/[\x00-\x20\x7f]/g, '');
|
|
57
|
+
const scheme = (u.match(/^([a-z][a-z0-9+.-]*):/i) || [])[1];
|
|
58
|
+
if (!scheme) return false;
|
|
59
|
+
const s = scheme.toLowerCase();
|
|
60
|
+
if (s === 'javascript' || s === 'vbscript') return true;
|
|
61
|
+
return s === 'data' && !(image && /^data:image\/(png|jpeg|gif|webp|avif)[;,]/i.test(u)); // raster only: an SVG can carry script
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ---- HTML → Markdown ---- */
|
|
65
|
+
const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
|
|
66
|
+
const MD_PUNCT = /[!-\/:-@\[-`{-~\u2000-\u206f\u2e00-\u2e7f]/;
|
|
67
|
+
// An _ between letters or digits cannot open or close emphasis: it is left as written
|
|
68
|
+
const esc = (t) => t.replace(/[\\`*_~[\]<>]/g, (c, i) => c === '_' && /[\p{L}\p{N}]/u.test(t[i - 1] || '') && /[\p{L}\p{N}]/u.test(t[i + 1] || '') ? c : '\\' + c).replace(ENTITY, '\\&');
|
|
69
|
+
// Line starts that would parse as a heading, list or rule
|
|
70
|
+
const escLead = (text) => text.split('\n').map(l => l
|
|
71
|
+
.replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
|
|
72
|
+
.replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
|
|
73
|
+
.replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
|
|
74
|
+
.replace(/^(\s*)(\d+)\.(?=\s)/, '$1$2\\.')).join('\n');
|
|
75
|
+
// A link destination that reads back to the same URL: spaces and angle brackets encoded,
|
|
76
|
+
// backslashes and unbalanced parentheses escaped (balanced ones stay, as CommonMark reads them)
|
|
77
|
+
const mdUrl = (u) => {
|
|
78
|
+
const s = String(u).replace(/[\s<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
79
|
+
const open = [], loose = new Set();
|
|
80
|
+
for (let i = 0; i < s.length; i++) { if (s[i] === '(') open.push(i); else if (s[i] === ')') { if (open.length) open.pop(); else loose.add(i); } }
|
|
81
|
+
open.forEach(i => loose.add(i));
|
|
82
|
+
return s.split('').map((c, i) => c === '\\' ? '\\\\' : loose.has(i) ? '\\' + c : c).join('').replace(ENTITY, '\\&');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// `ctx`: the characters just outside this content, and whether an `_` mark encloses it.
|
|
86
|
+
// em/strong use _/__ where no word character is outside them (and none encloses them), so
|
|
87
|
+
// their delimiters cannot pair with an inner * run; *, ** otherwise.
|
|
88
|
+
const wordChar = (c) => !!c && !/\s/.test(c) && !MD_PUNCT.test(c);
|
|
89
|
+
const textEdge = (n, last) => {
|
|
90
|
+
if (!n) return null;
|
|
91
|
+
if (n.nodeType === 3) return n.nodeValue ? (last ? n.nodeValue[n.nodeValue.length - 1] : n.nodeValue[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
|
|
92
|
+
if (n.nodeType !== 1) return textEdge(last ? n.previousSibling : n.nextSibling, last);
|
|
93
|
+
if (n.tagName === 'BR') return '\n';
|
|
94
|
+
if (n.tagName === 'IMG') return last ? ')' : '!';
|
|
95
|
+
if (MARKS[n.tagName] || n.tagName === 'A') return last ? '*' : '*'; // a delimiter or bracket: punctuation
|
|
96
|
+
const t = n.textContent; return t ? (last ? t[t.length - 1] : t[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
|
|
97
|
+
};
|
|
98
|
+
// A * mark inside with text right against it: its delimiters could close an enclosing * mark
|
|
99
|
+
const riskyInside = (n) => [...n.querySelectorAll('strong, b, em, i')].some(d => {
|
|
100
|
+
const before = d.previousSibling ? textEdge(d.previousSibling, true) : null, after = d.nextSibling ? textEdge(d.nextSibling, false) : null;
|
|
101
|
+
return (before != null && !/\s/.test(before)) || (after != null && !/\s/.test(after));
|
|
102
|
+
});
|
|
103
|
+
function inlineRaw(node, ctx) {
|
|
104
|
+
let out = '';
|
|
105
|
+
node.childNodes.forEach(n => {
|
|
106
|
+
if (n.nodeType === 3) { out += esc(n.textContent); return; }
|
|
107
|
+
if (n.nodeType !== 1) return;
|
|
108
|
+
const tag = n.tagName;
|
|
109
|
+
if (tag === 'BR') { out += ' \n'; return; }
|
|
110
|
+
if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src && !unsafeUrl(src, true) ? `})` : ''; return; }
|
|
111
|
+
if (tag === 'A' && /(^|[^\\])!$/.test(out) && n.getAttribute('href')) out = out.slice(0, -1) + '\\!'; // not an image
|
|
112
|
+
if (tag === 'A' && !n.hasAttribute('title') && n.children.length === 0 && /^[a-z][a-z0-9+.-]{1,31}:[^\s<>]*$/i.test(n.getAttribute('href') || '') && n.textContent === n.getAttribute('href') && !unsafeUrl(n.textContent)) { out += '<' + n.textContent + '>'; return; }
|
|
113
|
+
if (tag === 'A') {
|
|
114
|
+
const h = n.getAttribute('href') || '', title = n.getAttribute('title');
|
|
115
|
+
const t = title != null ? ` "${title.replace(/[\\"]/g, '\\$&').replace(/\n/g, ' ')}"` : '';
|
|
116
|
+
out += h && !unsafeUrl(h) ? `[${inlineRaw(n, { ...ctx, prev: '[', next: ']', under: ctx.under, star: ctx.star })}](${mdUrl(h)}${t})` : inlineRaw(n, ctx);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let m = MARKS[tag];
|
|
120
|
+
if (!m) { out += inlineRaw(n, ctx); return; }
|
|
121
|
+
if (tag === 'CODE') {
|
|
122
|
+
// A fence longer than any backtick run inside; padded where the code starts or ends with one
|
|
123
|
+
const code = n.textContent; if (!code) return;
|
|
124
|
+
const fence = '`'.repeat(Math.max(0, ...(code.match(/`+/g) || []).map(r => r.length)) + 1);
|
|
125
|
+
const pad = /^`|`$/.test(code) || (/^ /.test(code) && / $/.test(code) && code.trim()) ? ' ' : '';
|
|
126
|
+
out += fence + pad + code + pad + fence;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!n.textContent.trim()) { out += inlineRaw(n, ctx); return; }
|
|
130
|
+
let body = inlineRaw(n, { ...ctx, prev: m[0], next: m[0], under: ctx.under });
|
|
131
|
+
// Delimiters hug the text: spaces go outside
|
|
132
|
+
let [, lead, core, trail] = body.match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
133
|
+
// _ where * would touch text (it could pair with an enclosing *), or encloses a mark that does
|
|
134
|
+
const after = textEdge(n.nextSibling, false);
|
|
135
|
+
const prev = lead ? ' ' : (out ? out[out.length - 1] : ctx.prev), next = trail ? ' ' : (after == null ? ctx.next : after);
|
|
136
|
+
const nonspace = /\S/.test(prev || '') || /\S/.test(next || '');
|
|
137
|
+
if (m[0] === '*' && (ctx.html === 'all' || (ctx.html === 'touch' && nonspace))) {
|
|
138
|
+
const t = tag === 'STRONG' || tag === 'B' ? 'strong' : 'em';
|
|
139
|
+
[, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '>', next: '<', star: false }).match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
140
|
+
out += lead + `<${t}>` + core + `</${t}>` + trail;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const allowed = m[0] === '*' && !ctx.under && !wordChar(prev) && !wordChar(next) && (!!lead || !/(^|[^\\])_$/.test(out));
|
|
144
|
+
// * unless it would not read back: right after another * run, around a * mark with text
|
|
145
|
+
// against it, or with text against it inside a * mark
|
|
146
|
+
const touching = (!lead && /\S$/.test(out)) || (!trail && after != null && /\S/.test(after));
|
|
147
|
+
if (allowed && ((!lead && /(^|[^\\])\*$/.test(out)) || riskyInside(n) || (ctx.star && touching))) {
|
|
148
|
+
m = m.replace(/\*/g, '_');
|
|
149
|
+
[, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '_', next: '_', under: true, star: ctx.star }).match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
150
|
+
} else if (m[0] === '*' && ctx.star && touching) {
|
|
151
|
+
// Inside a * mark with text against it, a * run could close the enclosing one: no
|
|
152
|
+
// markdown form says this, so it is written as HTML (which markdown carries)
|
|
153
|
+
const t = tag === 'STRONG' || tag === 'B' ? 'strong' : 'em';
|
|
154
|
+
[, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '>', next: '<', under: ctx.under, star: false }).match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
155
|
+
out += lead + `<${t}>` + core + `</${t}>` + trail;
|
|
156
|
+
return;
|
|
157
|
+
} else if (m[0] === '*') [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '*', next: '*', under: ctx.under, star: true }).match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
158
|
+
out += lead + m + core + m + trail;
|
|
159
|
+
});
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Inline markdown for a node, checked by reading it back: in the rare arrangement where a
|
|
164
|
+
// * or _ run would pair differently, em/strong touching text go as HTML, else all of them
|
|
165
|
+
function inline(node) {
|
|
166
|
+
const want = shapeOf(node), ctx = { prev: '', next: '', under: false, star: false, html: '' };
|
|
167
|
+
let first = null;
|
|
168
|
+
for (const html of ['', 'touch', 'all']) {
|
|
169
|
+
const md = inlineRaw(node, { ...ctx, html });
|
|
170
|
+
if (first == null) first = md;
|
|
171
|
+
const t = document.createElement('template'); t.innerHTML = inlineHtml(md);
|
|
172
|
+
if (shapeOf(t.content) === want) return md;
|
|
173
|
+
}
|
|
174
|
+
return first; // what no form can carry (an unsafe link, say) is not a reason to change notation
|
|
175
|
+
}
|
|
176
|
+
// Every character with the formatting it shows (strong/em/strike on spaces show nothing)
|
|
177
|
+
function shapeOf(root) {
|
|
178
|
+
const out = [], KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', S: 's', DEL: 's', CODE: 'c' };
|
|
179
|
+
const walk = (n, f) => n.childNodes.forEach(c => {
|
|
180
|
+
if (c.nodeType === 3) { for (const ch of c.nodeValue) out.push(ch + (/\s/.test(ch) ? f.filter(x => x[0] !== 'b' && x[0] !== 'i' && x[0] !== 's') : f).join()); return; }
|
|
181
|
+
if (c.nodeType !== 1) return;
|
|
182
|
+
if (c.tagName === 'BR') { out.push('\n'); return; }
|
|
183
|
+
if (c.tagName === 'IMG') { const src = c.getAttribute('src') || ''; if (src && !unsafeUrl(src, true)) out.push('img:' + src + '|' + (c.getAttribute('alt') || '')); return; }
|
|
184
|
+
const href = c.tagName === 'A' ? c.getAttribute('href') || '' : '';
|
|
185
|
+
const k = KIND[c.tagName], h = href && !unsafeUrl(href) ? 'a:' + href : null;
|
|
186
|
+
walk(c, [...new Set([...f, ...(k ? [k] : []), ...(h ? [h] : [])])].sort());
|
|
187
|
+
});
|
|
188
|
+
walk(root, []);
|
|
189
|
+
return out.join('\u0001').replace(/^(\s(\u0001|$))+|(\u0001\s)+$/g, '');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const preText = (n) => [...n.childNodes].map(c =>
|
|
193
|
+
c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
|
|
194
|
+
|
|
195
|
+
function block(node, depth) {
|
|
196
|
+
if (node.nodeType === 3) { const t = node.textContent.trim(); return t ? escLead(esc(t)) : ''; }
|
|
197
|
+
if (node.nodeType !== 1) return '';
|
|
198
|
+
const tag = node.tagName;
|
|
199
|
+
if (tag === 'HR') return '---';
|
|
200
|
+
if (tag === 'PRE') return '```' + (node.getAttribute('data-lang') || '').replace(/[^\w+#.-]/g, '') + '\n' + preText(node).replace(/\n+$/, '') + '\n```';
|
|
201
|
+
if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
|
|
202
|
+
if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
|
|
203
|
+
if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
|
|
204
|
+
if (tag === 'UL' || tag === 'OL') {
|
|
205
|
+
const pad = ' '.repeat(depth);
|
|
206
|
+
return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
|
|
207
|
+
const box = li.querySelector(':scope > input[type=checkbox]');
|
|
208
|
+
const bullet = tag === 'OL' ? `${i + 1}. ` : box ? (box.checked ? '- [x] ' : '- [ ] ') : '- ';
|
|
209
|
+
const nested = [...li.children].filter(c => c.tagName === 'UL' || c.tagName === 'OL');
|
|
210
|
+
const own = inline(nestedStripped(li, nested)).trim();
|
|
211
|
+
const sub = nested.map(n => block(n, depth + 1)).join('\n');
|
|
212
|
+
return pad + bullet + own + (sub ? '\n' + sub : '');
|
|
213
|
+
}).join('\n');
|
|
214
|
+
}
|
|
215
|
+
// A container of blocks (dl, figure, a cell's paragraphs) keeps them apart
|
|
216
|
+
if ([...node.children].some(c => BLOCK.includes(c.tagName))) return blocks(node, depth);
|
|
217
|
+
return escLead(inline(node).trim());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function nestedStripped(li, nested) {
|
|
221
|
+
if (!nested.length) return li;
|
|
222
|
+
const c = li.cloneNode(true);
|
|
223
|
+
[...c.children].forEach(ch => { if (ch.tagName === 'UL' || ch.tagName === 'OL') ch.remove(); });
|
|
224
|
+
return c;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Consecutive inline nodes (loose text, marks) are one paragraph, formatting kept
|
|
228
|
+
function blocks(root, depth) {
|
|
229
|
+
const parts = [];
|
|
230
|
+
let run = null;
|
|
231
|
+
const flush = () => { if (run) { const t = escLead(inline(run).trim()); if (t) parts.push(t); run = null; } };
|
|
232
|
+
root.childNodes.forEach(n => {
|
|
233
|
+
if (n.nodeType === 1 && (BLOCK.includes(n.tagName) || [...n.children].some(c => BLOCK.includes(c.tagName)))) {
|
|
234
|
+
flush();
|
|
235
|
+
const b = block(n, depth || 0); if (b) parts.push(b);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (n.nodeType === 1 || n.nodeType === 3) (run = run || document.createElement('div')).appendChild(n.cloneNode(true));
|
|
239
|
+
});
|
|
240
|
+
flush();
|
|
241
|
+
return parts.join('\n\n');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Markdown carries a mark once: one inside another with the same delimiter adds nothing
|
|
245
|
+
const flattenMarks = (root) => [...root.querySelectorAll('*')].forEach(el => {
|
|
246
|
+
const m = MARKS[el.tagName]; if (!m) return;
|
|
247
|
+
for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (MARKS[p.tagName] === m) { el.replaceWith(...el.childNodes); return; }
|
|
248
|
+
});
|
|
249
|
+
const mdCopy = (root) => { const c = root.cloneNode(true); flattenMarks(c); mergeAdjacent(c, sameDelimiter); return c; };
|
|
250
|
+
const toMarkdown = (root) => blocks(mdCopy(root), 0).replace(/\n{3,}/g, '\n\n').trim();
|
|
251
|
+
// Single-line values never parse block syntax, so line starts need no escape
|
|
252
|
+
const inlineMarkdown = (root) => {
|
|
253
|
+
const flat = document.createElement('div');
|
|
254
|
+
flat.innerHTML = flattenLines(root.innerHTML, true);
|
|
255
|
+
flattenMarks(flat); mergeAdjacent(flat, sameDelimiter);
|
|
256
|
+
return inline(flat).trim();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// A cell's blocks joined with a space
|
|
260
|
+
const cellMarkdown = (cell) => {
|
|
261
|
+
if (![...cell.children].some(c => BLOCK.includes(c.tagName))) return inline(cell).trim();
|
|
262
|
+
const c = cell.cloneNode(true);
|
|
263
|
+
[...c.children].forEach(b => { if (BLOCK.includes(b.tagName)) { b.before(' '); b.after(' '); } });
|
|
264
|
+
return inline(c).replace(/\s+/g, ' ').trim();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/* ---- Tables in markdown ---- */
|
|
268
|
+
// One line per row, cells joined by " · ".
|
|
269
|
+
const CELL_SEP = ' · ';
|
|
270
|
+
const tableLines = (table) => [...table.rows].map(r => [...r.cells])
|
|
271
|
+
.filter(cells => cells.some(c => c.textContent.trim() || c.querySelector('img')));
|
|
272
|
+
|
|
273
|
+
function flattenTables(html, blocks) {
|
|
274
|
+
const t = document.createElement('template');
|
|
275
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
276
|
+
[...t.content.querySelectorAll('table')].reverse().forEach(table => {
|
|
277
|
+
const frag = document.createDocumentFragment();
|
|
278
|
+
tableLines(table).forEach((cells, i) => {
|
|
279
|
+
const line = blocks ? document.createElement('p') : frag;
|
|
280
|
+
if (!blocks && i) frag.appendChild(document.createElement('br'));
|
|
281
|
+
cells.forEach((cell, j) => {
|
|
282
|
+
cell.querySelectorAll(BLOCK_SEL).forEach(b => b.replaceWith(...b.childNodes, ' '));
|
|
283
|
+
cell.normalize();
|
|
284
|
+
const a = cell.firstChild, z = cell.lastChild;
|
|
285
|
+
if (a && a.nodeType === 3) a.nodeValue = a.nodeValue.replace(/^\s+/, '');
|
|
286
|
+
if (z && z.nodeType === 3) z.nodeValue = z.nodeValue.replace(/\s+$/, '');
|
|
287
|
+
if (j) line.appendChild(document.createTextNode(CELL_SEP));
|
|
288
|
+
while (cell.firstChild) line.appendChild(cell.firstChild);
|
|
289
|
+
});
|
|
290
|
+
if (blocks) frag.appendChild(line);
|
|
291
|
+
});
|
|
292
|
+
table.replaceWith(frag);
|
|
293
|
+
});
|
|
294
|
+
return t.innerHTML;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Html-only blocks become paragraphs for a markdown document
|
|
298
|
+
// Markdown keeps only its own marks: other inline tags (u, mark, sub, span...) are unwrapped
|
|
299
|
+
function markdownMarks(html) {
|
|
300
|
+
const t = document.createElement('template');
|
|
301
|
+
t.innerHTML = html;
|
|
302
|
+
[...t.content.querySelectorAll('*')].reverse().forEach(el => {
|
|
303
|
+
if (INLINE_OK.has(el.tagName) && !MARKS[el.tagName] && !/^(A|IMG|BR)$/.test(el.tagName)) el.replaceWith(...el.childNodes);
|
|
304
|
+
});
|
|
305
|
+
return t.innerHTML;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function markdownBlocks(html) {
|
|
309
|
+
const t = document.createElement('template');
|
|
310
|
+
t.innerHTML = html;
|
|
311
|
+
[...t.content.querySelectorAll('dt, dd, figcaption, address')].reverse().forEach(b => {
|
|
312
|
+
if ([...b.children].some(c => BLOCK.includes(c.tagName))) b.replaceWith(...b.childNodes);
|
|
313
|
+
else retagBlock(b, 'p');
|
|
314
|
+
});
|
|
315
|
+
[...t.content.querySelectorAll('dl, figure')].reverse().forEach(b => b.replaceWith(...b.childNodes));
|
|
316
|
+
return t.innerHTML;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* ---- Markdown → HTML ---- */
|
|
320
|
+
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
321
|
+
|
|
322
|
+
/* Inline markdown: a small CommonMark-style reader. Code spans, links and images are found
|
|
323
|
+
left to right; runs of * _ ~ are delimiters, matched innermost first with CommonMark's
|
|
324
|
+
rules (the rule of 3 included). `*` and `~~` open before any non-space and close after
|
|
325
|
+
one (CommonMark also weighs punctuation); `_` keeps CommonMark's word-boundary rules. */
|
|
326
|
+
const PUNCT = /[!-\/:-@\[-`{-~ -⸀-]/;
|
|
327
|
+
const isSpace = (c) => !c || /\s/.test(c);
|
|
328
|
+
// opts.raw: backslashes are text and no inline HTML is read (markdown being typed);
|
|
329
|
+
// opts.after: the character after the source, for the flanking of a run that ends it
|
|
330
|
+
function inlineHtml(src, opts = {}) {
|
|
331
|
+
const s = String(src == null ? '' : src);
|
|
332
|
+
const parts = [], delims = [];
|
|
333
|
+
let text = '';
|
|
334
|
+
const flush = () => { if (text) { parts.push(escHtml(text)); text = ''; } };
|
|
335
|
+
// A code span's closing run: the next backtick run of the same length
|
|
336
|
+
const codeEnd = (from, len) => {
|
|
337
|
+
for (let j = from; j < s.length;) {
|
|
338
|
+
const k = s.indexOf('`', j); if (k < 0) return -1;
|
|
339
|
+
const n = s.slice(k).match(/^`+/)[0].length;
|
|
340
|
+
if (n === len) return k;
|
|
341
|
+
j = k + n;
|
|
342
|
+
}
|
|
343
|
+
return -1;
|
|
344
|
+
};
|
|
345
|
+
// A link destination from the "(" at `at`: <…>, or balanced parentheses; an optional title is skipped
|
|
346
|
+
const destination = (at) => {
|
|
347
|
+
let j = at + 1, url;
|
|
348
|
+
while (s[j] === ' ' || s[j] === '\t') j++;
|
|
349
|
+
if (s[j] === '<') {
|
|
350
|
+
const e = s.indexOf('>', j); if (e < 0 || /[\n<]/.test(s.slice(j + 1, e))) return null;
|
|
351
|
+
url = s.slice(j + 1, e); j = e + 1;
|
|
352
|
+
} else {
|
|
353
|
+
const from = j;
|
|
354
|
+
for (let depth = 0; j < s.length; j++) {
|
|
355
|
+
const c = s[j];
|
|
356
|
+
if (c === '\\' && /[!-\/:-@\[-`{-~]/.test(s[j + 1] || '')) { j++; continue; }
|
|
357
|
+
if (/[\s\x00-\x1f]/.test(c)) break;
|
|
358
|
+
if (c === '(') depth++;
|
|
359
|
+
else if (c === ')') { if (!depth) break; depth--; }
|
|
360
|
+
}
|
|
361
|
+
url = s.slice(from, j);
|
|
362
|
+
}
|
|
363
|
+
const gap = j;
|
|
364
|
+
while (/[ \t\n]/.test(s[j] || '')) j++;
|
|
365
|
+
const q = s[j];
|
|
366
|
+
let title = null;
|
|
367
|
+
if (j > gap && (q === '"' || q === "'" || q === '(')) {
|
|
368
|
+
const close = q === '(' ? ')' : q;
|
|
369
|
+
let e = j + 1; for (; e < s.length && s[e] !== close; e++) if (s[e] === '\\') e++;
|
|
370
|
+
if (e >= s.length) return null;
|
|
371
|
+
title = s.slice(j + 1, e).replace(/\\([!-\/:-@\[-`{-~])/g, '$1');
|
|
372
|
+
j = e + 1; while (/[ \t\n]/.test(s[j] || '')) j++;
|
|
373
|
+
}
|
|
374
|
+
if (s[j] !== ')') return null;
|
|
375
|
+
return { url: url.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'), title, end: j + 1 };
|
|
376
|
+
};
|
|
377
|
+
// A bracket's matching close, skipping escapes and code spans
|
|
378
|
+
const closeBracket = (from) => {
|
|
379
|
+
for (let i = from, depth = 0; i < s.length; i++) {
|
|
380
|
+
const c = s[i];
|
|
381
|
+
if (c === '\\') { i++; continue; }
|
|
382
|
+
if (c === '`') { const n = s.slice(i).match(/^`+/)[0].length, e = codeEnd(i + n, n); i = (e < 0 ? i + n : e + n) - 1; continue; }
|
|
383
|
+
if (c === '[') depth++;
|
|
384
|
+
else if (c === ']') { if (!depth) return i; depth--; }
|
|
385
|
+
}
|
|
386
|
+
return -1;
|
|
387
|
+
};
|
|
388
|
+
for (let i = 0; i < s.length;) {
|
|
389
|
+
const c = s[i];
|
|
390
|
+
if (c === '\\' && !opts.raw && i + 1 < s.length && /[!-\/:-@\[-`{-~]/.test(s[i + 1])) { text += s[i + 1]; i += 2; continue; }
|
|
391
|
+
if (c === '`') {
|
|
392
|
+
const run = s.slice(i).match(/^`+/)[0], end = codeEnd(i + run.length, run.length);
|
|
393
|
+
if (end < 0) { text += run; i += run.length; continue; }
|
|
394
|
+
let code = s.slice(i + run.length, end).replace(/\n/g, ' ');
|
|
395
|
+
if (code.length > 1 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()) code = code.slice(1, -1);
|
|
396
|
+
flush(); parts.push(`<code>${escHtml(code)}</code>`);
|
|
397
|
+
i = end + run.length; continue;
|
|
398
|
+
}
|
|
399
|
+
if (c === '[' || (c === '!' && s[i + 1] === '[')) {
|
|
400
|
+
const img = c === '!', open = i + (img ? 2 : 1), close = closeBracket(open);
|
|
401
|
+
const m = close > 0 && s[close + 1] === '(' && destination(close + 1);
|
|
402
|
+
if (m) {
|
|
403
|
+
const url = m.url, body = s.slice(open, close);
|
|
404
|
+
flush();
|
|
405
|
+
if (img) parts.push(unsafeUrl(url, true) ? '' : `<img src="${escHtml(url)}" alt="${escHtml(body.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'))}">`);
|
|
406
|
+
else parts.push(unsafeUrl(url) || !url ? inlineHtml(body, { raw: opts.raw }) : `<a href="${escHtml(url)}"${m.title != null ? ` title="${escHtml(m.title)}"` : ''}>${inlineHtml(body, { raw: opts.raw })}</a>`);
|
|
407
|
+
i = m.end; continue;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// The inline HTML this writer uses where markdown has no form (raw tags, as CommonMark passes them)
|
|
411
|
+
if (c === '<' && !opts.raw) {
|
|
412
|
+
// Inline HTML for marks markdown has (as CommonMark passes it); other tags stay text
|
|
413
|
+
const t = s.slice(i).match(/^<(\/?)(strong|em|b|i|s|del|code)>/i);
|
|
414
|
+
if (t) { const tag = { b: 'strong', i: 'em', del: 's' }[t[2].toLowerCase()] || t[2].toLowerCase(); flush(); parts.push(`<${t[1]}${tag}>`); i += t[0].length; continue; }
|
|
415
|
+
// An autolink: <scheme:…>
|
|
416
|
+
const a = s.slice(i).match(/^<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)>/i);
|
|
417
|
+
if (a && !unsafeUrl(a[1])) { flush(); parts.push(`<a href="${escHtml(a[1])}">${escHtml(a[1])}</a>`); i += a[0].length; continue; }
|
|
418
|
+
}
|
|
419
|
+
if (c === '\n' && / {2,}$/.test(text)) { text = text.replace(/ +$/, ''); flush(); parts.push('<br>'); i++; continue; }
|
|
420
|
+
if (c === '*' || c === '_' || c === '~') {
|
|
421
|
+
const run = s.slice(i).match(c === '*' ? /^\*+/ : c === '_' ? /^_+/ : /^~+/)[0];
|
|
422
|
+
const before = i ? s[i - 1] : '', after = i + run.length < s.length ? s[i + run.length] : (opts.after || '');
|
|
423
|
+
let open, close;
|
|
424
|
+
if (c === '_') {
|
|
425
|
+
const left = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
|
|
426
|
+
const right = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
|
|
427
|
+
open = left && (!right || PUNCT.test(before)); close = right && (!left || PUNCT.test(after));
|
|
428
|
+
} else { open = !isSpace(after); close = !isSpace(before); }
|
|
429
|
+
if (c === '~' && run.length !== 2) { open = close = false; }
|
|
430
|
+
if (!open && !close) { text += run; i += run.length; continue; }
|
|
431
|
+
flush();
|
|
432
|
+
const d = { ch: c, count: run.length, orig: run.length, open, close };
|
|
433
|
+
parts.push(d); delims.push(d);
|
|
434
|
+
i += run.length; continue;
|
|
435
|
+
}
|
|
436
|
+
text += c; i++;
|
|
437
|
+
}
|
|
438
|
+
flush();
|
|
439
|
+
// Innermost first: each closer takes the nearest opener it can pair with
|
|
440
|
+
for (let ci = 0; ci < delims.length; ci++) {
|
|
441
|
+
const closer = delims[ci];
|
|
442
|
+
while (closer.close && closer.count) {
|
|
443
|
+
let oi = ci - 1;
|
|
444
|
+
for (; oi >= 0; oi--) {
|
|
445
|
+
const o = delims[oi];
|
|
446
|
+
if (o.ch !== closer.ch || !o.open || !o.count) continue;
|
|
447
|
+
if (closer.ch === '~') { if (o.count === 2 && closer.count === 2) break; continue; }
|
|
448
|
+
if ((o.close || closer.open) && (o.orig + closer.orig) % 3 === 0 && !(o.orig % 3 === 0 && closer.orig % 3 === 0)) continue;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
if (oi < 0) break;
|
|
452
|
+
const opener = delims[oi], use = closer.ch === '~' ? 2 : (opener.count >= 2 && closer.count >= 2 ? 2 : 1);
|
|
453
|
+
const tag = closer.ch === '~' ? 's' : use === 2 ? 'strong' : 'em';
|
|
454
|
+
const a = parts.indexOf(opener), b = parts.indexOf(closer);
|
|
455
|
+
const inner = parts.splice(a + 1, b - a - 1).map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
|
|
456
|
+
parts.splice(a + 1, 0, `<${tag}>${inner}</${tag}>`);
|
|
457
|
+
for (let k = oi + 1; k < ci; k++) delims[k].count = 0; // delimiters inside are text now (already rendered)
|
|
458
|
+
opener.count -= use; closer.count -= use;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return parts.map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Four spaces per nesting level.
|
|
465
|
+
function renderList(items, i, depth) {
|
|
466
|
+
const tag = items[i].tag;
|
|
467
|
+
let html = `<${tag}>`;
|
|
468
|
+
while (i < items.length && items[i].depth >= depth) {
|
|
469
|
+
if (items[i].depth > depth) {
|
|
470
|
+
const [sub, next] = renderList(items, i, items[i].depth);
|
|
471
|
+
html = html.endsWith(`<${tag}>`) ? html + `<li>${sub}</li>` : html.replace(/<\/li>$/, sub + '</li>');
|
|
472
|
+
i = next;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (items[i].tag !== tag) break;
|
|
476
|
+
const task = items[i].text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
477
|
+
html += task
|
|
478
|
+
? `<li><input type="checkbox"${/[xX]/.test(task[1]) ? ' checked' : ''}>${inlineHtml(task[2])}</li>`
|
|
479
|
+
: `<li>${inlineHtml(items[i].text)}</li>`;
|
|
480
|
+
i++;
|
|
481
|
+
}
|
|
482
|
+
return [html + `</${tag}>`, i];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function fromMarkdown(md) {
|
|
486
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
|
487
|
+
const out = [];
|
|
488
|
+
let para = [], list = null, quote = null, fence = null, lang = '';
|
|
489
|
+
const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
|
|
490
|
+
const flushList = () => { if (!list) return; for (let i = 0; i < list.length;) { const [html, next] = renderList(list, i, list[i].depth); out.push(html); i = next > i ? next : i + 1; } list = null; };
|
|
491
|
+
const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
|
|
492
|
+
const flushAll = () => { flushPara(); flushList(); flushQuote(); };
|
|
493
|
+
|
|
494
|
+
for (const raw of lines) {
|
|
495
|
+
const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
|
|
496
|
+
if (fence !== null) { if (/^```\s*$/.test(line)) { out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
|
|
497
|
+
if (/^```/.test(line)) { flushAll(); fence = []; lang = (line.slice(3).trim().match(/^[\w+#.-]+/) || [''])[0]; continue; }
|
|
498
|
+
if (!line.trim()) { flushAll(); continue; }
|
|
499
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
500
|
+
if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
|
|
501
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { flushAll(); out.push('<hr>'); continue; }
|
|
502
|
+
const q = line.match(/^>\s?(.*)$/);
|
|
503
|
+
if (q) { flushPara(); flushList(); (quote = quote || []).push(q[1]); continue; }
|
|
504
|
+
const li = line.match(/^([ \t]*)([-*+]|\d+\.)\s+(.*)$/);
|
|
505
|
+
if (li) {
|
|
506
|
+
flushPara(); flushQuote();
|
|
507
|
+
(list = list || []).push({ depth: Math.floor(li[1].replace(/\t/g, ' ').length / 4), tag: /\d/.test(li[2]) ? 'ol' : 'ul', text: li[3] });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
flushList(); flushQuote();
|
|
511
|
+
para.push(line);
|
|
512
|
+
}
|
|
513
|
+
if (fence) out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`);
|
|
514
|
+
flushAll();
|
|
515
|
+
return out.join('');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* ---- Sanitize ---- */
|
|
519
|
+
// Payload elements are dropped; anything else outside the allowlist is unwrapped.
|
|
520
|
+
const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'MATH']);
|
|
521
|
+
|
|
522
|
+
function sanitize(html, allowBlocks, keepStyle) {
|
|
523
|
+
const t = document.createElement('template');
|
|
524
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
525
|
+
const ok = new Set(allowBlocks ? [...INLINE_OK, ...BLOCK, 'INPUT', 'TR', 'TBODY', 'THEAD', 'CAPTION', 'COLGROUP', 'COL'] : INLINE_OK);
|
|
526
|
+
// Depth-first, so nothing hoisted out of an unwrapped parent escapes the walk
|
|
527
|
+
const walk = (node) => [...node.childNodes].forEach(c => {
|
|
528
|
+
if (c.nodeType === 8) return c.remove();
|
|
529
|
+
if (c.nodeType !== 1) return;
|
|
530
|
+
if (DROP.has(c.tagName)) return c.remove();
|
|
531
|
+
walk(c);
|
|
532
|
+
if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
|
|
533
|
+
const meta = INLINE_TAGS[c.tagName.toLowerCase()];
|
|
534
|
+
[...c.attributes].forEach(a => {
|
|
535
|
+
const n = a.name.toLowerCase();
|
|
536
|
+
if (c.tagName === 'A' && ((n === 'href' && !unsafeUrl(a.value)) || n === 'title')) return;
|
|
537
|
+
if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
|
|
538
|
+
if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
|
|
539
|
+
if (c.tagName === 'PRE' && n === 'data-lang' && /^[\w+#.-]{1,40}$/.test(a.value)) return; // a code fence's language
|
|
540
|
+
if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
|
|
541
|
+
if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
|
|
542
|
+
if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
|
|
543
|
+
c.removeAttribute(a.name);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
walk(t.content);
|
|
547
|
+
mergeAdjacent(t.content);
|
|
548
|
+
return t.innerHTML;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
|
|
552
|
+
|
|
553
|
+
// Pasted spans left bare once their styles are stripped carry nothing
|
|
554
|
+
function unwrapBareSpans(html) {
|
|
555
|
+
const t = document.createElement('template');
|
|
556
|
+
t.innerHTML = html;
|
|
557
|
+
[...t.content.querySelectorAll('span')].reverse().forEach(s => { if (!s.attributes.length) s.replaceWith(...s.childNodes); });
|
|
558
|
+
return t.innerHTML;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* ---- Single-line content ---- */
|
|
562
|
+
const flatText = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ');
|
|
563
|
+
|
|
564
|
+
// Blocks and breaks become spaces, then inline-only and whitespace-collapsed.
|
|
565
|
+
function flattenLines(html, keepStyle) {
|
|
566
|
+
const t = document.createElement('template');
|
|
567
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
568
|
+
t.content.querySelectorAll('br').forEach(b => b.replaceWith(' '));
|
|
569
|
+
t.content.querySelectorAll('*').forEach(el => { if (!INLINE_OK.has(el.tagName)) { el.before(' '); el.after(' '); } });
|
|
570
|
+
t.innerHTML = sanitize(t.innerHTML, false, keepStyle);
|
|
571
|
+
// One space per boundary, across element edges too
|
|
572
|
+
// Images and other replaced elements are content: the space after one stays
|
|
573
|
+
const walker = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
574
|
+
let spaced = true, last = null;
|
|
575
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
576
|
+
if (n.nodeType === 1) { if (/^(IMG|WBR)$/.test(n.tagName)) { spaced = false; last = n; } continue; }
|
|
577
|
+
let v = n.nodeValue.replace(/[ \t\n\r\f]+/g, ' ');
|
|
578
|
+
if (spaced) v = v.replace(/^ /, '');
|
|
579
|
+
if (v) { spaced = v.endsWith(' '); last = n; }
|
|
580
|
+
n.nodeValue = v;
|
|
581
|
+
}
|
|
582
|
+
if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/ $/, '');
|
|
583
|
+
return t.innerHTML;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// One line per block or <br> (.inline with breaks): boundaries become single <br>s, none at the ends.
|
|
587
|
+
const LINE = '\u2028', PARA = '\u2029';
|
|
588
|
+
function breakLines(html, keepStyle) {
|
|
589
|
+
const t = document.createElement('template');
|
|
590
|
+
t.innerHTML = String(html == null ? '' : html);
|
|
591
|
+
t.content.querySelectorAll('br').forEach(b => b.replaceWith(LINE));
|
|
592
|
+
t.content.querySelectorAll(BLOCK_SEL).forEach(b => { b.before(PARA); b.after(PARA); });
|
|
593
|
+
return flattenLines(t.innerHTML, keepStyle)
|
|
594
|
+
.replace(/ *([\u2028\u2029]) */g, '$1')
|
|
595
|
+
.replace(/\u2029+/g, PARA).replace(/\u2028\u2029|\u2029\u2028/g, LINE)
|
|
596
|
+
.replace(/^[\u2028\u2029]+|[\u2028\u2029]+$/g, '')
|
|
597
|
+
.replace(/[\u2028\u2029]/g, '<br>');
|
|
598
|
+
}
|
|
599
|
+
const textLines = (text) => String(text == null ? '' : text).split(/\r\n?|\n/).map(l => l.trim()).filter(Boolean)
|
|
600
|
+
.map(l => sanitize(inlineHtml(l), false)).join('<br>');
|
|
601
|
+
|
|
602
|
+
/* ---- Selection primitives ---- */
|
|
603
|
+
const sel = () => window.getSelection();
|
|
604
|
+
// `probe` stands in for the live range while a control is judged against an area's saved one
|
|
605
|
+
let probe = null;
|
|
606
|
+
const liveRange = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
|
|
607
|
+
const range = () => probe || liveRange();
|
|
608
|
+
|
|
609
|
+
function elementAt(node) { while (node && node.nodeType !== 1) node = node.parentNode; return node; }
|
|
610
|
+
|
|
611
|
+
// Bounded by the area: an area that is itself <strong> never reports as formatting
|
|
612
|
+
function ancestor(match) {
|
|
613
|
+
const r = range(); if (!r) return null;
|
|
614
|
+
const el = elementAt(r.startContainer); if (!el) return null;
|
|
615
|
+
const found = el.closest(match); if (!found) return null;
|
|
616
|
+
const area = el.closest('[data-rich-text]');
|
|
617
|
+
if (area && (found === area || !area.contains(found))) return null;
|
|
618
|
+
return found;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const withinArea = (node, found) => {
|
|
622
|
+
if (!found) return false;
|
|
623
|
+
const el = elementAt(node), area = el && el.closest('[data-rich-text]');
|
|
624
|
+
return !area || (found !== area && area.contains(found));
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Bounded by the area, so a block command never replaces the editor
|
|
628
|
+
function blockEl() {
|
|
629
|
+
let n = elementAt(range() && range().startContainer);
|
|
630
|
+
while (n && !areas.has(n)) {
|
|
631
|
+
if (BLOCK.includes(n.tagName)) return n;
|
|
632
|
+
n = n.parentElement;
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function ensureBlock(area) {
|
|
638
|
+
let b = blockEl();
|
|
639
|
+
if (b) return b;
|
|
640
|
+
b = document.createElement('p');
|
|
641
|
+
const r = range();
|
|
642
|
+
if (r && area.contains(r.startContainer)) r.insertNode(b); else area.appendChild(b);
|
|
643
|
+
caretIn(b, 0);
|
|
644
|
+
return b;
|
|
645
|
+
}
|
|
646
|
+
const blockTag = () => { const b = blockEl(); return b && b.tagName; };
|
|
647
|
+
|
|
648
|
+
function selectNode(node) {
|
|
649
|
+
const s = sel(), r = document.createRange();
|
|
650
|
+
r.selectNodeContents(node); s.removeAllRanges(); s.addRange(r);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Caret as a character offset, so it survives rebuilt nodes
|
|
654
|
+
function offsetIn(root) {
|
|
655
|
+
const r = range(); if (!r || !root.contains(r.startContainer)) return null;
|
|
656
|
+
const pre = document.createRange();
|
|
657
|
+
pre.selectNodeContents(root);
|
|
658
|
+
pre.setEnd(r.startContainer, r.startOffset);
|
|
659
|
+
return pre.toString().length;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// The selection as text offsets in root, and back
|
|
663
|
+
function textSpan(root) {
|
|
664
|
+
const r = range(); if (!r || !root.contains(r.startContainer) || !root.contains(r.endContainer)) return null;
|
|
665
|
+
const at = (node, off) => { const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(node, off); return pre.toString().length; };
|
|
666
|
+
return [at(r.startContainer, r.startOffset), at(r.endContainer, r.endOffset)];
|
|
667
|
+
}
|
|
668
|
+
function selectSpan(root, [from, to]) {
|
|
669
|
+
const point = (offset, end) => {
|
|
670
|
+
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
671
|
+
let seen = 0, last = null;
|
|
672
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
673
|
+
last = n;
|
|
674
|
+
if (end ? seen + n.length >= offset : seen + n.length > offset) return [n, offset - seen];
|
|
675
|
+
seen += n.length;
|
|
676
|
+
}
|
|
677
|
+
return last ? [last, last.length] : [root, 0];
|
|
678
|
+
};
|
|
679
|
+
const r = document.createRange(), a = point(from, false), b = point(to, true);
|
|
680
|
+
r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
|
|
681
|
+
const s = sel(); s.removeAllRanges(); s.addRange(r);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function caretIn(root, offset) {
|
|
685
|
+
const s = sel(), r = document.createRange();
|
|
686
|
+
if (offset == null) offset = 0;
|
|
687
|
+
// An empty block holds a caret only with a <br> and an element anchor
|
|
688
|
+
if (!root.textContent.length) {
|
|
689
|
+
[...root.childNodes].forEach(n => { if (n.nodeType === 3) n.remove(); });
|
|
690
|
+
if (!root.querySelector('br')) root.appendChild(document.createElement('br'));
|
|
691
|
+
r.setStart(root, 0); r.collapse(true);
|
|
692
|
+
s.removeAllRanges(); s.addRange(r);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
696
|
+
let seen = 0;
|
|
697
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
698
|
+
if (!n.length) continue;
|
|
699
|
+
if (seen + n.length >= offset) {
|
|
700
|
+
r.setStart(n, Math.max(0, offset - seen)); r.collapse(true);
|
|
701
|
+
s.removeAllRanges(); s.addRange(r);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
seen += n.length;
|
|
705
|
+
}
|
|
706
|
+
r.setStart(root, root.childNodes.length); r.collapse(true);
|
|
707
|
+
s.removeAllRanges(); s.addRange(r);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Boundary text nodes are split so every node returned lies wholly inside the selection
|
|
711
|
+
function selectedTextNodes() {
|
|
712
|
+
const r = range(); if (!r || r.collapsed) return [];
|
|
713
|
+
if (r.endContainer.nodeType === 3 && r.endOffset < r.endContainer.length) {
|
|
714
|
+
const end = r.endContainer, at = r.endOffset;
|
|
715
|
+
end.splitText(at); r.setEnd(end, end.length);
|
|
716
|
+
}
|
|
717
|
+
if (r.startContainer.nodeType === 3 && r.startOffset > 0) {
|
|
718
|
+
const start = r.startContainer, at = r.startOffset, same = r.endContainer === start, endAt = r.endOffset;
|
|
719
|
+
const tail = start.splitText(at);
|
|
720
|
+
r.setStart(tail, 0);
|
|
721
|
+
if (same) r.setEnd(tail, endAt - at);
|
|
722
|
+
}
|
|
723
|
+
const root = r.commonAncestorContainer;
|
|
724
|
+
const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
|
|
725
|
+
const out = [];
|
|
726
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
727
|
+
if (r.intersectsNode(n) && n.nodeValue && r.comparePoint(n, 0) >= 0 && r.comparePoint(n, n.length) <= 0) out.push(n);
|
|
728
|
+
}
|
|
729
|
+
return out.length ? out : (r.startContainer.nodeType === 3 ? [r.startContainer] : []);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/* ---- Inline tags ---- */
|
|
733
|
+
function wrapInline(tag, style, attrs) {
|
|
734
|
+
const nodes = selectedTextNodes(); if (!nodes.length) return [];
|
|
735
|
+
const made = [];
|
|
736
|
+
nodes.forEach(n => {
|
|
737
|
+
if (!n.nodeValue.length) return;
|
|
738
|
+
const el = document.createElement(tag);
|
|
739
|
+
if (style) Object.assign(el.style, style);
|
|
740
|
+
if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
741
|
+
n.parentNode.insertBefore(el, n);
|
|
742
|
+
el.appendChild(n);
|
|
743
|
+
made.push(el);
|
|
744
|
+
el.querySelectorAll(tag).forEach(dupe => dupe.replaceWith(...dupe.childNodes));
|
|
745
|
+
});
|
|
746
|
+
if (!made.length) return made;
|
|
747
|
+
// Boundaries land inside the new elements
|
|
748
|
+
const s = sel(), r = document.createRange(), last = made[made.length - 1];
|
|
749
|
+
r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length);
|
|
750
|
+
s.removeAllRanges(); s.addRange(r);
|
|
751
|
+
return made;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function unwrapInline(tag) {
|
|
755
|
+
const nodes = selectedTextNodes();
|
|
756
|
+
const targets = new Set();
|
|
757
|
+
nodes.forEach(n => { const el = elementAt(n) && elementAt(n).closest(tag); if (withinArea(n, el)) targets.add(el); });
|
|
758
|
+
const host = ancestor(tag); if (host) targets.add(host);
|
|
759
|
+
targets.forEach(el => el.replaceWith(...el.childNodes));
|
|
760
|
+
// Reselect the same text
|
|
761
|
+
const live = nodes.filter(n => n.isConnected && n.length);
|
|
762
|
+
if (!live.length) return;
|
|
763
|
+
const r = document.createRange();
|
|
764
|
+
r.setStart(live[0], 0);
|
|
765
|
+
r.setEnd(live[live.length - 1], live[live.length - 1].length);
|
|
766
|
+
const s = sel(); s.removeAllRanges(); s.addRange(r);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Every selected text node carries the tag
|
|
770
|
+
function selectionMarked(tag) {
|
|
771
|
+
const r = range(); if (!r) return false;
|
|
772
|
+
if (r.collapsed) return !!ancestor(tag);
|
|
773
|
+
const root = r.commonAncestorContainer;
|
|
774
|
+
const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
|
|
775
|
+
let any = false;
|
|
776
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
777
|
+
if (!r.intersectsNode(n) || !n.nodeValue.trim()) continue;
|
|
778
|
+
any = true;
|
|
779
|
+
if (!n.parentElement) return false;
|
|
780
|
+
if (!withinArea(n, n.parentElement.closest(tag))) return false;
|
|
781
|
+
}
|
|
782
|
+
return any;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const toggleInline = (tag, style, attrs) => selectionMarked(tag) ? unwrapInline(tag) : wrapInline(tag, style, attrs);
|
|
786
|
+
|
|
787
|
+
const attrsFor = (tag, v) => {
|
|
788
|
+
const m = INLINE_TAGS[tag], val = v != null && v !== '' ? String(v) : m.def;
|
|
789
|
+
return m.attr && val ? { [m.attr]: val } : null;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
function insertVoid(tag) {
|
|
793
|
+
const r = range(); if (!r) return;
|
|
794
|
+
r.deleteContents();
|
|
795
|
+
const n = document.createElement(tag);
|
|
796
|
+
r.insertNode(n);
|
|
797
|
+
const s = sel(), nr = document.createRange();
|
|
798
|
+
nr.setStartAfter(n); nr.collapse(true);
|
|
799
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/* ---- Block tags ---- */
|
|
803
|
+
// A cell's loose content into a <p> inside it, so block commands nest rather than replace the cell
|
|
804
|
+
function innerBlock(cell) {
|
|
805
|
+
const off = offsetIn(cell), p = document.createElement('p');
|
|
806
|
+
while (cell.firstChild) p.appendChild(cell.firstChild);
|
|
807
|
+
cell.appendChild(p);
|
|
808
|
+
caretIn(p, off);
|
|
809
|
+
return p;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The block a command may replace: never a cell, list item, term or container
|
|
813
|
+
function freeBlock() {
|
|
814
|
+
const b = blockEl(); if (!b) return null;
|
|
815
|
+
if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return null;
|
|
816
|
+
if (/^(TD|TH)$/.test(b.tagName)) return innerBlock(b);
|
|
817
|
+
if (/^(LI|DT|DD)$/.test(b.tagName)) return liftItem(b);
|
|
818
|
+
return b;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function setBlock(tag) {
|
|
822
|
+
let b = blockEl(); if (!b) return;
|
|
823
|
+
if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return;
|
|
824
|
+
if (/^(TD|TH)$/.test(b.tagName)) b = innerBlock(b);
|
|
825
|
+
const want = (b.tagName === tag.toUpperCase() ? 'p' : tag).toLowerCase();
|
|
826
|
+
if (b.tagName === want.toUpperCase()) return;
|
|
827
|
+
// Out of the list first (outdenting nested items to the top), then retag
|
|
828
|
+
if (b.tagName === 'LI' && want !== 'li') {
|
|
829
|
+
const area = b.closest('[data-rich-text]');
|
|
830
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
831
|
+
const list = b && b.tagName === 'LI' && b.closest('ul, ol');
|
|
832
|
+
if (!list || !list.parentElement || !list.parentElement.closest('li')) break;
|
|
833
|
+
indent(-1, area);
|
|
834
|
+
b = blockEl(); if (!b) return;
|
|
835
|
+
}
|
|
836
|
+
if (b.tagName === 'LI') { liftItem(b); b = blockEl(); if (!b) return; }
|
|
837
|
+
if (b.tagName === want.toUpperCase()) return;
|
|
838
|
+
}
|
|
839
|
+
if (/^(DT|DD)$/.test(b.tagName)) { b = liftItem(b); if (want === 'p') return; }
|
|
840
|
+
const n = document.createElement(want);
|
|
841
|
+
n.setAttribute('style', b.getAttribute('style') || '');
|
|
842
|
+
if (!n.getAttribute('style')) n.removeAttribute('style');
|
|
843
|
+
const off = offsetIn(b);
|
|
844
|
+
while (b.firstChild) n.appendChild(b.firstChild);
|
|
845
|
+
b.replaceWith(n);
|
|
846
|
+
// A sublist that rode along steps out after the new block
|
|
847
|
+
[...n.querySelectorAll(':scope > ul, :scope > ol')].reverse().forEach(l => n.after(l));
|
|
848
|
+
caretIn(n, off);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const styleBlock = (prop, value) => { const b = blockEl(); if (b) b.style[prop] = value; };
|
|
852
|
+
const readBlockStyle = (prop) => { const b = blockEl(); return b ? getComputedStyle(b)[prop] : ''; };
|
|
853
|
+
|
|
854
|
+
function retagKeep(b, tag) { const off = offsetIn(b); caretIn(retagBlock(b, tag), off); }
|
|
855
|
+
|
|
856
|
+
/* ---- Figures and description lists ---- */
|
|
857
|
+
function toggleFigure() {
|
|
858
|
+
const existing = ancestor('figure');
|
|
859
|
+
if (existing) {
|
|
860
|
+
const caption = existing.querySelector(':scope > figcaption');
|
|
861
|
+
if (caption) caption.remove();
|
|
862
|
+
existing.replaceWith(...existing.childNodes);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const b = freeBlock(); if (!b) return;
|
|
866
|
+
const fig = document.createElement('figure');
|
|
867
|
+
b.replaceWith(fig);
|
|
868
|
+
fig.appendChild(b);
|
|
869
|
+
const caption = document.createElement('figcaption');
|
|
870
|
+
caption.appendChild(document.createElement('br'));
|
|
871
|
+
fig.appendChild(caption);
|
|
872
|
+
caretIn(caption, 0);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// One caption per figure: an existing one takes the caret instead
|
|
876
|
+
function setCaption() {
|
|
877
|
+
const fig = ancestor('figure'), b = blockEl(); if (!fig || !b) return;
|
|
878
|
+
const caption = fig.querySelector(':scope > figcaption');
|
|
879
|
+
if (caption && caption !== b && !caption.contains(b)) return caretIn(caption, caption.textContent.length);
|
|
880
|
+
if (b.parentElement === fig) setBlock('figcaption');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function toggleDl() {
|
|
884
|
+
const dl = ancestor('dl');
|
|
885
|
+
if (dl) {
|
|
886
|
+
const term = ancestor('dt, dd'), off = term ? offsetIn(term) : 0;
|
|
887
|
+
let land = null;
|
|
888
|
+
[...dl.children].forEach(c => {
|
|
889
|
+
if ([...c.children].some(x => BLOCK.includes(x.tagName))) { if (c === term) land = c.firstElementChild; c.replaceWith(...c.childNodes); }
|
|
890
|
+
else { const p = retagBlock(c, 'p'); if (c === term) land = p; }
|
|
891
|
+
});
|
|
892
|
+
dl.replaceWith(...dl.childNodes);
|
|
893
|
+
if (land) caretIn(land, off);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const b = freeBlock(); if (!b) return;
|
|
897
|
+
const list = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
|
|
898
|
+
while (b.firstChild) dt.appendChild(b.firstChild);
|
|
899
|
+
dd.appendChild(document.createElement('br'));
|
|
900
|
+
list.append(dt, dd);
|
|
901
|
+
b.replaceWith(list);
|
|
902
|
+
caretIn(dd, 0);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Retags the term itself, never a block nested in one
|
|
906
|
+
const setTerm = (tag) => () => {
|
|
907
|
+
const term = ancestor('dt, dd');
|
|
908
|
+
if (!term || term.tagName === tag.toUpperCase()) return;
|
|
909
|
+
retagKeep(term, tag);
|
|
910
|
+
};
|
|
911
|
+
const termIs = (tag) => { const t = ancestor('dt, dd'); return !!t && t.tagName === tag; };
|
|
912
|
+
|
|
913
|
+
/* ---- Lists, tables, indent ---- */
|
|
914
|
+
const inList = () => !!ancestor('li');
|
|
915
|
+
const inTable = () => !!cellAt();
|
|
916
|
+
|
|
917
|
+
/* ---- Tables ---- */
|
|
918
|
+
let cellDrag = null; // pointer-picked block of cells
|
|
919
|
+
const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
|
|
920
|
+
const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
|
|
921
|
+
const rowsOf = (table) => [...table.rows];
|
|
922
|
+
const emptyCell = (tag = 'td') => { const td = document.createElement(tag); td.appendChild(document.createElement('br')); return td; };
|
|
923
|
+
// A header row (in a thead, or of header cells only) is padded with header cells
|
|
924
|
+
const rowCellTag = (row) => row.parentElement && row.parentElement.tagName === 'THEAD' || (row.cells.length && [...row.cells].every(c => c.tagName === 'TH')) ? 'th' : 'td';
|
|
925
|
+
|
|
926
|
+
// Occupancy grid: every slot maps to the cell covering it, spans included
|
|
927
|
+
function tableGrid(table) {
|
|
928
|
+
const grid = [];
|
|
929
|
+
rowsOf(table).forEach((row, r) => {
|
|
930
|
+
grid[r] = grid[r] || [];
|
|
931
|
+
let c = 0;
|
|
932
|
+
for (const cell of row.cells) {
|
|
933
|
+
while (grid[r][c]) c++;
|
|
934
|
+
for (let dr = 0; dr < (cell.rowSpan || 1); dr++) {
|
|
935
|
+
grid[r + dr] = grid[r + dr] || [];
|
|
936
|
+
for (let dc = 0; dc < (cell.colSpan || 1); dc++) grid[r + dr][c + dc] = cell;
|
|
937
|
+
}
|
|
938
|
+
c += cell.colSpan || 1;
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
return grid;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function gridPosition(cell) {
|
|
945
|
+
const table = cell.closest('table'), grid = tableGrid(table);
|
|
946
|
+
for (let r = 0; r < grid.length; r++) {
|
|
947
|
+
for (let c = 0; c < (grid[r] || []).length; c++) if (grid[r][c] === cell) return { row: r, col: c, grid };
|
|
948
|
+
}
|
|
949
|
+
return { row: 0, col: 0, grid };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const cellCovering = (row, col) => {
|
|
953
|
+
const table = row.closest('table'), grid = tableGrid(table);
|
|
954
|
+
const r = rowsOf(table).indexOf(row);
|
|
955
|
+
return (grid[r] || [])[col] || row.lastElementChild;
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const columnCount = (table) => Math.max(0, ...tableGrid(table).map(r => r.length));
|
|
959
|
+
|
|
960
|
+
// Short rows are padded back out
|
|
961
|
+
function normalizeTables(root) {
|
|
962
|
+
root.querySelectorAll('table').forEach(table => {
|
|
963
|
+
const rows = rowsOf(table); if (!rows.length) return;
|
|
964
|
+
const grid = tableGrid(table);
|
|
965
|
+
const width = Math.max(0, ...grid.map(r => r.length));
|
|
966
|
+
rows.forEach((row, r) => {
|
|
967
|
+
const have = (grid[r] || []).filter(Boolean).length;
|
|
968
|
+
for (let i = have; i < width; i++) row.appendChild(emptyCell(rowCellTag(row)));
|
|
969
|
+
});
|
|
970
|
+
if (!table.querySelector('tbody, thead')) {
|
|
971
|
+
const body = document.createElement('tbody');
|
|
972
|
+
while (table.firstChild) body.appendChild(table.firstChild);
|
|
973
|
+
table.appendChild(body);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function addRow(where) {
|
|
979
|
+
const cell = cellAt(); if (!cell) return;
|
|
980
|
+
const row = cell.parentElement, table = row.closest('table');
|
|
981
|
+
const fresh = document.createElement('tr');
|
|
982
|
+
for (let i = 0; i < columnCount(table); i++) fresh.appendChild(emptyCell());
|
|
983
|
+
if (where < 0) row.before(fresh); else row.after(fresh);
|
|
984
|
+
caretIn(fresh.firstElementChild, 0);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function addColumn(where) {
|
|
988
|
+
const cell = cellAt(); if (!cell) return;
|
|
989
|
+
const table = cell.closest('table'), { col, grid } = gridPosition(cell);
|
|
990
|
+
const seen = new Set();
|
|
991
|
+
rowsOf(table).forEach((row, r) => {
|
|
992
|
+
const at = (grid[r] || [])[col];
|
|
993
|
+
if (!at || seen.has(at)) return;
|
|
994
|
+
seen.add(at);
|
|
995
|
+
const fresh = document.createElement(at.tagName === 'TH' ? 'th' : 'td');
|
|
996
|
+
fresh.appendChild(document.createElement('br'));
|
|
997
|
+
if (where < 0) at.before(fresh); else at.after(fresh);
|
|
998
|
+
});
|
|
999
|
+
normalizeTables(table.parentElement || table);
|
|
1000
|
+
caretIn(cellCovering(rowsOf(table)[gridPosition(cell).row], where < 0 ? col : col + 1), 0);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function removeRow() {
|
|
1004
|
+
const cell = cellAt(); if (!cell) return;
|
|
1005
|
+
const row = cell.parentElement, table = row.closest('table');
|
|
1006
|
+
if (rowsOf(table).length <= 1) return removeTable();
|
|
1007
|
+
const next = row.nextElementSibling || row.previousElementSibling;
|
|
1008
|
+
row.remove();
|
|
1009
|
+
caretIn(next.firstElementChild, 0);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function removeColumn() {
|
|
1013
|
+
const cell = cellAt(); if (!cell) return;
|
|
1014
|
+
const table = cell.closest('table'), { col, grid } = gridPosition(cell);
|
|
1015
|
+
if (columnCount(table) <= 1) return removeTable();
|
|
1016
|
+
const seen = new Set();
|
|
1017
|
+
grid.forEach(row => { const at = row[col]; if (!at || seen.has(at)) return; seen.add(at); at.remove(); });
|
|
1018
|
+
normalizeTables(table.parentElement || table);
|
|
1019
|
+
caretIn(cellCovering(rowsOf(table)[0], Math.max(0, col - 1)), 0);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function removeTable() {
|
|
1023
|
+
const table = tableAt(); if (!table) return;
|
|
1024
|
+
const p = document.createElement('p');
|
|
1025
|
+
table.replaceWith(p);
|
|
1026
|
+
caretIn(p, 0);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Selected cells merge as a rectangle; a lone caret merges with the cell to its right
|
|
1030
|
+
function mergeCells() {
|
|
1031
|
+
const cell = cellAt(); if (!cell) return;
|
|
1032
|
+
const picked = selectedCells();
|
|
1033
|
+
const cells = picked.length > 1 ? picked : [cell, nextCell(cell)].filter(Boolean);
|
|
1034
|
+
if (cells.length < 2) return;
|
|
1035
|
+
|
|
1036
|
+
const at = cells.map(gridPosition);
|
|
1037
|
+
const r0 = Math.min(...at.map(p => p.row));
|
|
1038
|
+
const r1 = Math.max(...at.map((p, i) => p.row + (cells[i].rowSpan || 1) - 1));
|
|
1039
|
+
const c0 = Math.min(...at.map(p => p.col));
|
|
1040
|
+
const c1 = Math.max(...at.map((p, i) => p.col + (cells[i].colSpan || 1) - 1));
|
|
1041
|
+
|
|
1042
|
+
const grid = at[0].grid;
|
|
1043
|
+
const first = (grid[r0] || [])[c0] || cells[0];
|
|
1044
|
+
const cols = c1 - c0 + 1, rows = r1 - r0 + 1;
|
|
1045
|
+
if (cols > 1) first.colSpan = cols; else first.removeAttribute('colspan');
|
|
1046
|
+
if (rows > 1) first.rowSpan = rows; else first.removeAttribute('rowspan');
|
|
1047
|
+
|
|
1048
|
+
cells.forEach(c => {
|
|
1049
|
+
if (c === first) return;
|
|
1050
|
+
if (c.textContent.trim()) first.append(' ', ...c.childNodes);
|
|
1051
|
+
c.remove();
|
|
1052
|
+
});
|
|
1053
|
+
tidyCell(first);
|
|
1054
|
+
const table = first.closest('table');
|
|
1055
|
+
normalizeTables(table.parentElement || table);
|
|
1056
|
+
cellDrag = null;
|
|
1057
|
+
paintCells(first.closest('[data-rich-text]') || table);
|
|
1058
|
+
caretIn(first, first.textContent.length);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const nextCell = (cell) => {
|
|
1062
|
+
const { row, col, grid } = gridPosition(cell);
|
|
1063
|
+
const span = cell.colSpan || 1;
|
|
1064
|
+
return (grid[row] || [])[col + span] || null;
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const tidyCell = (cell) => {
|
|
1068
|
+
if (!cell.textContent.trim()) { cell.replaceChildren(document.createElement('br')); return; }
|
|
1069
|
+
cell.querySelectorAll(':scope > br').forEach(br => br.remove());
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
function splitCell() {
|
|
1073
|
+
const cell = cellAt(); if (!cell) return;
|
|
1074
|
+
const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
|
|
1075
|
+
if (cols < 2 && rows < 2) return;
|
|
1076
|
+
cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
|
|
1077
|
+
// A header cell splits into header cells; rows below take their row's kind
|
|
1078
|
+
for (let i = 1; i < cols; i++) cell.after(emptyCell(cell.tagName.toLowerCase()));
|
|
1079
|
+
if (rows > 1) {
|
|
1080
|
+
const table = cell.closest('table'), { row, col } = gridPosition(cell);
|
|
1081
|
+
for (let r = 1; r < rows; r++) {
|
|
1082
|
+
const target = rowsOf(table)[row + r]; if (!target) break;
|
|
1083
|
+
const at = cellCovering(target, col);
|
|
1084
|
+
const fresh = emptyCell(rowCellTag(target));
|
|
1085
|
+
if (at) at.before(fresh); else target.appendChild(fresh);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
normalizeTables(cell.closest('table').parentElement || cell.closest('table'));
|
|
1089
|
+
caretIn(cell, 0);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Empty cells hold no text, so cell selection follows the pointer
|
|
1093
|
+
const cellUnder = (x, y) => {
|
|
1094
|
+
const el = document.elementFromPoint(x, y);
|
|
1095
|
+
const cell = el && el.closest && el.closest('td, th');
|
|
1096
|
+
return cell && cell.closest('[data-rich-text]') ? cell : null;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
function rectBetween(a, b) {
|
|
1100
|
+
if (!a || !b) return [];
|
|
1101
|
+
const pa = gridPosition(a), pb = gridPosition(b), grid = pa.grid;
|
|
1102
|
+
const r0 = Math.min(pa.row, pb.row), r1 = Math.max(pa.row, pb.row);
|
|
1103
|
+
const c0 = Math.min(pa.col, pb.col), c1 = Math.max(pa.col, pb.col);
|
|
1104
|
+
const out = [];
|
|
1105
|
+
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
|
|
1106
|
+
const cell = (grid[r] || [])[c];
|
|
1107
|
+
if (cell && !out.includes(cell)) out.push(cell);
|
|
1108
|
+
}
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function selectedCells() {
|
|
1113
|
+
if (cellDrag && cellDrag.anchor !== cellDrag.focus && cellDrag.anchor.isConnected && cellDrag.focus.isConnected) {
|
|
1114
|
+
return rectBetween(cellDrag.anchor, cellDrag.focus);
|
|
1115
|
+
}
|
|
1116
|
+
const table = tableAt(); if (!table) return [];
|
|
1117
|
+
const s = sel(); if (!s || !s.anchorNode || !s.focusNode) return [];
|
|
1118
|
+
const from = elementAt(s.anchorNode), to = elementAt(s.focusNode);
|
|
1119
|
+
const a = from && from.closest('td, th'), b = to && to.closest('td, th');
|
|
1120
|
+
if (!a || !b || a === b) return [];
|
|
1121
|
+
return rectBetween(a, b);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function toggleHeaderRow() {
|
|
1125
|
+
const table = tableAt(); if (!table) return;
|
|
1126
|
+
const first = rowsOf(table)[0]; if (!first) return;
|
|
1127
|
+
const isHeader = first.firstElementChild && first.firstElementChild.tagName === 'TH';
|
|
1128
|
+
[...first.children].forEach(c => {
|
|
1129
|
+
const next = document.createElement(isHeader ? 'td' : 'th');
|
|
1130
|
+
while (c.firstChild) next.appendChild(c.firstChild);
|
|
1131
|
+
if (c.colSpan > 1) next.colSpan = c.colSpan;
|
|
1132
|
+
if (c.rowSpan > 1) next.rowSpan = c.rowSpan;
|
|
1133
|
+
c.replaceWith(next);
|
|
1134
|
+
});
|
|
1135
|
+
// <thead> is a child of the table, never of the tbody
|
|
1136
|
+
const head = table.querySelector('thead');
|
|
1137
|
+
if (!isHeader && !head) {
|
|
1138
|
+
const t = document.createElement('thead');
|
|
1139
|
+
table.prepend(t);
|
|
1140
|
+
t.appendChild(first);
|
|
1141
|
+
}
|
|
1142
|
+
if (isHeader && head) {
|
|
1143
|
+
let body = table.querySelector('tbody');
|
|
1144
|
+
if (!body) { body = document.createElement('tbody'); table.appendChild(body); }
|
|
1145
|
+
body.prepend(first);
|
|
1146
|
+
head.remove();
|
|
1147
|
+
}
|
|
1148
|
+
caretIn(table.rows[0].firstElementChild, 0);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/* ---- Column and row sizing ----
|
|
1152
|
+
Widths on a <colgroup>, heights on the row. */
|
|
1153
|
+
const EDGE = 5;
|
|
1154
|
+
|
|
1155
|
+
function ensureCols(table) {
|
|
1156
|
+
let group = table.querySelector(':scope > colgroup');
|
|
1157
|
+
if (!group) { group = document.createElement('colgroup'); table.prepend(group); }
|
|
1158
|
+
const want = columnCount(table);
|
|
1159
|
+
while (group.children.length < want) group.appendChild(document.createElement('col'));
|
|
1160
|
+
while (group.children.length > want) group.lastElementChild.remove();
|
|
1161
|
+
// Freeze current widths so one drag doesn't reflow the rest
|
|
1162
|
+
[...group.children].forEach((col, i) => {
|
|
1163
|
+
if (col.style.width) return;
|
|
1164
|
+
const at = (tableGrid(table)[0] || [])[i];
|
|
1165
|
+
if (at) col.style.width = Math.round(at.getBoundingClientRect().width / (at.colSpan || 1)) + 'px';
|
|
1166
|
+
});
|
|
1167
|
+
return group;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function borderAt(x, y) {
|
|
1171
|
+
const el = document.elementFromPoint(x, y);
|
|
1172
|
+
let cell = el && el.closest && el.closest('td, th');
|
|
1173
|
+
if (!cell) {
|
|
1174
|
+
// Outer edge: on the table, or just past it
|
|
1175
|
+
const scope = el && el.closest && (el.closest('table') || el.closest('[data-rich-text]'));
|
|
1176
|
+
if (!scope) return null;
|
|
1177
|
+
const tables = scope.tagName === 'TABLE' ? [scope] : [...scope.querySelectorAll('table')];
|
|
1178
|
+
for (const table of tables) {
|
|
1179
|
+
const box = table.getBoundingClientRect();
|
|
1180
|
+
const row = [...table.rows].find(rw => { const b = rw.getBoundingClientRect(); return y >= b.top - EDGE && y <= b.bottom + EDGE; });
|
|
1181
|
+
if (!row || !row.cells.length) continue;
|
|
1182
|
+
if (Math.abs(x - box.right) <= EDGE) return { axis: 'x', cell: row.cells[row.cells.length - 1] };
|
|
1183
|
+
if (Math.abs(y - box.bottom) <= EDGE && x >= box.left && x <= box.right) return { axis: 'y', cell: row.cells[0] };
|
|
1184
|
+
}
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
if (!cell.closest('[data-rich-text]')) return null;
|
|
1188
|
+
const b = cell.getBoundingClientRect();
|
|
1189
|
+
if (Math.abs(x - b.right) <= EDGE) return { axis: 'x', cell };
|
|
1190
|
+
if (Math.abs(x - b.left) <= EDGE) { const prev = previousCell(cell); return prev ? { axis: 'x', cell: prev } : null; }
|
|
1191
|
+
if (Math.abs(y - b.bottom) <= EDGE) return { axis: 'y', cell };
|
|
1192
|
+
if (Math.abs(y - b.top) <= EDGE) { const row = cell.parentElement.previousElementSibling; return row ? { axis: 'y', cell: row.cells[0] } : null; }
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function startResize(e, hit) {
|
|
1197
|
+
const cell = hit.cell, table = cell.closest('table');
|
|
1198
|
+
const area = cell.closest('[data-rich-text]');
|
|
1199
|
+
e.preventDefault();
|
|
1200
|
+
area.setAttribute('data-rich-text-resizing', hit.axis);
|
|
1201
|
+
|
|
1202
|
+
let apply;
|
|
1203
|
+
if (hit.axis === 'x') {
|
|
1204
|
+
const group = ensureCols(table);
|
|
1205
|
+
const { col } = gridPosition(cell);
|
|
1206
|
+
const index = Math.min(col + (cell.colSpan || 1) - 1, group.children.length - 1);
|
|
1207
|
+
const target = group.children[index];
|
|
1208
|
+
const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
|
|
1209
|
+
const from = e.clientX;
|
|
1210
|
+
// The outer border sizes the table too
|
|
1211
|
+
const outer = index === group.children.length - 1;
|
|
1212
|
+
const tableStart = table.getBoundingClientRect().width;
|
|
1213
|
+
if (outer) table.style.width = Math.round(tableStart) + 'px';
|
|
1214
|
+
apply = (ev) => {
|
|
1215
|
+
const delta = ev.clientX - from;
|
|
1216
|
+
target.style.width = Math.max(28, Math.round(start + delta)) + 'px';
|
|
1217
|
+
if (outer) table.style.width = Math.max(60, Math.round(tableStart + delta)) + 'px';
|
|
1218
|
+
};
|
|
1219
|
+
} else {
|
|
1220
|
+
const row = cell.parentElement;
|
|
1221
|
+
const start = row.getBoundingClientRect().height, from = e.clientY;
|
|
1222
|
+
apply = (ev) => { row.style.height = Math.max(20, Math.round(start + ev.clientY - from)) + 'px'; };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const move = (ev) => { ev.preventDefault(); apply(ev); };
|
|
1226
|
+
const up = () => {
|
|
1227
|
+
document.removeEventListener('pointermove', move);
|
|
1228
|
+
document.removeEventListener('pointerup', up);
|
|
1229
|
+
area.removeAttribute('data-rich-text-resizing');
|
|
1230
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1231
|
+
};
|
|
1232
|
+
document.addEventListener('pointermove', move);
|
|
1233
|
+
document.addEventListener('pointerup', up);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Tab walks the grid and grows it at the end
|
|
1237
|
+
function moveCell(dir) {
|
|
1238
|
+
const cell = cellAt(); if (!cell) return false;
|
|
1239
|
+
const cells = [...cell.closest('table').querySelectorAll('td, th')];
|
|
1240
|
+
let next = cells[cells.indexOf(cell) + dir];
|
|
1241
|
+
if (!next && dir > 0) {
|
|
1242
|
+
const row = cell.closest('tr'), fresh = row.cloneNode(true);
|
|
1243
|
+
[...fresh.children].forEach(c => { c.innerHTML = '<br>'; c.removeAttribute('colspan'); c.removeAttribute('rowspan'); });
|
|
1244
|
+
row.parentNode.appendChild(fresh);
|
|
1245
|
+
next = fresh.children[0];
|
|
1246
|
+
}
|
|
1247
|
+
if (!next) return false;
|
|
1248
|
+
caretIn(next, 0);
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function caretAtPoint(x, y) {
|
|
1253
|
+
if (document.caretPositionFromPoint) {
|
|
1254
|
+
const pos = document.caretPositionFromPoint(x, y);
|
|
1255
|
+
return pos ? { node: pos.offsetNode, offset: pos.offset } : null;
|
|
1256
|
+
}
|
|
1257
|
+
if (document.caretRangeFromPoint) {
|
|
1258
|
+
const r = document.caretRangeFromPoint(x, y);
|
|
1259
|
+
return r ? { node: r.startContainer, offset: r.startOffset } : null;
|
|
1260
|
+
}
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function placeAtPoint(x, y) {
|
|
1265
|
+
const hit = caretAtPoint(x, y); if (!hit) return null;
|
|
1266
|
+
const r = document.createRange();
|
|
1267
|
+
try { r.setStart(hit.node, hit.offset); } catch { return null; }
|
|
1268
|
+
r.collapse(true);
|
|
1269
|
+
const s = sel(); s.removeAllRanges(); s.addRange(r);
|
|
1270
|
+
return hit;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const cellOf = (node) => { const el = elementAt(node); return el && el.closest('td, th'); };
|
|
1274
|
+
|
|
1275
|
+
// Arrows move by line within a cell, then cell to cell, keeping the column
|
|
1276
|
+
function arrowInTable(e) {
|
|
1277
|
+
const cell = cellAt(); if (!cell) return false;
|
|
1278
|
+
const r = range(); if (!r || !r.collapsed) return false;
|
|
1279
|
+
const here = r.getBoundingClientRect();
|
|
1280
|
+
|
|
1281
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
1282
|
+
const at = offsetIn(cell), len = cell.textContent.length;
|
|
1283
|
+
if (e.key === 'ArrowRight' && at < len) return false;
|
|
1284
|
+
if (e.key === 'ArrowLeft' && at > 0) return false;
|
|
1285
|
+
const next = e.key === 'ArrowRight' ? nextCell(cell) : previousCell(cell);
|
|
1286
|
+
if (!next) return e.key === 'ArrowRight' ? moveCell(1) : moveCell(-1);
|
|
1287
|
+
caretIn(next, e.key === 'ArrowRight' ? 0 : next.textContent.length);
|
|
1288
|
+
return true;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const down = e.key === 'ArrowDown';
|
|
1292
|
+
const line = here.height || parseFloat(getComputedStyle(cell).lineHeight) || 16;
|
|
1293
|
+
const x = here.left;
|
|
1294
|
+
const probeY = down ? here.bottom + line * 0.5 : here.top - line * 0.5;
|
|
1295
|
+
|
|
1296
|
+
// Another line in this cell means the native move is right
|
|
1297
|
+
const ahead = caretAtPoint(x, probeY);
|
|
1298
|
+
if (ahead && cellOf(ahead.node) === cell) {
|
|
1299
|
+
const probe = document.createRange();
|
|
1300
|
+
let onAnotherLine = false;
|
|
1301
|
+
try {
|
|
1302
|
+
probe.setStart(ahead.node, ahead.offset); probe.collapse(true);
|
|
1303
|
+
const box = probe.getBoundingClientRect();
|
|
1304
|
+
onAnotherLine = !!box.height && Math.abs(box.top - here.top) > 2;
|
|
1305
|
+
} catch { }
|
|
1306
|
+
if (onAnotherLine) return false;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const table = cell.closest('table'), { row, col, grid } = gridPosition(cell);
|
|
1310
|
+
const targetRow = row + (down ? (cell.rowSpan || 1) : -1);
|
|
1311
|
+
const next = (grid[targetRow] || [])[col];
|
|
1312
|
+
if (!next) return escapeTable(table, down);
|
|
1313
|
+
|
|
1314
|
+
const box = next.getBoundingClientRect();
|
|
1315
|
+
const aimY = down ? box.top + line * 0.5 : box.bottom - line * 0.5;
|
|
1316
|
+
if (placeAtPoint(Math.min(Math.max(x, box.left + 2), box.right - 2), aimY)) return true;
|
|
1317
|
+
caretIn(next, down ? 0 : next.textContent.length);
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const previousCell = (cell) => {
|
|
1322
|
+
const { row, col, grid } = gridPosition(cell);
|
|
1323
|
+
for (let c = col - 1; c >= 0; c--) if ((grid[row] || [])[c] && grid[row][c] !== cell) return grid[row][c];
|
|
1324
|
+
return null;
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
function escapeTable(table, down) {
|
|
1328
|
+
const sibling = down ? table.nextElementSibling : table.previousElementSibling;
|
|
1329
|
+
if (sibling) { caretIn(sibling, down ? 0 : sibling.textContent.length); return true; }
|
|
1330
|
+
if (!down) return false;
|
|
1331
|
+
const p = document.createElement('p');
|
|
1332
|
+
table.after(p);
|
|
1333
|
+
caretIn(p, 0);
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/* ---- Lists ---- */
|
|
1338
|
+
function retagList(list, tag) {
|
|
1339
|
+
const n = document.createElement(tag);
|
|
1340
|
+
while (list.firstChild) n.appendChild(list.firstChild);
|
|
1341
|
+
list.replaceWith(n);
|
|
1342
|
+
return n;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// An item (li, dt, dd) out of its list into a <p>; following items keep their own list
|
|
1346
|
+
function liftItem(item) {
|
|
1347
|
+
const list = item.parentElement, off = offsetIn(item);
|
|
1348
|
+
const p = document.createElement('p');
|
|
1349
|
+
[...item.childNodes].forEach(n => { if (n.tagName !== 'INPUT') p.appendChild(n); });
|
|
1350
|
+
const after = [...list.children].slice([...list.children].indexOf(item) + 1);
|
|
1351
|
+
list.after(p);
|
|
1352
|
+
if (after.length) {
|
|
1353
|
+
const tail = document.createElement(list.tagName.toLowerCase());
|
|
1354
|
+
if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
|
|
1355
|
+
after.forEach(n => tail.appendChild(n));
|
|
1356
|
+
p.after(tail);
|
|
1357
|
+
}
|
|
1358
|
+
item.remove();
|
|
1359
|
+
if (!list.children.length) list.remove();
|
|
1360
|
+
caretIn(p, off);
|
|
1361
|
+
return p;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function toggleList(tag) {
|
|
1365
|
+
const li = ancestor('li');
|
|
1366
|
+
if (li) {
|
|
1367
|
+
const list = li.closest('ul, ol');
|
|
1368
|
+
if (list.tagName.toLowerCase() === tag) return liftItem(li);
|
|
1369
|
+
const next = retagList(list, tag);
|
|
1370
|
+
next.removeAttribute('data-checklist');
|
|
1371
|
+
next.querySelectorAll('input[type=checkbox]').forEach(b => b.remove());
|
|
1372
|
+
caretIn(next.querySelector('li') || next, 0);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const b = freeBlock(); if (!b) return;
|
|
1376
|
+
const off = offsetIn(b);
|
|
1377
|
+
const list = document.createElement(tag), item = document.createElement('li');
|
|
1378
|
+
while (b.firstChild) item.appendChild(b.firstChild);
|
|
1379
|
+
list.appendChild(item);
|
|
1380
|
+
b.replaceWith(list);
|
|
1381
|
+
caretIn(item, off);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// .li: into the list above when there is one, else a new bulleted list
|
|
1385
|
+
function toggleItem() {
|
|
1386
|
+
const li = ancestor('li');
|
|
1387
|
+
if (li) return liftItem(li);
|
|
1388
|
+
const b = freeBlock(); if (!b) return;
|
|
1389
|
+
const prev = b.previousElementSibling;
|
|
1390
|
+
if (!prev || !/^(UL|OL)$/.test(prev.tagName)) return toggleList('ul');
|
|
1391
|
+
const off = offsetIn(b), item = document.createElement('li');
|
|
1392
|
+
if (prev.hasAttribute('data-checklist')) item.appendChild(Object.assign(document.createElement('input'), { type: 'checkbox' }));
|
|
1393
|
+
while (b.firstChild) item.appendChild(b.firstChild);
|
|
1394
|
+
prev.appendChild(item);
|
|
1395
|
+
b.remove();
|
|
1396
|
+
caretIn(item, off);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function toggleChecklist() {
|
|
1400
|
+
if (!ancestor('li')) toggleList('ul');
|
|
1401
|
+
const item = ancestor('li'); if (!item) return;
|
|
1402
|
+
const list = item.closest('ul, ol'); if (!list) return;
|
|
1403
|
+
const on = !list.hasAttribute('data-checklist');
|
|
1404
|
+
list.toggleAttribute('data-checklist', on);
|
|
1405
|
+
[...list.children].forEach(row => {
|
|
1406
|
+
const box = row.querySelector(':scope > input[type=checkbox]');
|
|
1407
|
+
if (on && !box) { const b = document.createElement('input'); b.type = 'checkbox'; row.insertBefore(b, row.firstChild); }
|
|
1408
|
+
if (!on && box) box.remove();
|
|
1409
|
+
});
|
|
1410
|
+
// Caret after the checkbox, not before it
|
|
1411
|
+
const box = item.querySelector(':scope > input[type=checkbox]');
|
|
1412
|
+
if (!box) return caretIn(item, 0);
|
|
1413
|
+
if (!item.textContent.length && !item.querySelector('br')) item.appendChild(document.createElement('br'));
|
|
1414
|
+
const sl = sel(), r = document.createRange();
|
|
1415
|
+
r.setStartAfter(box); r.collapse(true);
|
|
1416
|
+
sl.removeAllRanges(); sl.addRange(r);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Sublists belong inside the preceding item; task items keep one leading box
|
|
1420
|
+
function normalizeLists(root) {
|
|
1421
|
+
root.querySelectorAll(':is(ul, ol) > :is(ul, ol)').forEach(nested => {
|
|
1422
|
+
const prev = nested.previousElementSibling;
|
|
1423
|
+
if (prev && prev.tagName === 'LI') return prev.appendChild(nested);
|
|
1424
|
+
const li = document.createElement('li');
|
|
1425
|
+
nested.parentNode.insertBefore(li, nested);
|
|
1426
|
+
li.appendChild(nested);
|
|
1427
|
+
});
|
|
1428
|
+
root.querySelectorAll('[data-checklist] > li').forEach(li => {
|
|
1429
|
+
const boxes = [...li.querySelectorAll(':scope > input[type=checkbox]')];
|
|
1430
|
+
boxes.slice(1).forEach(b => b.remove());
|
|
1431
|
+
const box = boxes[0] || Object.assign(document.createElement('input'), { type: 'checkbox' });
|
|
1432
|
+
if (li.firstChild !== box) li.insertBefore(box, li.firstChild);
|
|
1433
|
+
if (li.textContent.length) li.querySelectorAll(':scope > br').forEach(b => b.remove());
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Nest at most one level below the item above
|
|
1438
|
+
function moveItem(by) {
|
|
1439
|
+
const li = ancestor('li'); if (!li) return;
|
|
1440
|
+
const list = li.parentElement, off = offsetIn(li);
|
|
1441
|
+
if (by > 0) {
|
|
1442
|
+
const prev = li.previousElementSibling;
|
|
1443
|
+
if (!prev || prev.tagName !== 'LI') return;
|
|
1444
|
+
let sub = prev.querySelector(':scope > ul, :scope > ol');
|
|
1445
|
+
if (!sub) { sub = document.createElement(list.tagName.toLowerCase()); prev.appendChild(sub); }
|
|
1446
|
+
sub.appendChild(li);
|
|
1447
|
+
} else {
|
|
1448
|
+
const parentItem = list.parentElement;
|
|
1449
|
+
if (!parentItem || parentItem.tagName !== 'LI') return;
|
|
1450
|
+
parentItem.parentElement.insertBefore(li, parentItem.nextSibling);
|
|
1451
|
+
if (!list.children.length) list.remove();
|
|
1452
|
+
}
|
|
1453
|
+
if (off != null) caretIn(li, off); // the caret stays in the item's text, so Tab and Shift+Tab chain
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function canIndent(area) {
|
|
1457
|
+
const li = ancestor('li');
|
|
1458
|
+
if (li) return !!li.previousElementSibling;
|
|
1459
|
+
return area._rt.format === 'html';
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function canOutdent(area) {
|
|
1463
|
+
const li = ancestor('li');
|
|
1464
|
+
if (li) return li.closest('ul, ol').parentElement.tagName === 'LI';
|
|
1465
|
+
const b = blockEl();
|
|
1466
|
+
return area._rt.format === 'html' && !!b && parseFloat(b.style.marginInlineStart) > 0;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const STEP = 2; // rem per indent level
|
|
1470
|
+
// List items nest; other blocks indent by margin (.html only)
|
|
1471
|
+
function indent(by, area) {
|
|
1472
|
+
if (inList()) return moveItem(by);
|
|
1473
|
+
if (area && area._rt.format !== 'html') return;
|
|
1474
|
+
const b = blockEl(); if (!b) return;
|
|
1475
|
+
const now = parseFloat(b.style.marginInlineStart) || 0;
|
|
1476
|
+
const next = Math.max(0, now + by * STEP);
|
|
1477
|
+
if (next) b.style.marginInlineStart = next + 'rem'; else b.style.removeProperty('margin-inline-start');
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/* ---- Links ---- */
|
|
1481
|
+
// Scheme, relative path, anchor, or bare domain (gets https://); anything else is refused
|
|
1482
|
+
function normalizeUrl(url) {
|
|
1483
|
+
if (/\s/.test(url) || unsafeUrl(url)) return null;
|
|
1484
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
|
|
1485
|
+
if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
// An area that is, or sits in, a link takes no links: they cannot nest
|
|
1489
|
+
// Links cannot go inside a link or a button
|
|
1490
|
+
const linkHost = (area) => !!(area && area.closest('a, button'));
|
|
1491
|
+
const unwrapEl = (el) => el.replaceWith(...el.childNodes);
|
|
1492
|
+
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; };
|
|
1493
|
+
const sameEl = (a, b) => !!a && !!b && a.nodeType === 1 && b.nodeType === 1 && a.tagName === b.tagName
|
|
1494
|
+
&& a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
|
|
1495
|
+
// Links go outermost: a link inside formatting swaps with it (<b><a>x</a></b> → <a><b>x</b></a>);
|
|
1496
|
+
// then identical neighbours merge and links left without content go. The selection is kept.
|
|
1497
|
+
function tidyLinks(area) {
|
|
1498
|
+
const r = range(), keep = r && area.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
|
|
1499
|
+
for (let guard = 0, moved = true; moved && guard < 50; guard++) {
|
|
1500
|
+
moved = false;
|
|
1501
|
+
[...area.querySelectorAll('a')].forEach(a => {
|
|
1502
|
+
const w = a.parentElement;
|
|
1503
|
+
if (!a.isConnected || !w || w === area || !area.contains(w) || w.closest('a') || !INLINE_OK.has(w.tagName)) return;
|
|
1504
|
+
w.replaceWith(...[...w.childNodes].map(n => {
|
|
1505
|
+
const wc = w.cloneNode(false);
|
|
1506
|
+
if (n.nodeType === 1 && n.tagName === 'A') { wc.append(...n.childNodes); n.appendChild(wc); return n; }
|
|
1507
|
+
wc.appendChild(n); return wc;
|
|
1508
|
+
}));
|
|
1509
|
+
moved = true;
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
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(); } };
|
|
1513
|
+
for (let i = 0; i < 3; i++) merge(area);
|
|
1514
|
+
area.querySelectorAll('a').forEach(a => { if (!a.textContent.trim() && !a.querySelector('img')) unwrapEl(a); });
|
|
1515
|
+
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 { }
|
|
1516
|
+
}
|
|
1517
|
+
function setLink(href, area) {
|
|
1518
|
+
if (linkHost(area)) return;
|
|
1519
|
+
const existing = ancestor('a'), r = range();
|
|
1520
|
+
const raw = href == null ? '' : String(href).trim();
|
|
1521
|
+
const url = raw ? normalizeUrl(raw) : '';
|
|
1522
|
+
if (raw && !url) return;
|
|
1523
|
+
// A caret in a link retargets or unlinks all of it
|
|
1524
|
+
if (r && r.collapsed) {
|
|
1525
|
+
if (existing) { if (url) existing.setAttribute('href', url); else unwrapEl(existing); return; }
|
|
1526
|
+
if (url) insertHTML(`<a href="${escHtml(url)}">${escHtml(url)}</a>`);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (!r) return;
|
|
1530
|
+
// A selection: each link it touches keeps its unselected text; the selection loses its link, or gets the new one
|
|
1531
|
+
const nodes = selectedTextNodes(); if (!nodes.length) return;
|
|
1532
|
+
const touched = [...new Set(nodes.map(n => elementAt(n) && elementAt(n).closest('a')).filter(a => a && withinArea(a, a)))];
|
|
1533
|
+
touched.forEach(a => {
|
|
1534
|
+
const old = a.getAttribute('href'), keep = textIn(a).filter(t => !nodes.includes(t) && t.nodeValue.length);
|
|
1535
|
+
unwrapEl(a);
|
|
1536
|
+
keep.forEach(t => { const x = document.createElement('a'); if (old != null) x.setAttribute('href', old); t.before(x); x.appendChild(t); });
|
|
1537
|
+
});
|
|
1538
|
+
const s = sel(), nr = document.createRange(), last = nodes[nodes.length - 1];
|
|
1539
|
+
nr.setStart(nodes[0], 0); nr.setEnd(last, last.length);
|
|
1540
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1541
|
+
if (url) wrapInline('a').forEach(a => a.setAttribute('href', url));
|
|
1542
|
+
const root = area || elementAt(nodes[0]).closest('[data-rich-text]');
|
|
1543
|
+
if (root) tidyLinks(root);
|
|
1544
|
+
}
|
|
1545
|
+
const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
|
|
1546
|
+
|
|
1547
|
+
/* ---- Insertions ---- */
|
|
1548
|
+
function insertHTML(html) { document.execCommand('insertHTML', false, html); }
|
|
1549
|
+
|
|
1550
|
+
const PLAINTEXT_ONLY = (() => {
|
|
1551
|
+
try { const d = document.createElement('div'); d.contentEditable = 'plaintext-only'; return d.contentEditable === 'plaintext-only'; } catch { return false; }
|
|
1552
|
+
})();
|
|
1553
|
+
|
|
1554
|
+
// Lines as <br>, inserted by hand
|
|
1555
|
+
function insertPlain(area, text) {
|
|
1556
|
+
const frag = document.createDocumentFragment();
|
|
1557
|
+
String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
|
|
1558
|
+
if (i) frag.appendChild(document.createElement('br'));
|
|
1559
|
+
if (line) frag.appendChild(document.createTextNode(line));
|
|
1560
|
+
});
|
|
1561
|
+
insertNodes(area, frag);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Inserted by hand, so the browser adds no style wrappers of its own
|
|
1565
|
+
function insertNodes(area, frag) {
|
|
1566
|
+
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1567
|
+
// Pasted or dropped links never land inside a link
|
|
1568
|
+
const at = elementAt(r.startContainer);
|
|
1569
|
+
if (at && at.closest('a')) [...frag.querySelectorAll('a')].forEach(unwrapEl);
|
|
1570
|
+
if (!frag.lastChild) return;
|
|
1571
|
+
record(area);
|
|
1572
|
+
r.deleteContents();
|
|
1573
|
+
// A caret between a block field's blocks (an empty field clicked) goes into the block beside it
|
|
1574
|
+
const c0 = range();
|
|
1575
|
+
if (c0 && c0.startContainer === area && !(area._rt && area._rt.inline)) {
|
|
1576
|
+
const into = (b) => b && b.nodeType === 1 && /^(P|H[1-6]|BLOCKQUOTE|PRE)$/.test(b.tagName);
|
|
1577
|
+
const before = area.childNodes[c0.startOffset - 1], after = area.childNodes[c0.startOffset];
|
|
1578
|
+
if (into(before)) caretIn(before, before.textContent.length); else if (into(after)) caretIn(after, 0);
|
|
1579
|
+
}
|
|
1580
|
+
// An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
|
|
1581
|
+
const holder = blockEl() || area;
|
|
1582
|
+
if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
|
|
1583
|
+
if (offsetIn(holder) === 0) {
|
|
1584
|
+
const w = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT), first = w.nextNode();
|
|
1585
|
+
if (first) first.nodeValue = first.nodeValue.replace(/^\s+/, '');
|
|
1586
|
+
}
|
|
1587
|
+
let end = frag.lastChild;
|
|
1588
|
+
if (!end) return;
|
|
1589
|
+
const lastTop = end;
|
|
1590
|
+
const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
|
|
1591
|
+
// Pasted content ending in a mark: the caret sits after it, as after a conversion
|
|
1592
|
+
if (lastTop.nodeType === 1 && INLINE_OK.has(lastTop.tagName) && !/^(BR|WBR|IMG)$/.test(lastTop.tagName) && lastTop.isConnected) {
|
|
1593
|
+
caretAt(tailOf(lastTop), 0);
|
|
1594
|
+
area._exit = lastTop;
|
|
1595
|
+
record(area);
|
|
1596
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
// The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
|
|
1600
|
+
const s = sel(), nr = document.createRange();
|
|
1601
|
+
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; }
|
|
1602
|
+
if (end.nodeType === 3) nr.setStart(end, end.length); else nr.setStartAfter(end);
|
|
1603
|
+
nr.collapse(true);
|
|
1604
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1605
|
+
record(area);
|
|
1606
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// A selection across blocks is deleted as the browser would: the partly selected end
|
|
1610
|
+
// block merges into the start block (never across a table cell), emptied wrappers go.
|
|
1611
|
+
// A selection of everything leaves one empty paragraph.
|
|
1612
|
+
function clearSpan(area) {
|
|
1613
|
+
const r = range(); if (!r || r.collapsed || !area.contains(r.startContainer)) return;
|
|
1614
|
+
const texts = textIn(area).filter(t => r.intersectsNode(t) && t.nodeValue.length);
|
|
1615
|
+
const whole = document.createRange(); whole.selectNodeContents(area);
|
|
1616
|
+
const all = (r.compareBoundaryPoints(Range.START_TO_START, whole) <= 0 && r.compareBoundaryPoints(Range.END_TO_END, whole) >= 0)
|
|
1617
|
+
|| (texts.length && r.toString() === area.textContent && !area.querySelector('img, hr, table'));
|
|
1618
|
+
if (all) {
|
|
1619
|
+
area.innerHTML = '<p></p>'; caretIn(area.firstElementChild, 0); return;
|
|
1620
|
+
}
|
|
1621
|
+
const blockOf = (n) => { for (let e = elementAt(n); e && e !== area; e = e.parentElement) if (BLOCK.includes(e.tagName)) return e; return null; };
|
|
1622
|
+
// Boundaries on elements move into text, so the blocks they name are the ones holding text
|
|
1623
|
+
if (texts.length) {
|
|
1624
|
+
if (r.startContainer.nodeType !== 3) r.setStart(texts[0], 0);
|
|
1625
|
+
if (r.endContainer.nodeType !== 3) { const l = texts[texts.length - 1]; r.setEnd(l, l.length); }
|
|
1626
|
+
}
|
|
1627
|
+
const a = blockOf(r.startContainer), b = blockOf(r.endContainer), at = [r.startContainer, r.startOffset];
|
|
1628
|
+
r.deleteContents();
|
|
1629
|
+
if (a && b && a !== b && b.isConnected && !/^(TD|TH)$/.test(a.tagName) && !/^(TD|TH)$/.test(b.tagName) && !b.contains(a) && !a.contains(b)) {
|
|
1630
|
+
a.append(...b.childNodes);
|
|
1631
|
+
let gone = b;
|
|
1632
|
+
while (gone.parentElement && gone.parentElement !== area && !gone.parentElement.contains(a) && gone.parentElement.children.length === 1) gone = gone.parentElement;
|
|
1633
|
+
gone.remove();
|
|
1634
|
+
}
|
|
1635
|
+
// The caret stays where the selection started, inside its block (deleteContents leaves it between blocks)
|
|
1636
|
+
const c = document.createRange();
|
|
1637
|
+
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));
|
|
1638
|
+
else if (a && a.isConnected) c.selectNodeContents(a);
|
|
1639
|
+
else c.setStart(area, 0);
|
|
1640
|
+
c.collapse(c.startContainer === a ? false : true);
|
|
1641
|
+
const s = sel(); s.removeAllRanges(); s.addRange(c);
|
|
1642
|
+
}
|
|
1643
|
+
// A <br> at the caret; with nothing after it, a second one so the new line shows
|
|
1644
|
+
function insertBreak(area) {
|
|
1645
|
+
const r = range(); if (!r || !area.contains(r.startContainer)) return;
|
|
1646
|
+
r.deleteContents();
|
|
1647
|
+
const br = document.createElement('br');
|
|
1648
|
+
r.insertNode(br);
|
|
1649
|
+
const tail = document.createRange();
|
|
1650
|
+
tail.setStartAfter(br); tail.setEnd(area, area.childNodes.length);
|
|
1651
|
+
if (!tail.toString() && !tail.cloneContents().querySelector('br, img')) br.after(document.createElement('br'));
|
|
1652
|
+
const nr = document.createRange(); nr.setStartAfter(br); nr.collapse(true);
|
|
1653
|
+
const s = sel(); s.removeAllRanges(); s.addRange(nr);
|
|
1654
|
+
}
|
|
1655
|
+
const insertImage = (src, alt) => { if (src && !unsafeUrl(src, true)) insertHTML(`<img src="${escHtml(src)}" alt="${escHtml(alt || '')}">`); };
|
|
1656
|
+
|
|
1657
|
+
// Columns × rows: [3, 2], '3x2' or 3
|
|
1658
|
+
function insertTable(spec) {
|
|
1659
|
+
// Never nest: land after the outermost table
|
|
1660
|
+
let host = ancestor('table');
|
|
1661
|
+
while (host && host.parentElement && withinArea(host, host.parentElement.closest('table'))) host = host.parentElement.closest('table');
|
|
1662
|
+
if (host) { const p = document.createElement('p'); p.appendChild(document.createElement('br')); host.after(p); caretIn(p, 0); }
|
|
1663
|
+
if (Array.isArray(spec) && spec.length === 1) spec = spec[0]; // table('3x2'), table([3, 2])
|
|
1664
|
+
const parts = Array.isArray(spec) ? spec : String(spec == null ? '' : spec).split(/[x×,\s]+/);
|
|
1665
|
+
const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
|
|
1666
|
+
const cols = n(parts[0]), rows = n(parts[1]);
|
|
1667
|
+
const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
|
|
1668
|
+
insertHTML(`<table data-rich-text-new><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
|
|
1669
|
+
// The caret goes into the first cell, so typing and table commands start there
|
|
1670
|
+
const made = document.querySelector('table[data-rich-text-new]');
|
|
1671
|
+
if (made) { made.removeAttribute('data-rich-text-new'); const first = made.querySelector('td, th'); if (first) caretIn(first, 0); }
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// A trailing table, rule or similar block gets a line after it to land on
|
|
1675
|
+
// Text and inline marks sitting directly in a block field are wrapped into one <p> per run, caret
|
|
1676
|
+
// kept. Authored empty blocks (spacers, an empty figure) are left alone.
|
|
1677
|
+
function looseInline(area) {
|
|
1678
|
+
const loose = [...area.childNodes].some(n => (n.nodeType === 3 && n.nodeValue.trim()) || (n.nodeType === 1 && INLINE_OK.has(n.tagName) && n.tagName !== 'BR'));
|
|
1679
|
+
if (!loose) return;
|
|
1680
|
+
const off = offsetIn(area);
|
|
1681
|
+
let run = null;
|
|
1682
|
+
[...area.childNodes].forEach(n => {
|
|
1683
|
+
const inl = n.nodeType === 3 || (n.nodeType === 1 && INLINE_OK.has(n.tagName));
|
|
1684
|
+
if (!inl) { run = null; return; }
|
|
1685
|
+
if (!run) { if (n.nodeType === 3 && !n.nodeValue.trim()) return; run = document.createElement('p'); n.before(run); }
|
|
1686
|
+
run.appendChild(n);
|
|
1687
|
+
});
|
|
1688
|
+
if (off != null && document.activeElement === area) caretIn(area, off);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function ensureTrailingBlock(area) {
|
|
1692
|
+
const last = area.lastElementChild;
|
|
1693
|
+
if (!last || !/^(TABLE|HR|FIGURE|PRE|UL|OL|BLOCKQUOTE|DL)$/.test(last.tagName)) return false;
|
|
1694
|
+
const p = document.createElement('p');
|
|
1695
|
+
p.appendChild(document.createElement('br'));
|
|
1696
|
+
area.appendChild(p);
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// A value styles the selection; an empty one removes that style from it (spans left bare go)
|
|
1701
|
+
function styleSpan(prop, v) {
|
|
1702
|
+
if (v != null && v !== '') return wrapInline('span', { [prop]: v });
|
|
1703
|
+
const nodes = selectedTextNodes(), spans = new Set();
|
|
1704
|
+
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); });
|
|
1705
|
+
spans.forEach(s => { s.style[prop] = ''; if (!s.getAttribute('style')) s.removeAttribute('style'); if (!s.attributes.length) unwrapEl(s); });
|
|
1706
|
+
return [];
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function clearFormatting() {
|
|
1710
|
+
Object.keys(INLINE_TAGS).forEach(t => unwrapInline(t));
|
|
1711
|
+
unwrapInline('a');
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/* ---- Document-wide styles (.all) ----
|
|
1715
|
+
Custom properties on the area, read back by $rich.all; never in the stored markup. */
|
|
1716
|
+
const ALL_VARS = {
|
|
1717
|
+
font: '--rich-text-font', size: '--rich-text-size', leading: '--rich-text-leading',
|
|
1718
|
+
align: '--rich-text-align', color: '--rich-text-color', background: '--rich-text-background'
|
|
1719
|
+
};
|
|
1720
|
+
const allStyle = (area) => Object.fromEntries(Object.entries(ALL_VARS)
|
|
1721
|
+
.map(([k, v]) => [k, area.style.getPropertyValue(v).trim()]).filter(([, v]) => v));
|
|
1722
|
+
const setAllStyle = (area, values) => Object.entries(ALL_VARS).forEach(([k, v]) => {
|
|
1723
|
+
const next = values && values[k];
|
|
1724
|
+
if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
|
|
1725
|
+
});
|
|
1726
|
+
const allKey = (area) => JSON.stringify(allStyle(area));
|
|
1727
|
+
// .all styles set from code: every history entry takes the change, so undo never removes it
|
|
1728
|
+
function rebaseAll(area, was, now) {
|
|
1729
|
+
const h = area._history; if (!h) return;
|
|
1730
|
+
const keys = [...new Set([...Object.keys(was), ...Object.keys(now)])].filter(k => was[k] !== now[k]);
|
|
1731
|
+
h.stack.forEach(e => {
|
|
1732
|
+
const o = e.all ? JSON.parse(e.all) : {};
|
|
1733
|
+
keys.forEach(k => { if (now[k]) o[k] = now[k]; else delete o[k]; });
|
|
1734
|
+
e.all = JSON.stringify(Object.fromEntries(Object.keys(ALL_VARS).filter(k => o[k]).map(k => [k, o[k]])));
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/* ---- Command table ---- */
|
|
1739
|
+
const COMMANDS = Object.create(null);
|
|
1740
|
+
Object.entries(INLINE_TAGS).forEach(([tag, meta]) => {
|
|
1741
|
+
COMMANDS[tag] = {
|
|
1742
|
+
md: meta.md,
|
|
1743
|
+
run: (v, area) => hasSelection() ? toggleInline(tag, null, attrsFor(tag, v)) : armPending(area, tag, attrsFor(tag, v)),
|
|
1744
|
+
active: (v, area) => selectionMarked(tag) !== pendingHas(area, tag) // pending inside the tag ends it
|
|
1745
|
+
};
|
|
1746
|
+
});
|
|
1747
|
+
Object.entries(BLOCK_TAGS).forEach(([tag, meta]) => {
|
|
1748
|
+
COMMANDS[tag] = { md: meta.md, block: 1, run: () => setBlock(tag), active: () => blockTag() === tag.toUpperCase() };
|
|
1749
|
+
});
|
|
1750
|
+
Object.assign(COMMANDS, {
|
|
1751
|
+
a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: (area) => !linkHost(area) && (!!ancestor('a') || hasSelection()) },
|
|
1752
|
+
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)))) },
|
|
1753
|
+
br: { md: 1, run: (v, area) => area && area._rt && area._rt.breaks ? insertBreak(area) : insertHTML('<br>'), active: () => false },
|
|
1754
|
+
wbr: { run: () => insertVoid('wbr'), active: () => false },
|
|
1755
|
+
img: { md: 1, args: 2, run: (v) => Array.isArray(v) ? insertImage(v[0], v[1]) : insertImage(v), active: () => false },
|
|
1756
|
+
|
|
1757
|
+
ul: { md: 1, block: 1, run: () => toggleList('ul'), active: () => !!ancestor('ul') && !ancestor('ul').hasAttribute('data-checklist') },
|
|
1758
|
+
ol: { md: 1, block: 1, run: () => toggleList('ol'), active: () => !!ancestor('ol') },
|
|
1759
|
+
li: { md: 1, block: 1, run: toggleItem, active: () => !!ancestor('li') },
|
|
1760
|
+
checklist: { md: 1, block: 1, run: toggleChecklist, active: () => !!ancestor('ul[data-checklist]') },
|
|
1761
|
+
hr: { md: 1, block: 1, run: () => insertHTML('<hr><p><br></p>'), active: () => false },
|
|
1762
|
+
figure: { block: 1, run: toggleFigure, active: () => !!ancestor('figure') },
|
|
1763
|
+
figcaption: { block: 1, run: setCaption, active: () => !!ancestor('figcaption'), enabled: () => !!ancestor('figure') },
|
|
1764
|
+
dl: { block: 1, run: toggleDl, active: () => !!ancestor('dl') },
|
|
1765
|
+
dt: { block: 1, run: setTerm('dt'), active: () => termIs('DT'), enabled: () => !!ancestor('dt, dd') },
|
|
1766
|
+
dd: { block: 1, run: setTerm('dd'), active: () => termIs('DD'), enabled: () => !!ancestor('dt, dd') },
|
|
1767
|
+
|
|
1768
|
+
table: { block: 1, args: 2, run: (v) => insertTable(v), active: () => false },
|
|
1769
|
+
'row-before': { block: 1, run: () => addRow(-1), active: () => false, enabled: () => inTable() },
|
|
1770
|
+
'row-after': { block: 1, run: () => addRow(1), active: () => false, enabled: () => inTable() },
|
|
1771
|
+
'row-remove': { block: 1, run: removeRow, active: () => false, enabled: () => inTable() },
|
|
1772
|
+
'column-before': { block: 1, run: () => addColumn(-1), active: () => false, enabled: () => inTable() },
|
|
1773
|
+
'column-after': { block: 1, run: () => addColumn(1), active: () => false, enabled: () => inTable() },
|
|
1774
|
+
'column-remove': { block: 1, run: removeColumn, active: () => false, enabled: () => inTable() },
|
|
1775
|
+
'table-remove': { block: 1, run: removeTable, active: () => false, enabled: () => inTable() },
|
|
1776
|
+
'table-header': { block: 1, run: toggleHeaderRow, active: () => !!(tableAt() && tableAt().querySelector('th')), enabled: () => inTable() },
|
|
1777
|
+
merge: { block: 1, run: mergeCells, active: () => false, enabled: () => inTable() },
|
|
1778
|
+
split: { block: 1, run: splitCell, active: () => false, enabled: () => { const c = cellAt(); return !!c && ((c.colSpan || 1) > 1 || (c.rowSpan || 1) > 1); } },
|
|
1779
|
+
indent: { md: 1, block: 1, run: (v, area) => indent(1, area), active: () => false, enabled: (a) => canIndent(a) },
|
|
1780
|
+
outdent: { md: 1, block: 1, run: (v, area) => indent(-1, area), active: () => false, enabled: (a) => canOutdent(a) },
|
|
1781
|
+
align: { block: 1, all: 1, run: (v) => styleBlock('textAlign', v || 'start'), active: () => false, reflect: () => readBlockStyle('textAlign') },
|
|
1782
|
+
color: { all: 1, needsSelection: 1, run: (v) => styleSpan('color', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('color')) },
|
|
1783
|
+
background: { all: 1, needsSelection: 1, run: (v) => styleSpan('backgroundColor', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('backgroundColor')) },
|
|
1784
|
+
font: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontFamily', v), active: () => false, reflect: () => readInlineStyle('fontFamily').split(',')[0].replace(/['"]/g, '') },
|
|
1785
|
+
size: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontSize', v ? px(v) : ''), active: () => false, reflect: () => readInlineStyle('fontSize') },
|
|
1786
|
+
leading: { all: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
|
|
1787
|
+
|
|
1788
|
+
clear: { md: 1, run: clearFormatting, active: () => false },
|
|
1789
|
+
// A burst still waiting to be recorded is undoable, and leaves nothing to redo
|
|
1790
|
+
undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 || !!a._history.timer },
|
|
1791
|
+
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 },
|
|
1792
|
+
block: { md: 1, block: 1, run: (v, area) => runBlock(area, v), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
// .block="h2": any block tag command, under that command's own availability
|
|
1796
|
+
const BLOCK_CHOICES = new Set([...Object.keys(BLOCK_TAGS), 'ul', 'ol', 'li', 'figure', 'figcaption', 'dl', 'dt', 'dd']);
|
|
1797
|
+
function runBlock(area, v) {
|
|
1798
|
+
const tag = String(v || 'p').toLowerCase(), spec = COMMANDS[tag];
|
|
1799
|
+
if (!BLOCK_CHOICES.has(tag)) return;
|
|
1800
|
+
if (area._rt.format !== 'html' && !spec.md) return;
|
|
1801
|
+
if (spec.enabled && !spec.enabled(area)) return;
|
|
1802
|
+
spec.run(undefined, area);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const px = (v) => /^\d+(\.\d+)?$/.test(String(v)) ? v + 'px' : v;
|
|
1806
|
+
const hasSelection = () => { const r = range(); return !!r && !r.collapsed; };
|
|
1807
|
+
|
|
1808
|
+
/* ---- Pending marks ----
|
|
1809
|
+
An inline command at a collapsed caret arms the tag for the next typed text. */
|
|
1810
|
+
function armPending(area, tag, attrs) {
|
|
1811
|
+
const r = range(); if (!r || !r.collapsed) return;
|
|
1812
|
+
const at = area._pending;
|
|
1813
|
+
const tags = at && at.node === r.startContainer && at.offset === r.startOffset ? at.tags : new Map();
|
|
1814
|
+
tags.has(tag) ? tags.delete(tag) : tags.set(tag, attrs || null);
|
|
1815
|
+
area._pending = tags.size ? { tags, node: r.startContainer, offset: r.startOffset } : null;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const pendingHas = (area, tag) => !!(area && area._pending && area._pending.tags.has(tag));
|
|
1819
|
+
|
|
1820
|
+
function applyPending(area, typed) {
|
|
1821
|
+
const at = area._pending; if (!at) return false;
|
|
1822
|
+
const r = range();
|
|
1823
|
+
if (!r || !r.collapsed || r.startContainer !== at.node || r.startOffset !== at.offset + typed.length) {
|
|
1824
|
+
area._pending = null;
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
const node = r.startContainer, start = at.offset;
|
|
1828
|
+
const span = document.createRange();
|
|
1829
|
+
span.setStart(node, start); span.setEnd(node, r.startOffset);
|
|
1830
|
+
const text = span.toString(); // as the browser stored it (a trailing space may be U+00A0)
|
|
1831
|
+
span.deleteContents();
|
|
1832
|
+
// A pending tag already around the caret ends there: the text goes after it, keeping other formatting inside it
|
|
1833
|
+
const off = [], on = [];
|
|
1834
|
+
at.tags.forEach((attrs, tag) => { const h = elementAt(node).closest(tag); (h && withinArea(node, h) ? off : on).push([tag, attrs]); });
|
|
1835
|
+
const offTags = new Set(off.map(([t]) => t.toUpperCase()));
|
|
1836
|
+
let pos = { node, offset: start }, keep = [];
|
|
1837
|
+
off.forEach(([tag]) => {
|
|
1838
|
+
const host = elementAt(pos.node).closest(tag); if (!host) return;
|
|
1839
|
+
for (let n = elementAt(pos.node); n && n !== host; n = n.parentElement) if (!offTags.has(n.tagName)) keep.unshift(n.cloneNode(false));
|
|
1840
|
+
const rest = document.createRange(); rest.setStart(pos.node, pos.offset); rest.setEnd(host, host.childNodes.length);
|
|
1841
|
+
const tail = host.cloneNode(false); tail.appendChild(rest.extractContents());
|
|
1842
|
+
host.after(tail);
|
|
1843
|
+
if (!tail.textContent) tail.remove();
|
|
1844
|
+
pos = { node: host.parentNode, offset: [...host.parentNode.childNodes].indexOf(host) + 1 };
|
|
1845
|
+
if (!host.textContent) { pos.offset--; host.remove(); }
|
|
1846
|
+
});
|
|
1847
|
+
let outer = null, inner = null;
|
|
1848
|
+
const wrap = (el) => { if (inner) inner.appendChild(el); else outer = el; inner = el; };
|
|
1849
|
+
keep.forEach(wrap);
|
|
1850
|
+
on.forEach(([tag, attrs]) => {
|
|
1851
|
+
const el = document.createElement(tag);
|
|
1852
|
+
if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
1853
|
+
wrap(el);
|
|
1854
|
+
});
|
|
1855
|
+
const t = document.createTextNode(text);
|
|
1856
|
+
if (inner) inner.appendChild(t); else outer = t;
|
|
1857
|
+
const ins = document.createRange(); ins.setStart(pos.node, pos.offset); ins.collapse(true);
|
|
1858
|
+
ins.insertNode(outer);
|
|
1859
|
+
area._pending = null;
|
|
1860
|
+
const s = sel(), nr = document.createRange(); nr.setStart(t, text.length); nr.collapse(true);
|
|
1861
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function readInlineStyle(prop) {
|
|
1866
|
+
const el = elementAt(range() && range().startContainer);
|
|
1867
|
+
return el ? getComputedStyle(el)[prop] : '';
|
|
1868
|
+
}
|
|
1869
|
+
// <input type=color> only speaks hex; painting a pixel converts any colour
|
|
1870
|
+
let _hexCtx;
|
|
1871
|
+
function rgbToHex(v) {
|
|
1872
|
+
if (!v) return '#000000';
|
|
1873
|
+
try {
|
|
1874
|
+
_hexCtx = _hexCtx || document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
1875
|
+
_hexCtx.clearRect(0, 0, 1, 1);
|
|
1876
|
+
_hexCtx.fillStyle = v;
|
|
1877
|
+
_hexCtx.fillRect(0, 0, 1, 1);
|
|
1878
|
+
const [r, g, b] = _hexCtx.getImageData(0, 0, 1, 1).data;
|
|
1879
|
+
return '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('');
|
|
1880
|
+
} catch { return '#000000'; }
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const MODES = new Set(['html', 'plain', 'inline', 'literal', 'autofocus']);
|
|
1884
|
+
|
|
1885
|
+
// Nearest command for an unknown modifier (console warning only)
|
|
1886
|
+
const HINTS = {
|
|
1887
|
+
bold: 'strong', italic: 'em', underline: 'u', strike: 's', strikethrough: 's', link: 'a', quote: 'blockquote',
|
|
1888
|
+
codeblock: 'pre', bullet: 'ul', bullets: 'ul', numbered: 'ol', heading: 'h2', paragraph: 'p', image: 'img',
|
|
1889
|
+
rule: 'hr', highlight: 'mark', task: 'checklist', tasks: 'checklist', subscript: 'sub', superscript: 'sup',
|
|
1890
|
+
minimal: 'inline'
|
|
1891
|
+
};
|
|
1892
|
+
function suggest(m) {
|
|
1893
|
+
if (!m) return ' — name a command, e.g. .strong';
|
|
1894
|
+
const dist = (a, b) => {
|
|
1895
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
1896
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
1897
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++)
|
|
1898
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1899
|
+
return d[a.length][b.length];
|
|
1900
|
+
};
|
|
1901
|
+
let hit = HINTS[m.toLowerCase()];
|
|
1902
|
+
if (!hit) {
|
|
1903
|
+
const [best] = Object.keys(COMMANDS).map(c => [c, dist(m.toLowerCase(), c)]).sort((x, y) => x[1] - y[1]);
|
|
1904
|
+
if (best && best[1] <= Math.min(2, m.length - 1)) hit = best[0];
|
|
1905
|
+
}
|
|
1906
|
+
return hit ? ` — did you mean .${hit}?` : '';
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/* ---- Autoformat ----
|
|
1910
|
+
Block markdown converts on Enter; inline marks convert as their closing marker is typed. */
|
|
1911
|
+
const LINE_RULES = [
|
|
1912
|
+
{ re: /^(#{1,6})\s+/, make: (b, m) => retagBlock(b, 'h' + m[1].length) },
|
|
1913
|
+
{ re: /^[-*+]\s+/, make: (b) => listify(b, 'ul') },
|
|
1914
|
+
{ re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
|
|
1915
|
+
{ re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
|
|
1916
|
+
{ re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
|
|
1917
|
+
{ re: /^```([\w+#.-]{0,40})\s*$/, make: (b, m) => { const pre = retagBlock(b, 'pre'); if (m[1]) pre.setAttribute('data-lang', m[1]); return pre; } },
|
|
1918
|
+
{ re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
|
|
1919
|
+
];
|
|
1920
|
+
|
|
1921
|
+
function retagBlock(b, tag) {
|
|
1922
|
+
const n = document.createElement(tag);
|
|
1923
|
+
while (b.firstChild) n.appendChild(b.firstChild);
|
|
1924
|
+
b.replaceWith(n);
|
|
1925
|
+
return n;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Consecutive lines join the list above
|
|
1929
|
+
function listify(b, tag, checked) {
|
|
1930
|
+
const item = document.createElement('li');
|
|
1931
|
+
while (b.firstChild) item.appendChild(b.firstChild);
|
|
1932
|
+
const prev = b.previousElementSibling;
|
|
1933
|
+
const list = prev && prev.tagName === tag.toUpperCase() ? prev : document.createElement(tag);
|
|
1934
|
+
if (checked != null) {
|
|
1935
|
+
list.setAttribute('data-checklist', '');
|
|
1936
|
+
const box = document.createElement('input');
|
|
1937
|
+
box.type = 'checkbox'; box.checked = checked;
|
|
1938
|
+
item.insertBefore(box, item.firstChild);
|
|
1939
|
+
}
|
|
1940
|
+
list.appendChild(item);
|
|
1941
|
+
if (list !== prev) b.replaceWith(list); else b.remove();
|
|
1942
|
+
return item;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function stripLead(el, count) {
|
|
1946
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1947
|
+
let left = count;
|
|
1948
|
+
for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
|
|
1949
|
+
const take = Math.min(left, n.length);
|
|
1950
|
+
n.deleteData(0, take);
|
|
1951
|
+
left -= take;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Markdown typed in a run of inline content (up to the caret, or all of it) re-read with the
|
|
1956
|
+
// markdown reader, so what converts while typing is what a saved value reads back as. Elements
|
|
1957
|
+
// already there go through as opaque characters. Null when nothing new would be made.
|
|
1958
|
+
const ATOM = 0xE000;
|
|
1959
|
+
function reparse(box, until, upto, after) {
|
|
1960
|
+
const kids = [...box.childNodes], end = until ? kids.indexOf(until) : kids.length;
|
|
1961
|
+
let from = 0;
|
|
1962
|
+
for (let i = 0; i < end; i++) if (kids[i].nodeType === 1 && (kids[i].tagName === 'BR' || BLOCK.includes(kids[i].tagName))) from = i + 1;
|
|
1963
|
+
const run = kids.slice(from, end), atoms = [];
|
|
1964
|
+
let src = '';
|
|
1965
|
+
for (const n of run) {
|
|
1966
|
+
if (n.nodeType === 3) { if (/[\uE000-\uF8FF]/.test(n.nodeValue)) return null; src += n.nodeValue; }
|
|
1967
|
+
else if (n.nodeType === 1) { if (atoms.length > 6000) return null; src += String.fromCharCode(ATOM + atoms.length); atoms.push(n); }
|
|
1968
|
+
}
|
|
1969
|
+
if (until) src += until.nodeValue.slice(0, upto);
|
|
1970
|
+
if (!/[*_~`\[]/.test(src)) return null;
|
|
1971
|
+
const t = document.createElement('template');
|
|
1972
|
+
t.innerHTML = inlineHtml(src, { raw: true, after });
|
|
1973
|
+
const made = [...t.content.querySelectorAll('*')];
|
|
1974
|
+
if (!made.length) return null;
|
|
1975
|
+
// Elements already there land whole and once, never in a code span or an attribute
|
|
1976
|
+
if (made.some(e => [...e.attributes].some(x => /[\uE000-\uF8FF]/.test(x.value)))) return null;
|
|
1977
|
+
const texts = []; const w = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT);
|
|
1978
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) if (/[\uE000-\uF8FF]/.test(n.nodeValue)) { if (n.parentElement && n.parentElement.closest('code')) return null; texts.push(n); }
|
|
1979
|
+
const found = texts.flatMap(n => n.nodeValue.match(/[\uE000-\uF8FF]/g)).map(c => c.charCodeAt(0) - ATOM);
|
|
1980
|
+
if (found.length !== atoms.length || new Set(found).size !== atoms.length) return null;
|
|
1981
|
+
// The elements move in only when the conversion is taken
|
|
1982
|
+
const fill = () => texts.forEach(n => n.replaceWith(...n.nodeValue.split(/([\uE000-\uF8FF])/).filter(Boolean).map(p => p.length === 1 && p.charCodeAt(0) >= ATOM && p.charCodeAt(0) - ATOM < atoms.length ? atoms[p.charCodeAt(0) - ATOM] : document.createTextNode(p))));
|
|
1983
|
+
return { run, frag: t.content, fill };
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function convertInlineAtCaret(area) {
|
|
1987
|
+
const r = range(); if (!r || !r.collapsed) return false;
|
|
1988
|
+
const node = r.startContainer;
|
|
1989
|
+
if (!node || node.nodeType !== 3 || !node.parentElement) return false;
|
|
1990
|
+
const host = node.parentElement.closest('code, pre, a');
|
|
1991
|
+
if (host && area.contains(host)) return false;
|
|
1992
|
+
if (!/[*_~`)]/.test(node.nodeValue[r.startOffset - 1] || '')) return false;
|
|
1993
|
+
const next = node.nodeValue[r.startOffset] != null ? node.nodeValue[r.startOffset] : textEdge(node.nextSibling, false);
|
|
1994
|
+
const hit = reparse(node.parentElement, node, r.startOffset, next == null ? '' : next);
|
|
1995
|
+
// Only a construct closed by the character just typed, and whole: an opener with marker
|
|
1996
|
+
// left over (`**test*`, so far) waits for the rest of the closer
|
|
1997
|
+
const last = hit && hit.frag.lastChild, typed = node.nodeValue[r.startOffset - 1];
|
|
1998
|
+
if (!last || last.nodeType !== 1 || (!last.textContent && last.tagName !== 'IMG')) return false;
|
|
1999
|
+
const before = last.previousSibling;
|
|
2000
|
+
if (/[*_~]/.test(typed) && before && before.nodeType === 3 && before.nodeValue.endsWith(typed)) return false;
|
|
2001
|
+
record(area);
|
|
2002
|
+
const tail = node.splitText(r.startOffset);
|
|
2003
|
+
hit.run.forEach(n => n.remove()); node.remove();
|
|
2004
|
+
hit.fill();
|
|
2005
|
+
tail.parentNode.insertBefore(hit.frag, tail);
|
|
2006
|
+
const s = sel(), nr = document.createRange();
|
|
2007
|
+
nr.setStart(tail, 0); nr.collapse(true);
|
|
2008
|
+
s.removeAllRanges(); s.addRange(nr);
|
|
2009
|
+
area._exit = last.tagName === 'IMG' ? null : last;
|
|
2010
|
+
record(area);
|
|
2011
|
+
return true;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
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; };
|
|
2015
|
+
// 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
|
|
2016
|
+
function atMarkEnd(el, r) {
|
|
2017
|
+
if (!el || !el.isConnected || !r || !r.collapsed) return false;
|
|
2018
|
+
const n = r.startContainer, o = r.startOffset, last = lastTextIn(el);
|
|
2019
|
+
return (n === el.parentNode && n.childNodes[o - 1] === el) || (n === el && o === el.childNodes.length)
|
|
2020
|
+
|| (n.nodeType === 3 && n === el.nextSibling && o === 0) || (n.nodeType === 3 && n === last && o === n.length);
|
|
2021
|
+
}
|
|
2022
|
+
const tailOf = (el) => { let t = el.nextSibling; if (!t || t.nodeType !== 3) { t = document.createTextNode(''); el.after(t); } return t; };
|
|
2023
|
+
const caretAt = (node, offset) => { const s = sel(), nr = document.createRange(); nr.setStart(node, offset); nr.collapse(true); s.removeAllRanges(); s.addRange(nr); };
|
|
2024
|
+
const isWs = (c) => !c || /\s/.test(c);
|
|
2025
|
+
|
|
2026
|
+
// Text typed right after a conversion goes into a text node after the mark, never into it.
|
|
2027
|
+
// A space that would collapse is stored as U+00A0 until text follows it.
|
|
2028
|
+
function typeAfterMark(area, e) {
|
|
2029
|
+
const el = area._exit;
|
|
2030
|
+
if (e.inputType !== 'insertText' || !e.cancelable || !e.data || /[\r\n]/.test(e.data) || !atMarkEnd(el, range())) return false;
|
|
2031
|
+
e.preventDefault();
|
|
2032
|
+
area._exit = null; area._after = el;
|
|
2033
|
+
const last = lastTextIn(el), tail = tailOf(el);
|
|
2034
|
+
const next = tail.nodeValue[0] || '', prev = (last && last.nodeValue.slice(-1)) || '';
|
|
2035
|
+
const chars = [...e.data];
|
|
2036
|
+
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('');
|
|
2037
|
+
tail.insertData(0, text);
|
|
2038
|
+
const nbs = []; [...text].forEach((c, i) => { if (c === '\u00a0') nbs.push(i); });
|
|
2039
|
+
area._nb = nbs.length ? { node: tail, at: nbs } : null;
|
|
2040
|
+
caretAt(tail, text.length);
|
|
2041
|
+
area.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: e.data }));
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// A U+00A0 placed after a mark, or just before typed text, becomes a plain space once text is on both sides of it
|
|
2046
|
+
function relaxSpaces(area, typed) {
|
|
2047
|
+
const r0 = range();
|
|
2048
|
+
if (typed && r0 && r0.collapsed && r0.startContainer.nodeType === 3) {
|
|
2049
|
+
const at = r0.startOffset - typed.length - 1;
|
|
2050
|
+
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] };
|
|
2051
|
+
}
|
|
2052
|
+
const rec = area._nb; if (!rec) return;
|
|
2053
|
+
const node = rec.node;
|
|
2054
|
+
if (!node.isConnected) { area._nb = null; return; }
|
|
2055
|
+
const r = range(), keep = r && r.startContainer === node ? r.startOffset : null;
|
|
2056
|
+
rec.at = rec.at.filter(i => {
|
|
2057
|
+
const v = node.nodeValue;
|
|
2058
|
+
if (v[i] !== '\u00a0') return false;
|
|
2059
|
+
const p = node.previousSibling, x = node.nextSibling;
|
|
2060
|
+
const prev = i ? v[i - 1] : (p ? p.textContent.slice(-1) : ''), next = i < v.length - 1 ? v[i + 1] : (x ? x.textContent[0] : '');
|
|
2061
|
+
if (isWs(prev) || isWs(next)) return true;
|
|
2062
|
+
node.replaceData(i, 1, ' ');
|
|
2063
|
+
return false;
|
|
2064
|
+
});
|
|
2065
|
+
if (keep != null) caretAt(node, keep);
|
|
2066
|
+
if (!rec.at.length) area._nb = null;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// IME text composed right after a conversion is moved out of the mark, the caret with it
|
|
2070
|
+
function leaveMark(area, typed) {
|
|
2071
|
+
const el = area._exit; area._exit = null;
|
|
2072
|
+
const r = range();
|
|
2073
|
+
if (!el || !el.isConnected || !r || !r.collapsed) return false;
|
|
2074
|
+
const node = r.startContainer;
|
|
2075
|
+
// Chrome stores a typed trailing space as U+00A0
|
|
2076
|
+
const nb = (s) => s.replace(/\u00a0/g, ' ');
|
|
2077
|
+
if (node.nodeType !== 3 || !el.contains(node) || r.startOffset !== node.length || !nb(node.nodeValue).endsWith(nb(typed))) return false;
|
|
2078
|
+
if (lastTextIn(el) !== node || node.length === typed.length) return false;
|
|
2079
|
+
const moved = node.nodeValue.slice(node.length - typed.length);
|
|
2080
|
+
node.deleteData(node.length - typed.length, typed.length);
|
|
2081
|
+
const tail = tailOf(el);
|
|
2082
|
+
tail.insertData(0, moved);
|
|
2083
|
+
caretAt(tail, typed.length);
|
|
2084
|
+
area._after = el;
|
|
2085
|
+
return true;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// A finished line's markdown, in each run of inline content (marks' own content first)
|
|
2089
|
+
function convertInline(root) {
|
|
2090
|
+
[...root.children].forEach(c => { if (!/^(CODE|PRE|A|BR|IMG)$/.test(c.tagName)) convertInline(c); });
|
|
2091
|
+
if (root.closest('code, pre, a')) return;
|
|
2092
|
+
const segments = [];
|
|
2093
|
+
let start = root.firstChild;
|
|
2094
|
+
for (let n = root.firstChild; ; n = n ? n.nextSibling : null) {
|
|
2095
|
+
if (!n || (n.nodeType === 1 && (n.tagName === 'BR' || BLOCK.includes(n.tagName)))) {
|
|
2096
|
+
if (start !== n) segments.push([start, n]);
|
|
2097
|
+
if (!n) break;
|
|
2098
|
+
start = n.nextSibling;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
segments.forEach(([first, stop]) => {
|
|
2102
|
+
const box = document.createElement('span'), anchor = document.createComment('');
|
|
2103
|
+
first.before(anchor);
|
|
2104
|
+
for (let n = first; n && n !== stop;) { const nx = n.nextSibling; box.appendChild(n); n = nx; }
|
|
2105
|
+
const hit = reparse(box, null, 0, '');
|
|
2106
|
+
if (hit) { hit.fill(); box.replaceChildren(hit.frag); }
|
|
2107
|
+
anchor.replaceWith(...box.childNodes);
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// Returns the element now holding the line (an <li> continues the list)
|
|
2112
|
+
function convertLine(b) {
|
|
2113
|
+
if (!b) return b;
|
|
2114
|
+
// Only a <pre> inside the editor is a code block
|
|
2115
|
+
const area = b.closest('[data-rich-text]');
|
|
2116
|
+
const pre = b.closest('pre');
|
|
2117
|
+
if (pre && (!area || area.contains(pre))) return b;
|
|
2118
|
+
const text = b.textContent;
|
|
2119
|
+
for (const rule of LINE_RULES) {
|
|
2120
|
+
const m = text.match(rule.re); if (!m) continue;
|
|
2121
|
+
stripLead(b, m[0].length);
|
|
2122
|
+
const made = rule.make(b, m);
|
|
2123
|
+
if (made.tagName !== 'HR') convertInline(made);
|
|
2124
|
+
return made;
|
|
2125
|
+
}
|
|
2126
|
+
convertInline(b);
|
|
2127
|
+
return b;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
/* ---- Enter ---- */
|
|
2131
|
+
function splitInto(b, tag) {
|
|
2132
|
+
const n = document.createElement(tag);
|
|
2133
|
+
const r = range();
|
|
2134
|
+
if (b.lastChild && r && b.contains(r.startContainer)) {
|
|
2135
|
+
const tail = document.createRange();
|
|
2136
|
+
tail.setStart(r.startContainer, r.startOffset);
|
|
2137
|
+
tail.setEndAfter(b.lastChild);
|
|
2138
|
+
n.appendChild(tail.extractContents());
|
|
2139
|
+
}
|
|
2140
|
+
b.after(n);
|
|
2141
|
+
if (!b.textContent.length && !b.querySelector('br, img')) b.appendChild(document.createElement('br'));
|
|
2142
|
+
return n;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// Native Enter inside code and for Shift+Enter; Chrome writes <br> pairs in a <pre>
|
|
2146
|
+
const codeEndsBlank = (pre) => {
|
|
2147
|
+
const last = pre.lastChild;
|
|
2148
|
+
if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
|
|
2149
|
+
return /\n[^\S\n]*$/.test(pre.textContent);
|
|
2150
|
+
};
|
|
2151
|
+
const leavingCode = () => {
|
|
2152
|
+
const pre = ancestor('pre'); if (!pre) return false;
|
|
2153
|
+
const r = range();
|
|
2154
|
+
return !!r && r.collapsed && codeEndsBlank(pre);
|
|
2155
|
+
};
|
|
2156
|
+
const ownsEnter = (e) => !e.shiftKey && (!ancestor('pre') || leavingCode());
|
|
2157
|
+
|
|
2158
|
+
/* ---- Backspace ----
|
|
2159
|
+
Owned at a block start (no style spans from merged headings) and in a table's last cell. */
|
|
2160
|
+
const atBlockStart = () => { const b = ancestor('li') || blockEl(); return !!b && offsetIn(b) === 0; };
|
|
2161
|
+
|
|
2162
|
+
function cellCoversAll() {
|
|
2163
|
+
const cell = ancestor('td, th'); if (!cell) return null;
|
|
2164
|
+
const r = range(); if (!r) return null;
|
|
2165
|
+
const covers = r.collapsed
|
|
2166
|
+
? cell.textContent.length <= 1
|
|
2167
|
+
: r.toString().length >= cell.textContent.length;
|
|
2168
|
+
return covers ? cell : null;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function backspaceAtStart() {
|
|
2172
|
+
const li = ancestor('li');
|
|
2173
|
+
if (li) {
|
|
2174
|
+
if (li.previousElementSibling) {
|
|
2175
|
+
const prev = li.previousElementSibling, at = prev.textContent.length;
|
|
2176
|
+
while (li.firstChild) { const n = li.firstChild; if (n.nodeName === 'INPUT') n.remove(); else prev.appendChild(n); }
|
|
2177
|
+
li.remove();
|
|
2178
|
+
caretIn(prev, at);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const list = li.closest('ul, ol'), p = document.createElement('p');
|
|
2182
|
+
[...li.childNodes].forEach(n => { if (n.nodeName !== 'INPUT') p.appendChild(n); });
|
|
2183
|
+
list.before(p);
|
|
2184
|
+
li.remove();
|
|
2185
|
+
if (!list.children.length) list.remove();
|
|
2186
|
+
caretIn(p, 0);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
const b = blockEl(); if (!b || /^(TD|TH)$/.test(b.tagName)) return;
|
|
2190
|
+
const prev = b.previousElementSibling;
|
|
2191
|
+
if (!prev && /^(DT|DD)$/.test(b.tagName)) { liftItem(b); return; }
|
|
2192
|
+
if (!prev) { if (b.tagName !== 'P') retagBlock(b, 'p'); caretIn(blockEl() || b, 0); return; }
|
|
2193
|
+
if (prev.tagName === 'HR') { prev.remove(); caretIn(b, 0); return; }
|
|
2194
|
+
if (/^(UL|OL)$/.test(prev.tagName)) {
|
|
2195
|
+
const last = prev.lastElementChild; if (!last) return;
|
|
2196
|
+
const at = last.textContent.length;
|
|
2197
|
+
while (b.firstChild) last.appendChild(b.firstChild);
|
|
2198
|
+
b.remove();
|
|
2199
|
+
caretIn(last, at);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const at = prev.textContent.length;
|
|
2203
|
+
prev.querySelectorAll(':scope > br:last-child').forEach(br => br.remove());
|
|
2204
|
+
while (b.firstChild) prev.appendChild(b.firstChild);
|
|
2205
|
+
b.remove();
|
|
2206
|
+
caretIn(prev, at);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function onEnter(area, autoformat) {
|
|
2210
|
+
let holder = ancestor('li') || blockEl() || ensureBlock(area);
|
|
2211
|
+
// Paragraphs inside a cell, never siblings of it
|
|
2212
|
+
if (/^(TD|TH)$/.test(holder.tagName)) holder = innerBlock(holder);
|
|
2213
|
+
|
|
2214
|
+
// An empty last line leaves the code block (bounded to the area)
|
|
2215
|
+
const pre = holder.closest('pre');
|
|
2216
|
+
if (pre && pre !== area && area.contains(pre)) {
|
|
2217
|
+
while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
|
|
2218
|
+
if (/\n[^\S\n]*$/.test(pre.textContent)) pre.textContent = pre.textContent.replace(/\n[^\S\n]*$/, '');
|
|
2219
|
+
const p = document.createElement('p');
|
|
2220
|
+
pre.after(p);
|
|
2221
|
+
if (!pre.textContent.length) pre.remove();
|
|
2222
|
+
caretIn(p, 0);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
if (holder.tagName === 'LI' && !holder.textContent.length) return liftItem(holder);
|
|
2227
|
+
|
|
2228
|
+
// An empty last paragraph leaves its quote (a quote from markdown holds paragraphs)
|
|
2229
|
+
const quote = holder.parentElement;
|
|
2230
|
+
if (holder.tagName === 'P' && quote && quote.tagName === 'BLOCKQUOTE' && quote !== area && area.contains(quote) && holder === quote.lastElementChild && !holder.textContent.trim()) {
|
|
2231
|
+
quote.after(holder);
|
|
2232
|
+
if (!quote.textContent.trim() && !quote.querySelector('img')) quote.remove();
|
|
2233
|
+
caretIn(holder, 0);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Terms alternate dt → dd → dt; an empty one leaves the list
|
|
2238
|
+
if (/^(DT|DD)$/.test(holder.tagName)) {
|
|
2239
|
+
if (!holder.textContent.length) return liftItem(holder);
|
|
2240
|
+
caretIn(splitInto(holder, holder.tagName === 'DT' ? 'dd' : 'dt'), 0);
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// A caption continues after its figure
|
|
2245
|
+
if (holder.tagName === 'FIGCAPTION') {
|
|
2246
|
+
const next = splitInto(holder, 'p');
|
|
2247
|
+
holder.closest('figure').after(next);
|
|
2248
|
+
caretIn(next, 0);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// Split before converting: conversion strips marker text under the caret
|
|
2253
|
+
const next = splitInto(holder, holder.tagName === 'LI' ? 'li' : 'p');
|
|
2254
|
+
if (autoformat) holder = convertLine(holder) || holder;
|
|
2255
|
+
|
|
2256
|
+
if (holder.tagName === 'HR') { caretIn(next, 0); return; }
|
|
2257
|
+
|
|
2258
|
+
// A finished ``` line opens the code block
|
|
2259
|
+
if (holder.tagName === 'PRE') { next.remove(); caretIn(holder, 0); return; }
|
|
2260
|
+
|
|
2261
|
+
if (holder.tagName === 'LI') {
|
|
2262
|
+
const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
|
|
2263
|
+
holder.after(item);
|
|
2264
|
+
const box = holder.querySelector(':scope > input[type=checkbox]');
|
|
2265
|
+
if (box && !item.querySelector(':scope > input[type=checkbox]')) {
|
|
2266
|
+
const fresh = document.createElement('input');
|
|
2267
|
+
fresh.type = 'checkbox';
|
|
2268
|
+
item.insertBefore(fresh, item.firstChild);
|
|
2269
|
+
}
|
|
2270
|
+
caretIn(item, 0);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// Headings and quotes don't continue themselves
|
|
2275
|
+
caretIn(next, 0);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const MAC = /Mac|iPhone|iPad|iPod/.test((typeof navigator !== 'undefined' && (navigator.userAgentData && navigator.userAgentData.platform || navigator.platform)) || '');
|
|
2279
|
+
|
|
2280
|
+
/* ---- History ----
|
|
2281
|
+
HTML snapshots plus caret offset; typing bursts coalesce into one step. */
|
|
2282
|
+
const HISTORY_CAP = 200;
|
|
2283
|
+
const IDLE = 400;
|
|
2284
|
+
|
|
2285
|
+
// Every entry is a settled state (trailing block, tables), so settling never makes the field differ from its history
|
|
2286
|
+
function record(area) {
|
|
2287
|
+
const h = area._history; if (!h || h.restoring) return;
|
|
2288
|
+
clearTimeout(h.timer); h.timer = null;
|
|
2289
|
+
if (area._rt && area._rt.settle) area._rt.settle();
|
|
2290
|
+
const html = area.innerHTML, all = allKey(area);
|
|
2291
|
+
// Unchanged: the entry learns where the next edit starts (`before`, for undo);
|
|
2292
|
+
// `caret` stays where the edit that produced it ended (for redo)
|
|
2293
|
+
if (h.stack[h.at] && h.stack[h.at].html === html && h.stack[h.at].all === all) {
|
|
2294
|
+
const caret = offsetIn(area);
|
|
2295
|
+
if (caret != null) h.stack[h.at].before = caret;
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
h.stack.length = h.at + 1;
|
|
2299
|
+
h.stack.push({ html, caret: offsetIn(area), all });
|
|
2300
|
+
if (h.stack.length > HISTORY_CAP) h.stack.shift();
|
|
2301
|
+
h.at = h.stack.length - 1;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// A burst about to start: the entry it will undo to remembers where it starts (never adds a step)
|
|
2305
|
+
function markCaret(area) {
|
|
2306
|
+
const h = area._history; if (!h || h.timer || h.restoring) return;
|
|
2307
|
+
const top = h.stack[h.at], caret = offsetIn(area);
|
|
2308
|
+
if (top && caret != null && top.html === area.innerHTML) top.before = caret;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Re-sync once the burst lands, so Undo lights up
|
|
2312
|
+
const recordSoon = (area) => {
|
|
2313
|
+
const h = area._history; if (!h || h.restoring) return;
|
|
2314
|
+
clearTimeout(h.timer);
|
|
2315
|
+
h.timer = setTimeout(() => { record(area); sync(); }, IDLE);
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
function step(area, by) {
|
|
2319
|
+
const h = area._history; if (!h) return;
|
|
2320
|
+
// Land a pending burst; walking history alone leaves each entry's carets as recorded
|
|
2321
|
+
if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML || h.stack[h.at].all !== allKey(area)) record(area);
|
|
2322
|
+
const next = h.at + by;
|
|
2323
|
+
if (next < 0 || next >= h.stack.length) return;
|
|
2324
|
+
h.at = next; h.restoring = true;
|
|
2325
|
+
const entry = h.stack[next];
|
|
2326
|
+
area.innerHTML = entry.html;
|
|
2327
|
+
// .all styles are steps too
|
|
2328
|
+
if (entry.all != null && entry.all !== allKey(area)) { setAllStyle(area, JSON.parse(entry.all)); area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) })); }
|
|
2329
|
+
if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
|
|
2330
|
+
area.focus();
|
|
2331
|
+
caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
|
|
2332
|
+
saveRange(area);
|
|
2333
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2334
|
+
h.restoring = false;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/* ---- Registry + resolution ----
|
|
2338
|
+
Controls resolve by containment (nearest ancestor holding exactly one area), then
|
|
2339
|
+
by focus; x-rich-text-for pins either. */
|
|
2340
|
+
const areas = new Set();
|
|
2341
|
+
const controls = new Set();
|
|
2342
|
+
let lastFocused = null;
|
|
2343
|
+
|
|
2344
|
+
const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
|
|
2345
|
+
const inside = (root) => [...areas].filter(a => root.contains(a) && a !== root);
|
|
2346
|
+
const preferred = (list) => list.find(a => a === lastFocused && onScreen(a)) || list.find(onScreen) || list[0] || null;
|
|
2347
|
+
|
|
2348
|
+
function resolve(control) {
|
|
2349
|
+
const pin = control.closest('[x-rich-text-for]');
|
|
2350
|
+
if (pin) {
|
|
2351
|
+
const target = document.querySelector(pin.getAttribute('x-rich-text-for'));
|
|
2352
|
+
if (target) return areas.has(target) ? target : preferred(inside(target));
|
|
2353
|
+
}
|
|
2354
|
+
for (let n = control.parentElement; n; n = n.parentElement) {
|
|
2355
|
+
const found = inside(n);
|
|
2356
|
+
if (found.length === 1) return found[0];
|
|
2357
|
+
if (found.length > 1) return unambiguous(found);
|
|
2358
|
+
}
|
|
2359
|
+
return unambiguous([...areas]);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Releasing the target is announced with a null selection
|
|
2363
|
+
function release(area) {
|
|
2364
|
+
const was = area || lastFocused;
|
|
2365
|
+
if (!area || area === lastFocused) lastFocused = null;
|
|
2366
|
+
if (!was) return;
|
|
2367
|
+
was.removeAttribute('data-rich-text-focused');
|
|
2368
|
+
was._selection = null;
|
|
2369
|
+
was.removeAttribute('data-rich-text-selected');
|
|
2370
|
+
document.documentElement.removeAttribute('data-rich-text-selected');
|
|
2371
|
+
was.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: null }));
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Stands in for relatedTarget during the blur a pointer caused
|
|
2375
|
+
let pointerTarget = null;
|
|
2376
|
+
|
|
2377
|
+
function keeps(area, node) {
|
|
2378
|
+
const el = elementAt(node);
|
|
2379
|
+
if (!el) return false;
|
|
2380
|
+
if (area.contains(el)) return true;
|
|
2381
|
+
const control = el.closest('[data-rich-text-control]');
|
|
2382
|
+
return !!control && resolve(control) === area;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
const TEXTLESS = /^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i;
|
|
2386
|
+
const editableElsewhere = (node) => {
|
|
2387
|
+
const el = elementAt(node);
|
|
2388
|
+
return !!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !TEXTLESS.test(el.type)));
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
// Several areas in scope and none focused: no target
|
|
2392
|
+
function unambiguous(found) {
|
|
2393
|
+
if (lastFocused && found.includes(lastFocused) && lastFocused.isConnected && onScreen(lastFocused)) return lastFocused;
|
|
2394
|
+
const visible = found.filter(onScreen);
|
|
2395
|
+
return visible.length === 1 ? visible[0] : null;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/* ---- Caret custody ----
|
|
2399
|
+
Each area keeps its last range, so a focus-taking control can put it back. */
|
|
2400
|
+
function saveRange(area) {
|
|
2401
|
+
const r = range();
|
|
2402
|
+
if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const forget = (keep) => areas.forEach(a => { if (!keep || !a.contains(keep)) a._range = null; });
|
|
2406
|
+
|
|
2407
|
+
function restoreRange(area) {
|
|
2408
|
+
const r = liveRange();
|
|
2409
|
+
if (r && area.contains(r.startContainer)) return;
|
|
2410
|
+
const saved = area._range;
|
|
2411
|
+
if (!saved || !area.contains(saved.startContainer)) return;
|
|
2412
|
+
area.focus(); // its focusin saves the caret focus placed: put ours back
|
|
2413
|
+
area._range = saved;
|
|
2414
|
+
const s = sel(); s.removeAllRanges(); s.addRange(saved);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// With focus in a control (an <input> for a link, a <select>), commands read the area's saved range
|
|
2418
|
+
function judged(area, fn) {
|
|
2419
|
+
const live = liveRange();
|
|
2420
|
+
if ((live && area.contains(live.startContainer)) || !area._range || !area.contains(area._range.startContainer)) return fn();
|
|
2421
|
+
const was = probe; probe = area._range;
|
|
2422
|
+
try { return fn(); } finally { probe = was; }
|
|
2423
|
+
}
|
|
2424
|
+
// Focus or caret in another editable element (not this area, not one of the controls): stay quiet
|
|
2425
|
+
function busyElsewhere(area) {
|
|
2426
|
+
const a = document.activeElement;
|
|
2427
|
+
if (a && a !== document.body && a.closest && !area.contains(a)) {
|
|
2428
|
+
if (a.closest('[data-rich-text-control]')) return false;
|
|
2429
|
+
if (editableElsewhere(a)) return true;
|
|
2430
|
+
}
|
|
2431
|
+
const live = liveRange();
|
|
2432
|
+
return !!live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function allows(area, id, ctrl) {
|
|
2436
|
+
const spec = COMMANDS[id], cfg = area && area._rt;
|
|
2437
|
+
if (!spec || !cfg) return false;
|
|
2438
|
+
const all = !!(ctrl && ctrl.all && spec.all);
|
|
2439
|
+
if (all && cfg.noAll) return false;
|
|
2440
|
+
if (!all && busyElsewhere(area)) return false;
|
|
2441
|
+
if (spec.history) return spec.enabled(area);
|
|
2442
|
+
if (cfg.format === 'plain') return false;
|
|
2443
|
+
if (cfg.inline && !all && (spec.block || (id === 'br' && !cfg.breaks))) return false;
|
|
2444
|
+
if (cfg.format !== 'html' && !spec.md && !all) return false;
|
|
2445
|
+
if (all) return true;
|
|
2446
|
+
if (!area._range || !area.contains(area._range.startContainer)) return false;
|
|
2447
|
+
return judged(area, () => !(spec.needsSelection && !hasSelection()) && (!spec.enabled || spec.enabled(area)));
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function run(area, id, arg, ctrl) {
|
|
2451
|
+
const spec = COMMANDS[id]; if (!spec) return;
|
|
2452
|
+
const all = !!(ctrl && ctrl.all && spec.all);
|
|
2453
|
+
if (!all) {
|
|
2454
|
+
// Judged before restoring, so a refused (or keyboard-activated) control never takes focus back
|
|
2455
|
+
if (!allows(area, id, ctrl)) return;
|
|
2456
|
+
restoreRange(area);
|
|
2457
|
+
const r = range();
|
|
2458
|
+
if (!r || !area.contains(r.startContainer)) return;
|
|
2459
|
+
}
|
|
2460
|
+
if (!allows(area, id, ctrl)) return;
|
|
2461
|
+
if (all) {
|
|
2462
|
+
record(area);
|
|
2463
|
+
area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
|
|
2464
|
+
if (!arg) area.style.removeProperty(ALL_VARS[id]);
|
|
2465
|
+
record(area);
|
|
2466
|
+
area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
|
|
2467
|
+
sync();
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const arming = INLINE_TAGS[id] && !hasSelection();
|
|
2471
|
+
if (arming) { spec.run(arg, area); sync(); return; }
|
|
2472
|
+
if (!spec.history) record(area);
|
|
2473
|
+
const before = textSpan(area);
|
|
2474
|
+
spec.run(arg, area);
|
|
2475
|
+
// A command that rebuilt the blocks under the selection (a block style) leaves it where it was
|
|
2476
|
+
const after = range();
|
|
2477
|
+
if (before && before[1] > before[0] && (!after || !area.contains(after.startContainer) || after.collapsed)) selectSpan(area, before);
|
|
2478
|
+
mergeAdjacent(area);
|
|
2479
|
+
normalizeLists(area);
|
|
2480
|
+
if (!spec.history) record(area);
|
|
2481
|
+
saveRange(area);
|
|
2482
|
+
if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2483
|
+
sync();
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// $rich's reads (value, selection, can, active, …) depend on one reactive beat, bumped
|
|
2487
|
+
// (once a microtask) whenever the selection, value or formatting may have changed
|
|
2488
|
+
let pulse = null, beating = false;
|
|
2489
|
+
const watch = () => { if (pulse) void pulse.n; };
|
|
2490
|
+
// Bumped only when what $rich reports changed, so an effect that reads and writes it settles
|
|
2491
|
+
let lastSurface = null;
|
|
2492
|
+
const selKey = (x) => x ? [x.collapsed, x.text, x.x, x.y, x.width, x.height].join() : '';
|
|
2493
|
+
const surface = () => [...areas].map(a => [a === lastFocused, a === lastFocused ? String(textSpan(a)) : '', a.innerHTML, allKey(a), selKey(a._selection), a._pending ? [...a._pending.tags].join() : ''].join('\u0001')).join('\u0002');
|
|
2494
|
+
const beat = () => {
|
|
2495
|
+
if (!pulse || beating) return;
|
|
2496
|
+
beating = true;
|
|
2497
|
+
queueMicrotask(() => { beating = false; const now = surface(); if (now === lastSurface) return; lastSurface = now; pulse.n++; });
|
|
2498
|
+
};
|
|
2499
|
+
|
|
2500
|
+
// Reflect caret state onto every control
|
|
2501
|
+
function sync() {
|
|
2502
|
+
beat();
|
|
2503
|
+
controls.forEach(c => {
|
|
2504
|
+
const cfg = c._rt, area = resolve(c);
|
|
2505
|
+
const usable = !!area && allows(area, cfg.id, cfg);
|
|
2506
|
+
if (!area) { c.removeAttribute('data-rich-text-active'); c.setAttribute('aria-disabled', 'true'); if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', 'false'); return; }
|
|
2507
|
+
const spec = COMMANDS[cfg.id];
|
|
2508
|
+
const all = !!(cfg.all && spec.all);
|
|
2509
|
+
c.toggleAttribute('data-rich-text-active', usable && !all && !!judged(area, () => spec.active(cfg.literal, area)));
|
|
2510
|
+
c.setAttribute('aria-disabled', String(!usable));
|
|
2511
|
+
if (usable && (all || spec.reflect) && 'value' in c && c !== document.activeElement) {
|
|
2512
|
+
const v = all ? area.style.getPropertyValue(ALL_VARS[cfg.id]).trim() : judged(area, () => spec.reflect());
|
|
2513
|
+
// Never blank a <select> with a value it doesn't offer
|
|
2514
|
+
const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
|
|
2515
|
+
if (v != null && offered && c.value !== v) c.value = v;
|
|
2516
|
+
}
|
|
2517
|
+
if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-rich-text-active')));
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/* ---- Document listeners ---- */
|
|
2522
|
+
let listening = false;
|
|
2523
|
+
function listen() {
|
|
2524
|
+
if (listening) return;
|
|
2525
|
+
listening = true;
|
|
2526
|
+
// A link clicked in a field is content: neither the router nor the browser follows it.
|
|
2527
|
+
// A contenteditable=false island inside the field keeps its links. Window capture runs first.
|
|
2528
|
+
const blockLink = (e) => {
|
|
2529
|
+
const link = e.target && e.target.closest && e.target.closest('a[href]');
|
|
2530
|
+
const field = link && link.closest('[data-rich-text]');
|
|
2531
|
+
if (!field || !areas.has(field)) return;
|
|
2532
|
+
const off = link.closest('[contenteditable="false"]');
|
|
2533
|
+
if (off && field.contains(off)) return;
|
|
2534
|
+
e.preventDefault();
|
|
2535
|
+
};
|
|
2536
|
+
window.addEventListener('click', blockLink, true);
|
|
2537
|
+
window.addEventListener('auxclick', blockLink, true);
|
|
2538
|
+
// No inline computed styles; Enter makes <p>, not <div>
|
|
2539
|
+
try { document.execCommand('styleWithCSS', false, false); } catch { }
|
|
2540
|
+
try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch { }
|
|
2541
|
+
|
|
2542
|
+
document.addEventListener('pointerdown', (e) => {
|
|
2543
|
+
pointerTarget = e.target;
|
|
2544
|
+
setTimeout(() => { pointerTarget = null; }, 0);
|
|
2545
|
+
}, true);
|
|
2546
|
+
|
|
2547
|
+
document.addEventListener('selectionchange', () => {
|
|
2548
|
+
const r = range(); if (!r) return;
|
|
2549
|
+
const a = [...areas].find(x => x.contains(r.startContainer));
|
|
2550
|
+
if (a) {
|
|
2551
|
+
if (lastFocused && lastFocused !== a) release(lastFocused);
|
|
2552
|
+
saveRange(a); lastFocused = a; report(a);
|
|
2553
|
+
}
|
|
2554
|
+
else if (editableElsewhere(r.startContainer)) { forget(); if (lastFocused) release(); }
|
|
2555
|
+
sync();
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
// Caret moved into something else editable: controls lose their target
|
|
2559
|
+
document.addEventListener('focusin', (e) => {
|
|
2560
|
+
const t = e.target;
|
|
2561
|
+
if (!t || !t.closest) return;
|
|
2562
|
+
if (t.closest('[data-rich-text-control]')) return;
|
|
2563
|
+
const elsewhere = editableElsewhere(t);
|
|
2564
|
+
if (elsewhere) forget(t);
|
|
2565
|
+
if (t.closest('[data-rich-text]')) return;
|
|
2566
|
+
if (!lastFocused && !elsewhere) return;
|
|
2567
|
+
if (lastFocused) release();
|
|
2568
|
+
sync();
|
|
2569
|
+
}, true);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
/* ---- Directive ---- */
|
|
2573
|
+
function init() {
|
|
2574
|
+
if (!window.Alpine || !Alpine.directive) return;
|
|
2575
|
+
// Alpine walks only x-data trees: a page with none still gets its fields
|
|
2576
|
+
if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
|
|
2577
|
+
if (Alpine.reactive && !pulse) pulse = Alpine.reactive({ n: 0 });
|
|
2578
|
+
listen();
|
|
2579
|
+
|
|
2580
|
+
Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
|
|
2581
|
+
const id = modifiers.find(m => COMMANDS[m]);
|
|
2582
|
+
if (id) return control(el, id, modifiers, expression, evaluate, cleanup);
|
|
2583
|
+
if (/^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(el.tagName)) {
|
|
2584
|
+
const m = modifiers.find(x => !MODES.has(x) && x !== 'all');
|
|
2585
|
+
console.warn(`[Manifest Rich Text] unknown command${m ? ` .${m}` : ''} on <${el.tagName.toLowerCase()}>${suggest(m)}`);
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
return areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup });
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
// x-rich-text-menu: a popover shown over its area's selection
|
|
2592
|
+
Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
|
|
2593
|
+
|
|
2594
|
+
// $rich: the area this element sits in, else the one controls would resolve to
|
|
2595
|
+
// Unresolved, it depends on the beat, so it resolves once a field is there
|
|
2596
|
+
const richMagic = (el) => {
|
|
2597
|
+
const a = fieldFor(el);
|
|
2598
|
+
if (!a) watch();
|
|
2599
|
+
return a ? a._rt.api : idleApi();
|
|
2600
|
+
};
|
|
2601
|
+
Alpine.magic('rich', richMagic);
|
|
2602
|
+
// The loader's stand-in answered $rich until now: it hands over, and effects that read it
|
|
2603
|
+
// run again once adopted areas exist
|
|
2604
|
+
window.__manifestRichMagic = richMagic;
|
|
2605
|
+
if (window.__manifestRichStub) setTimeout(() => { flushStubWrites(); window.__manifestRichStub.v++; }, 0);
|
|
2606
|
+
|
|
2607
|
+
adopt();
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// $rich's field for an element: the one it sits in, else the one controls would resolve to
|
|
2611
|
+
const fieldFor = (el) => { const own = el.closest('[data-rich-text]'); return own && areas.has(own) ? own : resolve(el); };
|
|
2612
|
+
// Writes made on the loader's stand-in ($rich.value = … before Rich Text arrived) land on
|
|
2613
|
+
// their element's field once it exists
|
|
2614
|
+
function flushStubWrites() {
|
|
2615
|
+
const w = window.__manifestRichStubWrites; if (!w || !w.length) return;
|
|
2616
|
+
for (let i = 0; i < w.length;) {
|
|
2617
|
+
const [ref, k, v] = w[i], el = ref && typeof ref.deref === 'function' ? ref.deref() : ref;
|
|
2618
|
+
if (!el || !el.isConnected) { w.splice(i, 1); continue; }
|
|
2619
|
+
const a = fieldFor(el);
|
|
2620
|
+
if (!a) { i++; continue; }
|
|
2621
|
+
w.splice(i, 1);
|
|
2622
|
+
try { a._rt.api[k] = v; } catch { }
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// Loaded after Alpine started: elements it already walked get their x-rich-text*
|
|
2627
|
+
// attributes re-set, which Alpine handles as newly added directives
|
|
2628
|
+
function adopt() {
|
|
2629
|
+
document.querySelectorAll('*').forEach(el => {
|
|
2630
|
+
if (!el._x_marker || el._rt || el._rtMenu || controls.has(el)) return;
|
|
2631
|
+
[...el.attributes].forEach(a => {
|
|
2632
|
+
if (a.name.startsWith('x-rich-text') && !a.name.startsWith('x-rich-text-for')) el.setAttribute(a.name, a.value);
|
|
2633
|
+
});
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
function idleApi() {
|
|
2638
|
+
const api = {
|
|
2639
|
+
value: '', link: '', selection: null, all: {}, run() { }, active: () => false,
|
|
2640
|
+
can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
|
|
2641
|
+
};
|
|
2642
|
+
Object.keys(COMMANDS).forEach(id => { api[id] = () => { }; });
|
|
2643
|
+
return api;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/* ---- Selection reporting ----
|
|
2647
|
+
Geometry as a rich-text:selection event and as --rich-text-selection-* properties. */
|
|
2648
|
+
const SELECTION_VARS = ['x', 'y', 'width', 'height', 'center'].map(k => '--rich-text-selection-' + k);
|
|
2649
|
+
|
|
2650
|
+
function selectionOf(area) {
|
|
2651
|
+
const r = range();
|
|
2652
|
+
if (!r || !area.contains(r.startContainer)) return null;
|
|
2653
|
+
const box = r.getBoundingClientRect();
|
|
2654
|
+
return {
|
|
2655
|
+
collapsed: r.collapsed,
|
|
2656
|
+
text: r.toString(),
|
|
2657
|
+
x: box.left, y: box.top, width: box.width, height: box.height,
|
|
2658
|
+
top: box.top, bottom: box.bottom, left: box.left, right: box.right
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function paintCells(area) {
|
|
2663
|
+
area.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
|
|
2664
|
+
selectedCells().forEach(c => c.setAttribute('data-rich-text-cell', ''));
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// On the root only, where a top-layer menu reads them: never in the content's own style
|
|
2668
|
+
function place(area, shape) {
|
|
2669
|
+
if (!shape || shape.collapsed) return;
|
|
2670
|
+
const px = (n) => Math.round(n) + 'px', t = document.documentElement;
|
|
2671
|
+
t.style.setProperty('--rich-text-selection-x', px(shape.x));
|
|
2672
|
+
t.style.setProperty('--rich-text-selection-y', px(shape.y));
|
|
2673
|
+
t.style.setProperty('--rich-text-selection-width', px(shape.width));
|
|
2674
|
+
t.style.setProperty('--rich-text-selection-height', px(shape.height));
|
|
2675
|
+
t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// While a field holds a selection, scrolling and resizing re-report its geometry (once a frame)
|
|
2679
|
+
let tracked = null, trackFrame = 0;
|
|
2680
|
+
const untrack = () => {
|
|
2681
|
+
if (!tracked) return;
|
|
2682
|
+
tracked = null;
|
|
2683
|
+
window.removeEventListener('scroll', follow, { capture: true });
|
|
2684
|
+
window.removeEventListener('resize', follow);
|
|
2685
|
+
};
|
|
2686
|
+
function follow() {
|
|
2687
|
+
if (trackFrame) return;
|
|
2688
|
+
trackFrame = requestAnimationFrame(() => {
|
|
2689
|
+
trackFrame = 0;
|
|
2690
|
+
const area = tracked, shape = area && area.isConnected ? selectionOf(area) : null;
|
|
2691
|
+
if (!shape || shape.collapsed) return untrack();
|
|
2692
|
+
report(area);
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
function track(area, on) {
|
|
2696
|
+
if (!on) { if (tracked === area) untrack(); return; }
|
|
2697
|
+
if (!tracked) {
|
|
2698
|
+
window.addEventListener('scroll', follow, { capture: true, passive: true });
|
|
2699
|
+
window.addEventListener('resize', follow, { passive: true });
|
|
2700
|
+
}
|
|
2701
|
+
tracked = area;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function report(area) {
|
|
2705
|
+
const live = sel();
|
|
2706
|
+
if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
|
|
2707
|
+
if (area.querySelector('table')) paintCells(area);
|
|
2708
|
+
const shape = selectionOf(area);
|
|
2709
|
+
area._selection = shape;
|
|
2710
|
+
beat();
|
|
2711
|
+
track(area, !!shape && !shape.collapsed);
|
|
2712
|
+
place(area, shape);
|
|
2713
|
+
area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
2714
|
+
document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
2715
|
+
area.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: shape }));
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// popover="manual": an auto popover closes on the click that makes the selection
|
|
2719
|
+
function selectionMenu(el, cleanup) {
|
|
2720
|
+
el._rtMenu = true;
|
|
2721
|
+
if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
|
|
2722
|
+
el.setAttribute('data-rich-text-menu', '');
|
|
2723
|
+
const onSel = (e) => {
|
|
2724
|
+
const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
|
|
2725
|
+
if (!area || resolve(el) !== area) return;
|
|
2726
|
+
const want = !!e.detail && !e.detail.collapsed;
|
|
2727
|
+
try {
|
|
2728
|
+
if (want && !el.matches(':popover-open')) el.showPopover();
|
|
2729
|
+
if (!want && el.matches(':popover-open')) el.hidePopover();
|
|
2730
|
+
} catch { }
|
|
2731
|
+
};
|
|
2732
|
+
document.addEventListener('rich-text:selection', onSel);
|
|
2733
|
+
cleanup(() => {
|
|
2734
|
+
document.removeEventListener('rich-text:selection', onSel);
|
|
2735
|
+
try { el.hidePopover(); } catch { }
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function control(el, id, modifiers, expression, evaluate, cleanup) {
|
|
2740
|
+
if (controls.has(el)) return;
|
|
2741
|
+
// `.align.center`, `.align.all.center`: the first other modifier after the command is its argument
|
|
2742
|
+
const all = modifiers.includes('all');
|
|
2743
|
+
const literal = modifiers.slice(modifiers.indexOf(id) + 1).find(m => m !== 'all');
|
|
2744
|
+
const arg = () => literal !== undefined ? literal : (expression ? evaluate(expression) : undefined);
|
|
2745
|
+
el._rt = { id, arg, all, literal }; // the expression is evaluated only when the control runs
|
|
2746
|
+
if (all) el.setAttribute('data-rich-text-all', '');
|
|
2747
|
+
el.setAttribute('data-rich-text-control', id);
|
|
2748
|
+
if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.type = 'button';
|
|
2749
|
+
controls.add(el);
|
|
2750
|
+
|
|
2751
|
+
const valued = 'value' in el && el.tagName !== 'BUTTON';
|
|
2752
|
+
// Never take the caret
|
|
2753
|
+
const disabled = () => el.getAttribute('aria-disabled') === 'true';
|
|
2754
|
+
el.addEventListener('mousedown', e => { if (!valued || disabled()) e.preventDefault(); });
|
|
2755
|
+
if (valued) el.addEventListener('click', e => { if (disabled()) e.preventDefault(); });
|
|
2756
|
+
if (valued) {
|
|
2757
|
+
const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._rt); };
|
|
2758
|
+
if (el.type === 'color') {
|
|
2759
|
+
// Live preview: the first tick wraps (one undo step), later ticks restyle the same spans
|
|
2760
|
+
const prop = id === 'background' ? 'backgroundColor' : 'color';
|
|
2761
|
+
let live = null;
|
|
2762
|
+
const dropLive = () => {
|
|
2763
|
+
if (!live) return;
|
|
2764
|
+
const r = range();
|
|
2765
|
+
if (!r || !live.some(sp => sp.isConnected && sp.contains(r.startContainer))) live = null;
|
|
2766
|
+
};
|
|
2767
|
+
document.addEventListener('rich-text:selection', dropLive);
|
|
2768
|
+
cleanup(() => document.removeEventListener('rich-text:selection', dropLive));
|
|
2769
|
+
el.addEventListener('input', () => {
|
|
2770
|
+
const a = resolve(el); if (!a) return;
|
|
2771
|
+
if (el._rt.all) { run(a, id, el.value, el._rt); return; }
|
|
2772
|
+
if (live && live.length && live.every(s => s.isConnected)) {
|
|
2773
|
+
live.forEach(s => { s.style[prop] = el.value; });
|
|
2774
|
+
a.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (!allows(a, id, el._rt)) return;
|
|
2778
|
+
restoreRange(a);
|
|
2779
|
+
const r = range(); if (!r || !a.contains(r.startContainer)) return;
|
|
2780
|
+
record(a);
|
|
2781
|
+
live = wrapInline('span', { [prop]: el.value });
|
|
2782
|
+
saveRange(a);
|
|
2783
|
+
a.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2784
|
+
});
|
|
2785
|
+
el.addEventListener('change', () => {
|
|
2786
|
+
if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; }
|
|
2787
|
+
fire();
|
|
2788
|
+
});
|
|
2789
|
+
} else {
|
|
2790
|
+
el.addEventListener('change', fire);
|
|
2791
|
+
}
|
|
2792
|
+
el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
|
|
2793
|
+
} else {
|
|
2794
|
+
el.addEventListener('pointerdown', e => e.preventDefault());
|
|
2795
|
+
el.addEventListener('click', e => { e.preventDefault(); const a = resolve(el); if (a) run(a, id, arg(), el._rt); });
|
|
2796
|
+
}
|
|
2797
|
+
setTimeout(sync, 0);
|
|
2798
|
+
cleanup(() => { controls.delete(el); });
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
function areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
|
|
2802
|
+
if (el._rt) return;
|
|
2803
|
+
const format = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
|
|
2804
|
+
modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Rich Text] unknown modifier: .${m}${suggest(m)}`));
|
|
2805
|
+
|
|
2806
|
+
let last = null, setValue = null;
|
|
2807
|
+
const core = setup(el, {
|
|
2808
|
+
format, inline: modifiers.includes('inline'), literal: modifiers.includes('literal'),
|
|
2809
|
+
bound: !!expression, controlled: !!expression,
|
|
2810
|
+
onChange: expression ? (v) => { last = v; setValue(() => { }, { scope: { __richTextValue: v } }); } : null
|
|
2811
|
+
});
|
|
2812
|
+
if (expression) {
|
|
2813
|
+
const getValue = evaluateLater(expression);
|
|
2814
|
+
setValue = evaluateLater(`${expression} = __richTextValue`);
|
|
2815
|
+
effect(() => getValue(v => {
|
|
2816
|
+
const s = v == null ? '' : String(v);
|
|
2817
|
+
if (s === last) return; // our own write coming back
|
|
2818
|
+
last = s;
|
|
2819
|
+
core.load(s);
|
|
2820
|
+
}));
|
|
2821
|
+
}
|
|
2822
|
+
if (modifiers.includes('autofocus')) setTimeout(() => el.focus(), 0);
|
|
2823
|
+
cleanup(core.destroy);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/* ---- Area engine ----
|
|
2827
|
+
Shared by the directive and ManifestRichText.attach(). */
|
|
2828
|
+
function setup(el, { format = 'markdown', inline: single = false, breaks = false, all: allStyles = true, literal = false, bound = false, controlled = false, onChange = null } = {}) {
|
|
2829
|
+
// .inline html with breaks: one block of lines; <br> kept, Shift+Enter makes one
|
|
2830
|
+
const lines = single && breaks && format === 'html';
|
|
2831
|
+
const flat = (html) => lines ? breakLines(html, true) : flattenLines(html, true);
|
|
2832
|
+
const ac = new AbortController();
|
|
2833
|
+
const on = (type, fn) => el.addEventListener(type, fn, { signal: ac.signal });
|
|
2834
|
+
const prior = ['contenteditable', 'role', 'aria-multiline'].map(n => [n, el.getAttribute(n)]);
|
|
2835
|
+
const priorAll = Object.values(ALL_VARS).map(v => [v, el.style.getPropertyValue(v)]);
|
|
2836
|
+
|
|
2837
|
+
el.setAttribute('data-rich-text', format);
|
|
2838
|
+
// Tells x-edit whether the value already has an owner
|
|
2839
|
+
el.toggleAttribute('data-rich-text-bound', bound);
|
|
2840
|
+
el.toggleAttribute('data-rich-text-inline', single);
|
|
2841
|
+
el.setAttribute('contenteditable', format === 'plain' && PLAINTEXT_ONLY ? 'plaintext-only' : 'true');
|
|
2842
|
+
el.setAttribute('role', 'textbox');
|
|
2843
|
+
el.setAttribute('aria-multiline', String(!single || lines));
|
|
2844
|
+
|
|
2845
|
+
const read = () => {
|
|
2846
|
+
if (format === 'plain') return single ? el.innerText.replace(/\s*\n\s*/g, ' ').trim() : el.innerText.replace(/\n{3,}/g, '\n\n').trim();
|
|
2847
|
+
if (single) return format === 'html' ? flat(el.innerHTML) : inlineMarkdown(el);
|
|
2848
|
+
return format === 'html' ? sanitize(el.innerHTML, true, true) : toMarkdown(el);
|
|
2849
|
+
};
|
|
2850
|
+
const write = (v) => {
|
|
2851
|
+
if (format === 'plain') { el.textContent = single ? flatText(v).trim() : v == null ? '' : String(v); return; }
|
|
2852
|
+
if (single) { el.innerHTML = format === 'html' ? flat(v) : sanitize(inlineHtml(flatText(v).trim()), false); return; }
|
|
2853
|
+
el.innerHTML = format === 'html' ? sanitize(v, true, true) : markdownHtml(v);
|
|
2854
|
+
if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
|
|
2855
|
+
normalizeLists(el);
|
|
2856
|
+
if (el.querySelector('table')) normalizeTables(el);
|
|
2857
|
+
ensureTrailingBlock(el);
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
// Single-line insertion: clipboard or drop data flattened, at a range when given
|
|
2861
|
+
const htmlText = (html) => { const t = document.createElement('template'); t.innerHTML = html || ''; return t.content.textContent; };
|
|
2862
|
+
const mdOnly = (html) => format === 'markdown' ? markdownMarks(html) : html;
|
|
2863
|
+
const flatClip = (html, text) => html ? mdOnly(unwrapBareSpans(lines ? breakLines(flattenTables(html, false)) : flattenLines(flattenTables(html, false))))
|
|
2864
|
+
: lines ? textLines(text) : sanitize(inlineHtml(flatText(text).trim()), false);
|
|
2865
|
+
const multiline = (html, text) => {
|
|
2866
|
+
if (!html) return /[\r\n]/.test(text || '');
|
|
2867
|
+
const t = document.createElement('template'); t.innerHTML = html;
|
|
2868
|
+
return !!t.content.querySelector(BLOCK_SEL + ',br,div');
|
|
2869
|
+
};
|
|
2870
|
+
const insertFlat = (html, at, text) => {
|
|
2871
|
+
if (at) { const s = sel(); s.removeAllRanges(); s.addRange(at); }
|
|
2872
|
+
if (format === 'plain') return insertPlain(el, flatText(text != null ? text : htmlText(html)).trim());
|
|
2873
|
+
const t = document.createElement('template');
|
|
2874
|
+
t.innerHTML = html;
|
|
2875
|
+
insertNodes(el, t.content);
|
|
2876
|
+
};
|
|
2877
|
+
|
|
2878
|
+
let writing = false;
|
|
2879
|
+
const commit =() => { if (onChange) onChange(read()); };
|
|
2880
|
+
on('input', () => { if (!writing) commit(); });
|
|
2881
|
+
|
|
2882
|
+
// Uncontrolled: the markup already there is the initial content
|
|
2883
|
+
if (!controlled && format !== 'plain') {
|
|
2884
|
+
if (single) { if (el.querySelector(BLOCK_SEL)) el.innerHTML = flat(el.innerHTML); }
|
|
2885
|
+
else {
|
|
2886
|
+
if (el.firstChild && ![...el.children].some(c => BLOCK.includes(c.tagName))) el.innerHTML = `<p>${el.innerHTML}</p>`;
|
|
2887
|
+
if (!el.firstChild) el.innerHTML = '<p><br></p>';
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
el._history = { stack: [{ html: el.innerHTML, caret: 0, all: allKey(el) }], at: 0, timer: null, restoring: false };
|
|
2892
|
+
const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0, all: allKey(el) }]; el._history.at = 0; };
|
|
2893
|
+
|
|
2894
|
+
// Empty flag for the placeholder; a wiped document restarts as a paragraph
|
|
2895
|
+
function settle() {
|
|
2896
|
+
if (single) {
|
|
2897
|
+
// Backstop: whatever got a block or break in is flattened, caret kept
|
|
2898
|
+
if (el.querySelector(lines ? BLOCK_SEL : BLOCK_SEL + ',br')) {
|
|
2899
|
+
const off = offsetIn(el), html = flat(el.innerHTML);
|
|
2900
|
+
if (format === 'plain') { const t = document.createElement('template'); t.innerHTML = html; el.textContent = t.content.textContent; }
|
|
2901
|
+
else el.innerHTML = html;
|
|
2902
|
+
if (off != null && el.textContent.length) caretIn(el, off);
|
|
2903
|
+
}
|
|
2904
|
+
const empty = !el.textContent.trim() && !el.querySelector('img');
|
|
2905
|
+
if (empty && el.firstChild && !el.textContent.length) el.replaceChildren();
|
|
2906
|
+
el.toggleAttribute('data-rich-text-empty', empty);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (el.querySelector('table')) normalizeTables(el);
|
|
2910
|
+
if (format !== 'plain') looseInline(el);
|
|
2911
|
+
ensureTrailingBlock(el);
|
|
2912
|
+
// A code block just opened (``` + Enter) holds the caret: it stays
|
|
2913
|
+
const r = range(), first = el.firstElementChild, opened = !!first && first.tagName === 'PRE' && !!r && first.contains(r.startContainer);
|
|
2914
|
+
if (!opened && !el.textContent.trim() && !el.querySelector('img, table, hr')) {
|
|
2915
|
+
if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
|
|
2916
|
+
if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
|
|
2917
|
+
el.toggleAttribute('data-rich-text-empty', true);
|
|
2918
|
+
} else el.toggleAttribute('data-rich-text-empty', false);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
const load = (v) => {
|
|
2922
|
+
writing = true; write(v); writing = false;
|
|
2923
|
+
resetHistory();
|
|
2924
|
+
settle();
|
|
2925
|
+
el._selection = selectionOf(el);
|
|
2926
|
+
sync();
|
|
2927
|
+
};
|
|
2928
|
+
|
|
2929
|
+
const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
|
|
2930
|
+
const enter = (shiftKey) => el.dispatchEvent(new CustomEvent('rich-text:enter', { bubbles: true, detail: { value: read(), shiftKey: !!shiftKey } }));
|
|
2931
|
+
|
|
2932
|
+
// Table borders resize; drags pick cells
|
|
2933
|
+
on('pointermove', (ev) => {
|
|
2934
|
+
if (ev.buttons || !el.querySelector('table')) return;
|
|
2935
|
+
const hit = borderAt(ev.clientX, ev.clientY);
|
|
2936
|
+
el.setAttribute('data-rich-text-border', hit ? hit.axis : '');
|
|
2937
|
+
if (!hit) el.removeAttribute('data-rich-text-border');
|
|
2938
|
+
});
|
|
2939
|
+
on('pointerdown', (ev) => {
|
|
2940
|
+
el._exit = null; el._after = null;
|
|
2941
|
+
if (ev.button !== 0) return;
|
|
2942
|
+
if (el.querySelector('table')) {
|
|
2943
|
+
const hit = borderAt(ev.clientX, ev.clientY);
|
|
2944
|
+
if (hit) return startResize(ev, hit);
|
|
2945
|
+
const from = cellUnder(ev.clientX, ev.clientY);
|
|
2946
|
+
if (from) cellDrag = { anchor: from, focus: from };
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
on('pointermove', (ev) => {
|
|
2950
|
+
if (!cellDrag || !ev.buttons) return;
|
|
2951
|
+
const over = cellUnder(ev.clientX, ev.clientY);
|
|
2952
|
+
if (!over || over === cellDrag.focus) return;
|
|
2953
|
+
cellDrag.focus = over;
|
|
2954
|
+
paintCells(el);
|
|
2955
|
+
});
|
|
2956
|
+
on('pointerup', () => {
|
|
2957
|
+
if (cellDrag && cellDrag.anchor === cellDrag.focus) cellDrag = null;
|
|
2958
|
+
paintCells(el);
|
|
2959
|
+
sync();
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
// Links are content here, not navigation
|
|
2963
|
+
on('click', (e) => {
|
|
2964
|
+
const a = e.target && e.target.closest && e.target.closest('a');
|
|
2965
|
+
const island = a && a.closest('[contenteditable="false"]');
|
|
2966
|
+
if (a && el.contains(a) && !(island && el.contains(island))) e.preventDefault();
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
on('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-rich-text-focused', ''); saveRange(el); sync(); });
|
|
2970
|
+
on('focusout', (e) => {
|
|
2971
|
+
if (keeps(el, e.relatedTarget || pointerTarget)) return;
|
|
2972
|
+
release(el);
|
|
2973
|
+
sync();
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
const autoformat = format !== 'plain' && !literal;
|
|
2977
|
+
on('input', (e) => {
|
|
2978
|
+
// Mid-composition the IME owns the text
|
|
2979
|
+
if (!writing && e.data && !e.isComposing) {
|
|
2980
|
+
if (applyPending(el, e.data)) { mergeAdjacent(el); commit(); }
|
|
2981
|
+
if (leaveMark(el, e.data)) commit();
|
|
2982
|
+
if (autoformat && convertInlineAtCaret(el)) commit();
|
|
2983
|
+
}
|
|
2984
|
+
// A deletion of text typed after a mark, back to its edge, is right after the mark again
|
|
2985
|
+
if (el._delOut && /^delete/.test(e.inputType || '') && atMarkEnd(el._after, range())) el._exit = el._after;
|
|
2986
|
+
el._delOut = false;
|
|
2987
|
+
if (!writing && !e.isComposing) relaxSpaces(el, e.inputType === 'insertText' ? e.data : null);
|
|
2988
|
+
if (!writing) recordSoon(el);
|
|
2989
|
+
settle(); sync();
|
|
2990
|
+
});
|
|
2991
|
+
on('compositionend', (e) => {
|
|
2992
|
+
if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
|
|
2993
|
+
else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
|
|
2994
|
+
// A closing marker committed through an IME converts as a typed one does
|
|
2995
|
+
else if (!writing && autoformat && e.data && /[*_~`)]$/.test(e.data) && convertInlineAtCaret(el)) { commit(); sync(); }
|
|
2996
|
+
if (!writing) relaxSpaces(el);
|
|
2997
|
+
});
|
|
2998
|
+
on('change', e => {
|
|
2999
|
+
if (e.target.type !== 'checkbox') return;
|
|
3000
|
+
// Only the attribute survives serialization
|
|
3001
|
+
e.target.toggleAttribute('checked', e.target.checked);
|
|
3002
|
+
record(el);
|
|
3003
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3004
|
+
});
|
|
3005
|
+
on('input', (e) => {
|
|
3006
|
+
if (writing || e.isComposing) return;
|
|
3007
|
+
if (ancestor('li')) normalizeLists(el);
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
// Native undo would desync the plugin's stack
|
|
3011
|
+
on('beforeinput', (e) => {
|
|
3012
|
+
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
3013
|
+
e.preventDefault();
|
|
3014
|
+
run(el, e.inputType === 'historyUndo' ? 'undo' : 'redo');
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
markCaret(el);
|
|
3018
|
+
if (typeAfterMark(el, e)) return;
|
|
3019
|
+
if (/^delete/.test(e.inputType || '')) { const m = el._after, r = range(); el._delOut = !!(m && m.isConnected && r && !m.contains(r.startContainer) && !atMarkEnd(m, r)); }
|
|
3020
|
+
if (lines && e.inputType === 'insertLineBreak') { e.preventDefault(); lineBreak(); return; }
|
|
3021
|
+
if (single && /^insert(Paragraph|LineBreak)$/.test(e.inputType || '')) { e.preventDefault(); enter(false); return; }
|
|
3022
|
+
// Text carrying line breaks (autocorrect, IME, replacement) lands as one line
|
|
3023
|
+
if (single && e.cancelable && /^insert(Text|ReplacementText|CompositionText)$/.test(e.inputType || '')) {
|
|
3024
|
+
const d = e.data != null ? e.data : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
|
|
3025
|
+
if (/[\r\n]/.test(d)) {
|
|
3026
|
+
e.preventDefault();
|
|
3027
|
+
const tr = e.getTargetRanges ? e.getTargetRanges()[0] : null;
|
|
3028
|
+
let at = null;
|
|
3029
|
+
if (tr) { at = document.createRange(); at.setStart(tr.startContainer, tr.startOffset); at.setEnd(tr.endContainer, tr.endOffset); }
|
|
3030
|
+
insertFlat(lines ? textLines(d) : escHtml(flatText(d).trim()), at, d);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
if (format === 'plain' && /^format/.test(e.inputType || '')) e.preventDefault();
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
// A multi-line drop is flattened at the drop point
|
|
3038
|
+
on('drop', (e) => {
|
|
3039
|
+
const dt = e.dataTransfer; if (!single || !dt) return;
|
|
3040
|
+
const html = dt.getData('text/html'), text = dt.getData('text/plain');
|
|
3041
|
+
if (!multiline(html, text)) return;
|
|
3042
|
+
e.preventDefault();
|
|
3043
|
+
const hit = caretAtPoint(e.clientX, e.clientY);
|
|
3044
|
+
let at = null;
|
|
3045
|
+
if (hit && el.contains(hit.node)) { at = document.createRange(); at.setStart(hit.node, hit.offset); at.collapse(true); }
|
|
3046
|
+
el.focus();
|
|
3047
|
+
insertFlat(flatClip(html, text), at, text || htmlText(html));
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
on('keydown', (e) => {
|
|
3051
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
3052
|
+
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;
|
|
3053
|
+
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; }
|
|
3054
|
+
if (e.key === 'Enter' && single) { e.preventDefault(); if (lines && e.shiftKey) lineBreak(); else enter(e.shiftKey); return; }
|
|
3055
|
+
if (e.key === 'Enter' && format !== 'plain') {
|
|
3056
|
+
if (!ownsEnter(e)) return;
|
|
3057
|
+
e.preventDefault();
|
|
3058
|
+
record(el);
|
|
3059
|
+
onEnter(el, autoformat);
|
|
3060
|
+
normalizeLists(el);
|
|
3061
|
+
record(el);
|
|
3062
|
+
commit();
|
|
3063
|
+
sync();
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (e.key === 'Backspace' && format !== 'plain' && !single) {
|
|
3067
|
+
const cell = cellCoversAll();
|
|
3068
|
+
if (cell) { // clear the cell, keep the table
|
|
3069
|
+
e.preventDefault(); record(el);
|
|
3070
|
+
cell.replaceChildren(document.createElement('br'));
|
|
3071
|
+
caretIn(cell, 0);
|
|
3072
|
+
record(el); commit(); sync();
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
const r = range();
|
|
3076
|
+
if (r && r.collapsed && atBlockStart()) {
|
|
3077
|
+
e.preventDefault(); record(el);
|
|
3078
|
+
backspaceAtStart();
|
|
3079
|
+
normalizeLists(el);
|
|
3080
|
+
record(el); commit(); sync();
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
if (/^Arrow(Up|Down|Left|Right)$/.test(e.key) && !e.shiftKey && !e.metaKey && !e.ctrlKey && inTable()) {
|
|
3085
|
+
if (arrowInTable(e)) { e.preventDefault(); sync(); return; }
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
// Tab indents (single-line fields keep native focus order); Escape is the way out
|
|
3089
|
+
if (e.key === 'Tab') {
|
|
3090
|
+
if (format === 'plain' || single) return;
|
|
3091
|
+
e.preventDefault();
|
|
3092
|
+
if (moveCell(e.shiftKey ? -1 : 1)) return;
|
|
3093
|
+
run(el, e.shiftKey ? 'outdent' : 'indent');
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
3096
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); return; }
|
|
3097
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
|
|
3098
|
+
e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
|
|
3099
|
+
}
|
|
3100
|
+
// Ctrl+Y redoes off the Mac (where it is yank)
|
|
3101
|
+
if (e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'y' && !MAC) {
|
|
3102
|
+
e.preventDefault(); run(el, 'redo'); return;
|
|
3103
|
+
}
|
|
3104
|
+
// No Cmd/Ctrl+K: that chord belongs to site search
|
|
3105
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
3106
|
+
const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
|
|
3107
|
+
if (!id) return;
|
|
3108
|
+
// Never the browser's own formatting
|
|
3109
|
+
e.preventDefault();
|
|
3110
|
+
if (allows(el, id)) run(el, id);
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
// Paste as our own subset, never the source app's markup or styles
|
|
3114
|
+
on('paste', (e) => {
|
|
3115
|
+
e.preventDefault();
|
|
3116
|
+
const dt = e.clipboardData; if (!dt) return;
|
|
3117
|
+
const text = dt.getData('text/plain');
|
|
3118
|
+
if (format === 'plain') return insertPlain(el, single ? flatText(text).trim() : text);
|
|
3119
|
+
const html = dt.getData('text/html');
|
|
3120
|
+
if (single) return insertFlat(flatClip(html, text), null, text);
|
|
3121
|
+
const clip = html ? unwrapBareSpans(format === 'markdown' ? markdownMarks(markdownBlocks(sanitize(flattenTables(html, true), true))) : sanitize(html, true))
|
|
3122
|
+
: markdownHtml(text.replace(/([^\n])\n(?!\n)/g, '$1 \n'));
|
|
3123
|
+
// Inline content (or a single paragraph of it) goes in at the caret, inside the caret's block,
|
|
3124
|
+
// leading space and formatting kept; the browser's insertHTML would place it beside the block
|
|
3125
|
+
const t = document.createElement('template'); t.innerHTML = clip;
|
|
3126
|
+
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;
|
|
3127
|
+
if (only) t.innerHTML = only.innerHTML;
|
|
3128
|
+
if (!t.content.querySelector(BLOCK_SEL)) { clearSpan(el); return insertNodes(el, t.content); }
|
|
3129
|
+
// Its own undo step: a pending burst lands first, the paste records after
|
|
3130
|
+
record(el);
|
|
3131
|
+
insertHTML(clip);
|
|
3132
|
+
record(el);
|
|
3133
|
+
});
|
|
3134
|
+
|
|
3135
|
+
const api = {
|
|
3136
|
+
get value() { watch(); return read(); },
|
|
3137
|
+
// The value it already holds keeps the markup and caret; history restarts either way
|
|
3138
|
+
set value(v) {
|
|
3139
|
+
if (v !== read()) write(v);
|
|
3140
|
+
resetHistory();
|
|
3141
|
+
settle();
|
|
3142
|
+
el._history.restoring = true;
|
|
3143
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3144
|
+
el._history.restoring = false;
|
|
3145
|
+
},
|
|
3146
|
+
get all() { watch(); return allStyle(el); },
|
|
3147
|
+
// Page styling, not an edit: no history step (the history takes it as its baseline)
|
|
3148
|
+
set all(v) {
|
|
3149
|
+
if (!allStyles) return;
|
|
3150
|
+
const was = allStyle(el);
|
|
3151
|
+
setAllStyle(el, v);
|
|
3152
|
+
const now = allStyle(el);
|
|
3153
|
+
if (JSON.stringify(now) === JSON.stringify(was)) return;
|
|
3154
|
+
rebaseAll(el, was, now);
|
|
3155
|
+
el.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: now }));
|
|
3156
|
+
sync();
|
|
3157
|
+
},
|
|
3158
|
+
get selection() { watch(); return el._selection || null; },
|
|
3159
|
+
get link() { watch(); return linkHref(); },
|
|
3160
|
+
set link(v) { if ((v == null ? '' : String(v)) === (linkHref() || '')) return; run(el, 'a', v); },
|
|
3161
|
+
focus: () => el.focus(),
|
|
3162
|
+
run: (id, arg) => run(el, id, arg),
|
|
3163
|
+
active: (id) => { watch(); return allows(el, id) && COMMANDS[id].active(undefined, el); },
|
|
3164
|
+
can: (id) => { watch(); return allows(el, id); },
|
|
3165
|
+
selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
|
|
3166
|
+
// The newest step in this field's history (a pending typing burst lands first)
|
|
3167
|
+
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; },
|
|
3168
|
+
markdown: () => single ? inlineMarkdown(el) : toMarkdown(el),
|
|
3169
|
+
html: () => sanitize(el.innerHTML, !single, true)
|
|
3170
|
+
};
|
|
3171
|
+
// $rich.strong(), $rich.table(3, 2)
|
|
3172
|
+
Object.keys(COMMANDS).forEach(id => { api[id] = (...args) => run(el, id, COMMANDS[id].args > 1 ? args : args[0]); });
|
|
3173
|
+
|
|
3174
|
+
el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
|
|
3175
|
+
areas.add(el);
|
|
3176
|
+
if (window.__manifestRichStubWrites && window.__manifestRichStubWrites.length) setTimeout(flushStubWrites, 0);
|
|
3177
|
+
settle();
|
|
3178
|
+
el._history.stack[0].html = el.innerHTML; el._history.stack[0].all = allKey(el);
|
|
3179
|
+
setTimeout(sync, 0);
|
|
3180
|
+
|
|
3181
|
+
const destroy = () => {
|
|
3182
|
+
if (!areas.has(el)) return;
|
|
3183
|
+
if (tracked === el) untrack();
|
|
3184
|
+
ac.abort();
|
|
3185
|
+
clearTimeout(el._history && el._history.timer);
|
|
3186
|
+
if (lastFocused === el) release(el);
|
|
3187
|
+
areas.delete(el);
|
|
3188
|
+
prior.forEach(([n, v]) => v == null ? el.removeAttribute(n) : el.setAttribute(n, v));
|
|
3189
|
+
[...el.attributes].forEach(a => { if (a.name.startsWith('data-rich-text')) el.removeAttribute(a.name); });
|
|
3190
|
+
el.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
|
|
3191
|
+
SELECTION_VARS.forEach(v => el.style.removeProperty(v));
|
|
3192
|
+
priorAll.forEach(([v, was]) => { if (was) el.style.setProperty(v, was); else el.style.removeProperty(v); });
|
|
3193
|
+
if (!areas.size) {
|
|
3194
|
+
SELECTION_VARS.forEach(v => document.documentElement.style.removeProperty(v));
|
|
3195
|
+
document.documentElement.removeAttribute('data-rich-text-selected');
|
|
3196
|
+
if (document.documentElement.getAttribute('style') === '') document.documentElement.removeAttribute('style');
|
|
3197
|
+
}
|
|
3198
|
+
if (el.getAttribute('style') === '') el.removeAttribute('style');
|
|
3199
|
+
['_rt', '_history', '_range', '_pending', '_exit', '_after', '_nb', '_delOut', '_selection'].forEach(k => { delete el[k]; });
|
|
3200
|
+
sync();
|
|
3201
|
+
};
|
|
3202
|
+
|
|
3203
|
+
return { api, load, destroy };
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/* ---- Engine API ---- */
|
|
3207
|
+
const FORMATS = new Set(['markdown', 'html', 'plain']);
|
|
3208
|
+
function attach(el, opts = {}) {
|
|
3209
|
+
if (!el || el.nodeType !== 1) return null;
|
|
3210
|
+
if (el._rt) return el._rt.handle || null;
|
|
3211
|
+
listen();
|
|
3212
|
+
const controlled = opts.value !== undefined;
|
|
3213
|
+
const core = setup(el, {
|
|
3214
|
+
format: FORMATS.has(opts.format) ? opts.format : 'markdown',
|
|
3215
|
+
inline: opts.mode === 'inline', breaks: !!opts.breaks, all: opts.all !== false, literal: !!opts.literal,
|
|
3216
|
+
bound: typeof opts.onChange === 'function', controlled,
|
|
3217
|
+
onChange: typeof opts.onChange === 'function' ? opts.onChange : null
|
|
3218
|
+
});
|
|
3219
|
+
if (controlled) core.load(opts.value);
|
|
3220
|
+
const handle = Object.create(core.api);
|
|
3221
|
+
handle.el = el;
|
|
3222
|
+
handle.detach = core.destroy;
|
|
3223
|
+
el._rt.handle = handle;
|
|
3224
|
+
return handle;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
document.addEventListener('alpine:init', init);
|
|
3228
|
+
if (window.Alpine && Alpine.directive) init();
|
|
3229
|
+
|
|
3230
|
+
window.ManifestRichText = { toMarkdown, fromMarkdown: markdownHtml, sanitize, commands: () => Object.keys(COMMANDS), attach };
|
|
3231
|
+
})();
|