mnfst 0.5.232 → 0.5.234
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.d.ts +4 -2
- package/lib/manifest.data.js +1 -1
- package/lib/manifest.edit.js +129 -40
- package/lib/manifest.integrity.json +6 -6
- package/lib/manifest.js +44 -3
- package/lib/manifest.rich.text.js +642 -175
- package/lib/manifest.sw.js +1 -1
- package/package.json +2 -1
|
@@ -19,6 +19,37 @@
|
|
|
19
19
|
const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
|
|
20
20
|
const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
|
|
21
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
|
+
|
|
22
53
|
/* ---- URL policy ---- */
|
|
23
54
|
// No script schemes; data: only as an image.
|
|
24
55
|
function unsafeUrl(url, image) {
|
|
@@ -32,32 +63,137 @@
|
|
|
32
63
|
|
|
33
64
|
/* ---- HTML → Markdown ---- */
|
|
34
65
|
const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
|
|
35
|
-
const
|
|
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, '\\&');
|
|
36
69
|
// Line starts that would parse as a heading, list or rule
|
|
37
70
|
const escLead = (text) => text.split('\n').map(l => l
|
|
38
71
|
.replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
|
|
39
72
|
.replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
|
|
40
73
|
.replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
|
|
41
74
|
.replace(/^(\s*)(\d+)\.(?=\s)/, '$1$2\\.')).join('\n');
|
|
42
|
-
|
|
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
|
+
};
|
|
43
84
|
|
|
44
|
-
|
|
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 (n.tagName === 'A') return last ? ')' : '[';
|
|
96
|
+
if (MARKS[n.tagName]) return MARKS[n.tagName][0]; // its delimiter
|
|
97
|
+
const t = n.textContent; return t ? (last ? t[t.length - 1] : t[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
|
|
98
|
+
};
|
|
99
|
+
function inlineRaw(node, ctx) {
|
|
45
100
|
let out = '';
|
|
46
101
|
node.childNodes.forEach(n => {
|
|
47
|
-
if (n.nodeType === 3) { out += esc(n.textContent); return; }
|
|
102
|
+
if (n.nodeType === 3) { out += ctx.link ? esc(n.textContent).replace(/[()]/g, '\\$&') : esc(n.textContent); return; } // in link text, no "(" can start a destination
|
|
48
103
|
if (n.nodeType !== 1) return;
|
|
49
104
|
const tag = n.tagName;
|
|
50
105
|
if (tag === 'BR') { out += ' \n'; return; }
|
|
51
106
|
if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src && !unsafeUrl(src, true) ? `})` : ''; return; }
|
|
52
|
-
if (tag === 'A'
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
107
|
+
if (tag === 'A' && /(^|[^\\])!$/.test(out) && n.getAttribute('href')) out = out.slice(0, -1) + '\\!'; // not an image
|
|
108
|
+
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; }
|
|
109
|
+
if (tag === 'A') {
|
|
110
|
+
const h = n.getAttribute('href') || '', title = n.getAttribute('title');
|
|
111
|
+
const tt = title ? title.replace(/\n/g, ' ') : '';
|
|
112
|
+
const t = !tt ? '' : !tt.includes('"') ? ` "${tt.replace(/\\/g, '\\\\')}"` : !tt.includes("'") ? ` '${tt.replace(/\\/g, '\\\\')}'` : ` "${tt.replace(/[\\"]/g, '\\$&')}"`;
|
|
113
|
+
out += h && !unsafeUrl(h) ? `[${inlineRaw(n, { ...ctx, prev: '[', next: ']', link: true, trims: false })}](${mdUrl(h)}${t})` : inlineRaw(n, ctx);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let m = MARKS[tag];
|
|
117
|
+
if (!m) { out += inlineRaw(n, ctx); return; }
|
|
118
|
+
if (tag === 'CODE') {
|
|
119
|
+
// A fence longer than any backtick run inside; padded where the code starts or ends with one
|
|
120
|
+
const code = n.textContent; if (!code) return;
|
|
121
|
+
// Code holding a backtick needs a longer fence, which renderers do not all read alike
|
|
122
|
+
// around emphasis or in link text: written as HTML
|
|
123
|
+
if (code.includes('`')) { out += '<code>' + esc(code) + '</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], trims: true });
|
|
131
|
+
// Delimiters hug the text: spaces go outside
|
|
132
|
+
let [, lead, core, trail] = body.match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
133
|
+
const after = textEdge(n.nextSibling, false);
|
|
134
|
+
// What will be against the delimiters: a space moved out of a markdown parent's edge is
|
|
135
|
+
// not there, the parent's delimiter is
|
|
136
|
+
const prev = lead ? (!out && ctx.trims ? ctx.prev : ' ') : (out ? out[out.length - 1] : ctx.prev);
|
|
137
|
+
const next = trail ? (after == null && ctx.trims ? ctx.next : ' ') : (after == null ? ctx.next : after);
|
|
138
|
+
const nonspace = /\S/.test(prev || '') || /\S/.test(next || '');
|
|
139
|
+
// Delimiters every renderer reads the same way: whitespace or plain punctuation outside them
|
|
140
|
+
// (an enclosing mark's same delimiter too), text inside them that starts and ends plainly.
|
|
141
|
+
// Anything else (letters, another delimiter or an HTML tag against them) is written as HTML.
|
|
142
|
+
const isS = (c) => !c || /\s/.test(c), isD = (c) => c === '*' || c === '_' || c === '~';
|
|
143
|
+
// Punctuation outside only where text is inside: the delimiter can then only open (or close)
|
|
144
|
+
// Letters outside only between letters (intraword, as in foo**bar**), and only outside any other * mark
|
|
145
|
+
const wordy = (c) => !!c && !isS(c) && !MD_PUNCT.test(c);
|
|
146
|
+
const outside = (c, fromCtx, inner) => isS(c) || (fromCtx && c === m[0]) || (MD_PUNCT.test(c) && !isD(c) && c !== '<' && c !== '>' && c !== '`' && wordy(inner))
|
|
147
|
+
|| (wordy(c) && wordy(inner) && !ctx.star && m[0] === '*' && !n.querySelector('strong, b, em, i'));
|
|
148
|
+
const f = core[0], l = core[core.length - 1], bare = core.replace(new RegExp('^\\' + m[0] + '+|\\' + m[0] + '+$', 'g'), '');
|
|
149
|
+
const clean = outside(prev, !lead && !out, bare[0]) && outside(next, !trail && after == null, bare[bare.length - 1])
|
|
150
|
+
&& !isS(f) && !isS(l) && f !== '\\' && !/\\.$/.test(core) && f !== '<' && l !== '>'
|
|
151
|
+
&& !(isD(f) && f !== m[0]) && !(isD(l) && l !== m[0])
|
|
152
|
+
// runs merged at both ends (***a* b *c***) only around one inner mark (***all***)
|
|
153
|
+
&& !(f === m[0] && l === m[0] && [...n.childNodes].filter(c => c.nodeType === 1 || (c.nodeType === 3 && c.nodeValue.trim())).length > 1);
|
|
154
|
+
if ((m[0] === '*' || m[0] === '~') && (ctx.html === 'all' || (ctx.html === 'touch' && nonspace) || !clean)) {
|
|
155
|
+
const t = tag === 'STRONG' || tag === 'B' ? 'strong' : m[0] === '~' ? 's' : 'em';
|
|
156
|
+
// spaces stay inside a tag: what is against its children stays as computed
|
|
157
|
+
out += `<${t}>` + inlineRaw(n, { ...ctx, prev: '>', next: '<', trims: false }) + `</${t}>`;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (m[0] === '*') [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '*', next: '*', star: true, trims: true }).match(/^(\s*)([\s\S]*?)(\s*)$/);
|
|
161
|
+
out += lead + m + core + m + trail;
|
|
57
162
|
});
|
|
58
163
|
return out;
|
|
59
164
|
}
|
|
60
165
|
|
|
166
|
+
// Inline markdown for a node, checked by reading it back: in the rare arrangement where a
|
|
167
|
+
// * or _ run would pair differently, em/strong touching text go as HTML, else all of them
|
|
168
|
+
function inline(node) {
|
|
169
|
+
const want = shapeOf(node), ctx = { prev: '', next: '', star: false, link: false, html: '' };
|
|
170
|
+
let first = null;
|
|
171
|
+
for (const html of ['', 'touch', 'all']) {
|
|
172
|
+
const md = inlineRaw(node, { ...ctx, html });
|
|
173
|
+
if (first == null) first = md;
|
|
174
|
+
// Read back as this reader does and as other markdown renderers (x-markdown) do
|
|
175
|
+
const t = document.createElement('template'); t.innerHTML = inlineHtml(md);
|
|
176
|
+
const u = document.createElement('template'); u.innerHTML = inlineHtml(md, { strict: true });
|
|
177
|
+
if (shapeOf(t.content) === want && shapeOf(u.content) === want) return md;
|
|
178
|
+
}
|
|
179
|
+
return first; // what no form can carry (an unsafe link, say) is not a reason to change notation
|
|
180
|
+
}
|
|
181
|
+
// Every character with the formatting it shows (strong/em/strike on spaces show nothing)
|
|
182
|
+
function shapeOf(root) {
|
|
183
|
+
const out = [], KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', S: 's', DEL: 's', CODE: 'c' };
|
|
184
|
+
const walk = (n, f) => n.childNodes.forEach(c => {
|
|
185
|
+
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; }
|
|
186
|
+
if (c.nodeType !== 1) return;
|
|
187
|
+
if (c.tagName === 'BR') { out.push('\n'); return; }
|
|
188
|
+
if (c.tagName === 'IMG') { const src = c.getAttribute('src') || ''; if (src && !unsafeUrl(src, true)) out.push('img:' + src + '|' + (c.getAttribute('alt') || '')); return; }
|
|
189
|
+
const href = c.tagName === 'A' ? c.getAttribute('href') || '' : '';
|
|
190
|
+
const k = KIND[c.tagName], h = href && !unsafeUrl(href) ? 'a:' + href : null;
|
|
191
|
+
walk(c, [...new Set([...f, ...(k ? [k] : []), ...(h ? [h] : [])])].sort());
|
|
192
|
+
});
|
|
193
|
+
walk(root, []);
|
|
194
|
+
return out.join('\u0001').replace(/^(\s(\u0001|$))+|(\u0001\s)+$/g, '');
|
|
195
|
+
}
|
|
196
|
+
|
|
61
197
|
const preText = (n) => [...n.childNodes].map(c =>
|
|
62
198
|
c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
|
|
63
199
|
|
|
@@ -66,7 +202,7 @@
|
|
|
66
202
|
if (node.nodeType !== 1) return '';
|
|
67
203
|
const tag = node.tagName;
|
|
68
204
|
if (tag === 'HR') return '---';
|
|
69
|
-
if (tag === 'PRE') return '
|
|
205
|
+
if (tag === 'PRE') return '```' + (node.getAttribute('data-lang') || '').replace(/[^\w+#.-]/g, '') + '\n' + preText(node).replace(/\n+$/, '') + '\n```';
|
|
70
206
|
if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
|
|
71
207
|
if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
|
|
72
208
|
if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
|
|
@@ -110,11 +246,18 @@
|
|
|
110
246
|
return parts.join('\n\n');
|
|
111
247
|
}
|
|
112
248
|
|
|
113
|
-
|
|
249
|
+
// Markdown carries a mark once: one inside another with the same delimiter adds nothing
|
|
250
|
+
const flattenMarks = (root) => [...root.querySelectorAll('*')].forEach(el => {
|
|
251
|
+
const m = MARKS[el.tagName]; if (!m) return;
|
|
252
|
+
for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (MARKS[p.tagName] === m) { el.replaceWith(...el.childNodes); return; }
|
|
253
|
+
});
|
|
254
|
+
const mdCopy = (root) => { const c = root.cloneNode(true); flattenMarks(c); mergeAdjacent(c, sameDelimiter); return c; };
|
|
255
|
+
const toMarkdown = (root) => blocks(mdCopy(root), 0).replace(/\n{3,}/g, '\n\n').trim();
|
|
114
256
|
// Single-line values never parse block syntax, so line starts need no escape
|
|
115
257
|
const inlineMarkdown = (root) => {
|
|
116
258
|
const flat = document.createElement('div');
|
|
117
259
|
flat.innerHTML = flattenLines(root.innerHTML, true);
|
|
260
|
+
flattenMarks(flat); mergeAdjacent(flat, sameDelimiter);
|
|
118
261
|
return inline(flat).trim();
|
|
119
262
|
};
|
|
120
263
|
|
|
@@ -181,19 +324,150 @@
|
|
|
181
324
|
/* ---- Markdown → HTML ---- */
|
|
182
325
|
const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
183
326
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
327
|
+
/* Inline markdown: a small CommonMark-style reader. Code spans, links and images are found
|
|
328
|
+
left to right; runs of * _ ~ are delimiters, matched innermost first with CommonMark's
|
|
329
|
+
rules (the rule of 3 included). `*` and `~~` open before any non-space and close after
|
|
330
|
+
one (CommonMark also weighs punctuation); `_` keeps CommonMark's word-boundary rules. */
|
|
331
|
+
const PUNCT = /[!-\/:-@\[-`{-~ -⸀-]/;
|
|
332
|
+
const isSpace = (c) => !c || /\s/.test(c);
|
|
333
|
+
// opts.raw: backslashes are text and no inline HTML is read (markdown being typed);
|
|
334
|
+
// opts.after: the character after the source, for the flanking of a run that ends it
|
|
335
|
+
function inlineHtml(src, opts = {}) {
|
|
336
|
+
const s = String(src == null ? '' : src);
|
|
337
|
+
const parts = [], delims = [];
|
|
338
|
+
let text = '';
|
|
339
|
+
const flush = () => { if (text) { parts.push(escHtml(text)); text = ''; } };
|
|
340
|
+
// A code span's closing run: the next backtick run of the same length
|
|
341
|
+
const codeEnd = (from, len) => {
|
|
342
|
+
for (let j = from; j < s.length;) {
|
|
343
|
+
const k = s.indexOf('`', j); if (k < 0) return -1;
|
|
344
|
+
const n = s.slice(k).match(/^`+/)[0].length;
|
|
345
|
+
if (n === len) return k;
|
|
346
|
+
j = k + n;
|
|
347
|
+
}
|
|
348
|
+
return -1;
|
|
349
|
+
};
|
|
350
|
+
// A link destination from the "(" at `at`: <…>, or balanced parentheses; an optional title is skipped
|
|
351
|
+
const destination = (at) => {
|
|
352
|
+
let j = at + 1, url;
|
|
353
|
+
while (s[j] === ' ' || s[j] === '\t') j++;
|
|
354
|
+
if (s[j] === '<') {
|
|
355
|
+
const e = s.indexOf('>', j); if (e < 0 || /[\n<]/.test(s.slice(j + 1, e))) return null;
|
|
356
|
+
url = s.slice(j + 1, e); j = e + 1;
|
|
357
|
+
} else {
|
|
358
|
+
const from = j;
|
|
359
|
+
for (let depth = 0; j < s.length; j++) {
|
|
360
|
+
const c = s[j];
|
|
361
|
+
if (c === '\\' && /[!-\/:-@\[-`{-~]/.test(s[j + 1] || '')) { j++; continue; }
|
|
362
|
+
if (/[\s\x00-\x1f]/.test(c)) break;
|
|
363
|
+
if (c === '(') depth++;
|
|
364
|
+
else if (c === ')') { if (!depth) break; depth--; }
|
|
365
|
+
}
|
|
366
|
+
url = s.slice(from, j);
|
|
367
|
+
}
|
|
368
|
+
const gap = j;
|
|
369
|
+
while (/[ \t\n]/.test(s[j] || '')) j++;
|
|
370
|
+
const q = s[j];
|
|
371
|
+
let title = null;
|
|
372
|
+
if (j > gap && (q === '"' || q === "'" || q === '(')) {
|
|
373
|
+
const close = q === '(' ? ')' : q;
|
|
374
|
+
let e = j + 1; for (; e < s.length && s[e] !== close; e++) if (s[e] === '\\') e++;
|
|
375
|
+
if (e >= s.length) return null;
|
|
376
|
+
title = s.slice(j + 1, e).replace(/\\([!-\/:-@\[-`{-~])/g, '$1');
|
|
377
|
+
j = e + 1; while (/[ \t\n]/.test(s[j] || '')) j++;
|
|
378
|
+
}
|
|
379
|
+
if (s[j] !== ')') return null;
|
|
380
|
+
return { url: url.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'), title, end: j + 1 };
|
|
381
|
+
};
|
|
382
|
+
// A bracket's matching close, skipping escapes and code spans
|
|
383
|
+
const closeBracket = (from) => {
|
|
384
|
+
for (let i = from, depth = 0; i < s.length; i++) {
|
|
385
|
+
const c = s[i];
|
|
386
|
+
if (c === '\\') { i++; continue; }
|
|
387
|
+
if (c === '`') { const n = s.slice(i).match(/^`+/)[0].length, e = codeEnd(i + n, n); i = (e < 0 ? i + n : e + n) - 1; continue; }
|
|
388
|
+
if (c === '[') depth++;
|
|
389
|
+
else if (c === ']') { if (!depth) return i; depth--; }
|
|
390
|
+
}
|
|
391
|
+
return -1;
|
|
392
|
+
};
|
|
393
|
+
for (let i = 0; i < s.length;) {
|
|
394
|
+
const c = s[i];
|
|
395
|
+
if (c === '\\' && !opts.raw && i + 1 < s.length && /[!-\/:-@\[-`{-~]/.test(s[i + 1])) { text += s[i + 1]; i += 2; continue; }
|
|
396
|
+
if (c === '`') {
|
|
397
|
+
const run = s.slice(i).match(/^`+/)[0], end = codeEnd(i + run.length, run.length);
|
|
398
|
+
if (end < 0) { text += run; i += run.length; continue; }
|
|
399
|
+
let code = s.slice(i + run.length, end).replace(/\n/g, ' ');
|
|
400
|
+
if (code.length > 1 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()) code = code.slice(1, -1);
|
|
401
|
+
flush(); parts.push(`<code>${escHtml(code)}</code>`);
|
|
402
|
+
i = end + run.length; continue;
|
|
403
|
+
}
|
|
404
|
+
if (c === '[' || (c === '!' && s[i + 1] === '[')) {
|
|
405
|
+
const img = c === '!', open = i + (img ? 2 : 1), close = closeBracket(open);
|
|
406
|
+
const m = close > 0 && s[close + 1] === '(' && destination(close + 1);
|
|
407
|
+
if (m) {
|
|
408
|
+
const url = m.url, body = s.slice(open, close);
|
|
409
|
+
flush();
|
|
410
|
+
if (img) parts.push(unsafeUrl(url, true) ? '' : `<img src="${escHtml(url)}" alt="${escHtml(body.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'))}">`);
|
|
411
|
+
else parts.push(unsafeUrl(url) || !url ? inlineHtml(body, { raw: opts.raw, strict: opts.strict }) : `<a href="${escHtml(url)}"${m.title != null ? ` title="${escHtml(m.title)}"` : ''}>${inlineHtml(body, { raw: opts.raw, strict: opts.strict })}</a>`);
|
|
412
|
+
i = m.end; continue;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// The inline HTML this writer uses where markdown has no form (raw tags, as CommonMark passes them)
|
|
416
|
+
if (c === '<' && !opts.raw) {
|
|
417
|
+
// Inline HTML for marks markdown has (as CommonMark passes it); other tags stay text
|
|
418
|
+
const t = s.slice(i).match(/^<(\/?)(strong|em|b|i|s|del|code)>/i);
|
|
419
|
+
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; }
|
|
420
|
+
// An autolink: <scheme:…>
|
|
421
|
+
const a = s.slice(i).match(/^<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)>/i);
|
|
422
|
+
if (a && !unsafeUrl(a[1])) { flush(); parts.push(`<a href="${escHtml(a[1])}">${escHtml(a[1])}</a>`); i += a[0].length; continue; }
|
|
423
|
+
}
|
|
424
|
+
if (c === '\n' && / {2,}$/.test(text)) { text = text.replace(/ +$/, ''); flush(); parts.push('<br>'); i++; continue; }
|
|
425
|
+
if (c === '*' || c === '_' || c === '~') {
|
|
426
|
+
const run = s.slice(i).match(c === '*' ? /^\*+/ : c === '_' ? /^_+/ : /^~+/)[0];
|
|
427
|
+
const before = i ? s[i - 1] : '', after = i + run.length < s.length ? s[i + run.length] : (opts.after || '');
|
|
428
|
+
let open, close;
|
|
429
|
+
if (c === '_') {
|
|
430
|
+
const left = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
|
|
431
|
+
const right = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
|
|
432
|
+
open = left && (!right || PUNCT.test(before)); close = right && (!left || PUNCT.test(after));
|
|
433
|
+
} else if (opts.strict) {
|
|
434
|
+
// CommonMark's flanking, punctuation weighed (what other markdown renderers do)
|
|
435
|
+
open = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
|
|
436
|
+
close = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
|
|
437
|
+
} else { open = !isSpace(after); close = !isSpace(before); }
|
|
438
|
+
if (c === '~' && run.length !== 2) { open = close = false; }
|
|
439
|
+
if (!open && !close) { text += run; i += run.length; continue; }
|
|
440
|
+
flush();
|
|
441
|
+
const d = { ch: c, count: run.length, orig: run.length, open, close };
|
|
442
|
+
parts.push(d); delims.push(d);
|
|
443
|
+
i += run.length; continue;
|
|
444
|
+
}
|
|
445
|
+
text += c; i++;
|
|
446
|
+
}
|
|
447
|
+
flush();
|
|
448
|
+
// Innermost first: each closer takes the nearest opener it can pair with
|
|
449
|
+
for (let ci = 0; ci < delims.length; ci++) {
|
|
450
|
+
const closer = delims[ci];
|
|
451
|
+
while (closer.close && closer.count) {
|
|
452
|
+
let oi = ci - 1;
|
|
453
|
+
for (; oi >= 0; oi--) {
|
|
454
|
+
const o = delims[oi];
|
|
455
|
+
if (o.ch !== closer.ch || !o.open || !o.count) continue;
|
|
456
|
+
if (closer.ch === '~') { if (o.count === 2 && closer.count === 2) break; continue; }
|
|
457
|
+
if ((o.close || closer.open) && (o.orig + closer.orig) % 3 === 0 && !(o.orig % 3 === 0 && closer.orig % 3 === 0)) continue;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
if (oi < 0) break;
|
|
461
|
+
const opener = delims[oi], use = closer.ch === '~' ? 2 : (opener.count >= 2 && closer.count >= 2 ? 2 : 1);
|
|
462
|
+
const tag = closer.ch === '~' ? 's' : use === 2 ? 'strong' : 'em';
|
|
463
|
+
const a = parts.indexOf(opener), b = parts.indexOf(closer);
|
|
464
|
+
const inner = parts.splice(a + 1, b - a - 1).map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
|
|
465
|
+
parts.splice(a + 1, 0, `<${tag}>${inner}</${tag}>`);
|
|
466
|
+
for (let k = oi + 1; k < ci; k++) delims[k].count = 0; // delimiters inside are text now (already rendered)
|
|
467
|
+
opener.count -= use; closer.count -= use;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return parts.map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
|
|
197
471
|
}
|
|
198
472
|
|
|
199
473
|
// Four spaces per nesting level.
|
|
@@ -220,7 +494,7 @@
|
|
|
220
494
|
function fromMarkdown(md) {
|
|
221
495
|
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
|
222
496
|
const out = [];
|
|
223
|
-
let para = [], list = null, quote = null, fence = null;
|
|
497
|
+
let para = [], list = null, quote = null, fence = null, lang = '';
|
|
224
498
|
const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
|
|
225
499
|
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; };
|
|
226
500
|
const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
|
|
@@ -228,8 +502,8 @@
|
|
|
228
502
|
|
|
229
503
|
for (const raw of lines) {
|
|
230
504
|
const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
|
|
231
|
-
if (fence !== null) { if (
|
|
232
|
-
if (/^```/.test(line)) { flushAll(); fence = []; continue; }
|
|
505
|
+
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; }
|
|
506
|
+
if (/^```/.test(line)) { flushAll(); fence = []; lang = (line.slice(3).trim().match(/^[\w+#.-]+/) || [''])[0]; continue; }
|
|
233
507
|
if (!line.trim()) { flushAll(); continue; }
|
|
234
508
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
235
509
|
if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
|
|
@@ -245,7 +519,7 @@
|
|
|
245
519
|
flushList(); flushQuote();
|
|
246
520
|
para.push(line);
|
|
247
521
|
}
|
|
248
|
-
if (fence) out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`);
|
|
522
|
+
if (fence) out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`);
|
|
249
523
|
flushAll();
|
|
250
524
|
return out.join('');
|
|
251
525
|
}
|
|
@@ -268,9 +542,10 @@
|
|
|
268
542
|
const meta = INLINE_TAGS[c.tagName.toLowerCase()];
|
|
269
543
|
[...c.attributes].forEach(a => {
|
|
270
544
|
const n = a.name.toLowerCase();
|
|
271
|
-
if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
|
|
545
|
+
if (c.tagName === 'A' && ((n === 'href' && !unsafeUrl(a.value)) || n === 'title')) return;
|
|
272
546
|
if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
|
|
273
547
|
if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
|
|
548
|
+
if (c.tagName === 'PRE' && n === 'data-lang' && /^[\w+#.-]{1,40}$/.test(a.value)) return; // a code fence's language
|
|
274
549
|
if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
|
|
275
550
|
if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
|
|
276
551
|
if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
|
|
@@ -278,8 +553,20 @@
|
|
|
278
553
|
});
|
|
279
554
|
});
|
|
280
555
|
walk(t.content);
|
|
556
|
+
mergeAdjacent(t.content);
|
|
557
|
+
if (allowBlocks) dropTrailingEmpty(t.content);
|
|
281
558
|
return t.innerHTML;
|
|
282
559
|
}
|
|
560
|
+
// The empty paragraph after a closing list, table or code block is an editing affordance only:
|
|
561
|
+
// values never end in one (nor in several)
|
|
562
|
+
function dropTrailingEmpty(root) {
|
|
563
|
+
for (let n = root.lastChild; n;) {
|
|
564
|
+
const prev = n.previousSibling;
|
|
565
|
+
if ((n.nodeType === 3 && !n.nodeValue.trim()) || (n.nodeType === 1 && n.tagName === 'P' && !n.textContent.trim() && !n.querySelector('img, input'))) n.remove();
|
|
566
|
+
else break;
|
|
567
|
+
n = prev;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
283
570
|
|
|
284
571
|
const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
|
|
285
572
|
|
|
@@ -392,6 +679,28 @@
|
|
|
392
679
|
return pre.toString().length;
|
|
393
680
|
}
|
|
394
681
|
|
|
682
|
+
// The selection as text offsets in root, and back
|
|
683
|
+
function textSpan(root) {
|
|
684
|
+
const r = range(); if (!r || !root.contains(r.startContainer) || !root.contains(r.endContainer)) return null;
|
|
685
|
+
const at = (node, off) => { const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(node, off); return pre.toString().length; };
|
|
686
|
+
return [at(r.startContainer, r.startOffset), at(r.endContainer, r.endOffset)];
|
|
687
|
+
}
|
|
688
|
+
function selectSpan(root, [from, to]) {
|
|
689
|
+
const point = (offset, end) => {
|
|
690
|
+
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
691
|
+
let seen = 0, last = null;
|
|
692
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
693
|
+
last = n;
|
|
694
|
+
if (end ? seen + n.length >= offset : seen + n.length > offset) return [n, offset - seen];
|
|
695
|
+
seen += n.length;
|
|
696
|
+
}
|
|
697
|
+
return last ? [last, last.length] : [root, 0];
|
|
698
|
+
};
|
|
699
|
+
const r = document.createRange(), a = point(from, false), b = point(to, true);
|
|
700
|
+
r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
|
|
701
|
+
const s = sel(); s.removeAllRanges(); s.addRange(r);
|
|
702
|
+
}
|
|
703
|
+
|
|
395
704
|
function caretIn(root, offset) {
|
|
396
705
|
const s = sel(), r = document.createRange();
|
|
397
706
|
if (offset == null) offset = 0;
|
|
@@ -630,7 +939,9 @@
|
|
|
630
939
|
const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
|
|
631
940
|
const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
|
|
632
941
|
const rowsOf = (table) => [...table.rows];
|
|
633
|
-
const emptyCell = () => { const td = document.createElement(
|
|
942
|
+
const emptyCell = (tag = 'td') => { const td = document.createElement(tag); td.appendChild(document.createElement('br')); return td; };
|
|
943
|
+
// A header row (in a thead, or of header cells only) is padded with header cells
|
|
944
|
+
const rowCellTag = (row) => row.parentElement && row.parentElement.tagName === 'THEAD' || (row.cells.length && [...row.cells].every(c => c.tagName === 'TH')) ? 'th' : 'td';
|
|
634
945
|
|
|
635
946
|
// Occupancy grid: every slot maps to the cell covering it, spans included
|
|
636
947
|
function tableGrid(table) {
|
|
@@ -674,7 +985,7 @@
|
|
|
674
985
|
const width = Math.max(0, ...grid.map(r => r.length));
|
|
675
986
|
rows.forEach((row, r) => {
|
|
676
987
|
const have = (grid[r] || []).filter(Boolean).length;
|
|
677
|
-
for (let i = have; i < width; i++) row.appendChild(emptyCell());
|
|
988
|
+
for (let i = have; i < width; i++) row.appendChild(emptyCell(rowCellTag(row)));
|
|
678
989
|
});
|
|
679
990
|
if (!table.querySelector('tbody, thead')) {
|
|
680
991
|
const body = document.createElement('tbody');
|
|
@@ -783,13 +1094,14 @@
|
|
|
783
1094
|
const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
|
|
784
1095
|
if (cols < 2 && rows < 2) return;
|
|
785
1096
|
cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
|
|
786
|
-
|
|
1097
|
+
// A header cell splits into header cells; rows below take their row's kind
|
|
1098
|
+
for (let i = 1; i < cols; i++) cell.after(emptyCell(cell.tagName.toLowerCase()));
|
|
787
1099
|
if (rows > 1) {
|
|
788
1100
|
const table = cell.closest('table'), { row, col } = gridPosition(cell);
|
|
789
1101
|
for (let r = 1; r < rows; r++) {
|
|
790
1102
|
const target = rowsOf(table)[row + r]; if (!target) break;
|
|
791
1103
|
const at = cellCovering(target, col);
|
|
792
|
-
const fresh = emptyCell();
|
|
1104
|
+
const fresh = emptyCell(rowCellTag(target));
|
|
793
1105
|
if (at) at.before(fresh); else target.appendChild(fresh);
|
|
794
1106
|
}
|
|
795
1107
|
}
|
|
@@ -901,39 +1213,48 @@
|
|
|
901
1213
|
return null;
|
|
902
1214
|
}
|
|
903
1215
|
|
|
1216
|
+
// Sizes are written only once the pointer actually drags: a click on a border writes nothing
|
|
904
1217
|
function startResize(e, hit) {
|
|
905
1218
|
const cell = hit.cell, table = cell.closest('table');
|
|
906
1219
|
const area = cell.closest('[data-rich-text]');
|
|
907
1220
|
e.preventDefault();
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
const row = cell.parentElement;
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
1221
|
+
const fromX = e.clientX, fromY = e.clientY;
|
|
1222
|
+
let apply = null;
|
|
1223
|
+
const begin = () => {
|
|
1224
|
+
area.setAttribute('data-rich-text-resizing', hit.axis);
|
|
1225
|
+
if (hit.axis === 'x') {
|
|
1226
|
+
const group = ensureCols(table);
|
|
1227
|
+
const { col } = gridPosition(cell);
|
|
1228
|
+
const index = Math.min(col + (cell.colSpan || 1) - 1, group.children.length - 1);
|
|
1229
|
+
const target = group.children[index];
|
|
1230
|
+
const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
|
|
1231
|
+
// The outer border sizes the table too
|
|
1232
|
+
const outer = index === group.children.length - 1;
|
|
1233
|
+
const tableStart = table.getBoundingClientRect().width;
|
|
1234
|
+
if (outer) table.style.width = Math.round(tableStart) + 'px';
|
|
1235
|
+
return (ev) => {
|
|
1236
|
+
const delta = ev.clientX - fromX;
|
|
1237
|
+
target.style.width = Math.max(28, Math.round(start + delta)) + 'px';
|
|
1238
|
+
if (outer) table.style.width = Math.max(60, Math.round(tableStart + delta)) + 'px';
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const row = cell.parentElement, start = row.getBoundingClientRect().height;
|
|
1242
|
+
return (ev) => { row.style.height = Math.max(20, Math.round(start + ev.clientY - fromY)) + 'px'; };
|
|
1243
|
+
};
|
|
932
1244
|
|
|
933
|
-
const move = (ev) => {
|
|
1245
|
+
const move = (ev) => {
|
|
1246
|
+
ev.preventDefault();
|
|
1247
|
+
if (!apply) {
|
|
1248
|
+
if (Math.abs(ev.clientX - fromX) < 3 && Math.abs(ev.clientY - fromY) < 3) return;
|
|
1249
|
+
record(area);
|
|
1250
|
+
apply = begin();
|
|
1251
|
+
}
|
|
1252
|
+
apply(ev);
|
|
1253
|
+
};
|
|
934
1254
|
const up = () => {
|
|
935
1255
|
document.removeEventListener('pointermove', move);
|
|
936
1256
|
document.removeEventListener('pointerup', up);
|
|
1257
|
+
if (!apply) return;
|
|
937
1258
|
area.removeAttribute('data-rich-text-resizing');
|
|
938
1259
|
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
939
1260
|
};
|
|
@@ -1278,6 +1599,13 @@
|
|
|
1278
1599
|
if (!frag.lastChild) return;
|
|
1279
1600
|
record(area);
|
|
1280
1601
|
r.deleteContents();
|
|
1602
|
+
// A caret between a block field's blocks (an empty field clicked) goes into the block beside it
|
|
1603
|
+
const c0 = range();
|
|
1604
|
+
if (c0 && c0.startContainer === area && !(area._rt && area._rt.inline)) {
|
|
1605
|
+
const into = (b) => b && b.nodeType === 1 && /^(P|H[1-6]|BLOCKQUOTE|PRE)$/.test(b.tagName);
|
|
1606
|
+
const before = area.childNodes[c0.startOffset - 1], after = area.childNodes[c0.startOffset];
|
|
1607
|
+
if (into(before)) caretIn(before, before.textContent.length); else if (into(after)) caretIn(after, 0);
|
|
1608
|
+
}
|
|
1281
1609
|
// An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
|
|
1282
1610
|
const holder = blockEl() || area;
|
|
1283
1611
|
if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
|
|
@@ -1287,7 +1615,16 @@
|
|
|
1287
1615
|
}
|
|
1288
1616
|
let end = frag.lastChild;
|
|
1289
1617
|
if (!end) return;
|
|
1618
|
+
const lastTop = end;
|
|
1290
1619
|
const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
|
|
1620
|
+
// Pasted content ending in a mark: the caret sits after it, as after a conversion
|
|
1621
|
+
if (lastTop.nodeType === 1 && INLINE_OK.has(lastTop.tagName) && !/^(BR|WBR|IMG)$/.test(lastTop.tagName) && lastTop.isConnected) {
|
|
1622
|
+
caretAt(tailOf(lastTop), 0);
|
|
1623
|
+
area._exit = lastTop;
|
|
1624
|
+
record(area);
|
|
1625
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1291
1628
|
// The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
|
|
1292
1629
|
const s = sel(), nr = document.createRange();
|
|
1293
1630
|
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; }
|
|
@@ -1357,7 +1694,16 @@
|
|
|
1357
1694
|
const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
|
|
1358
1695
|
const cols = n(parts[0]), rows = n(parts[1]);
|
|
1359
1696
|
const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
|
|
1360
|
-
insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
|
|
1697
|
+
insertHTML(`<table data-rich-text-new><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
|
|
1698
|
+
// The caret goes into the first cell, so typing and table commands start there
|
|
1699
|
+
const made = document.querySelector('table[data-rich-text-new]');
|
|
1700
|
+
if (made) {
|
|
1701
|
+
made.removeAttribute('data-rich-text-new');
|
|
1702
|
+
// One empty line after it to land on, not one per insertion (from inside a table, it lands after it)
|
|
1703
|
+
const blank = (e) => e && e.tagName === 'P' && !e.textContent.trim() && !e.querySelector('img');
|
|
1704
|
+
for (let n = made.nextElementSibling; blank(n) && blank(n.nextElementSibling);) { const x = n.nextElementSibling; x.remove(); }
|
|
1705
|
+
const first = made.querySelector('td, th'); if (first) caretIn(first, 0);
|
|
1706
|
+
}
|
|
1361
1707
|
}
|
|
1362
1708
|
|
|
1363
1709
|
// A trailing table, rule or similar block gets a line after it to land on
|
|
@@ -1387,8 +1733,21 @@
|
|
|
1387
1733
|
}
|
|
1388
1734
|
|
|
1389
1735
|
// A value styles the selection; an empty one removes that style from it (spans left bare go)
|
|
1736
|
+
// A style span covering exactly what a style span around it covers is folded into it, so one span
|
|
1737
|
+
// carries the combined styles and a re-applied value replaces the old one instead of nesting
|
|
1390
1738
|
function styleSpan(prop, v) {
|
|
1391
|
-
if (v != null && v !== '')
|
|
1739
|
+
if (v != null && v !== '') {
|
|
1740
|
+
const made = wrapInline('span', { [prop]: v }).map(sp => {
|
|
1741
|
+
for (let p = sp.parentElement; p && p.tagName === 'SPAN' && !p.hasAttribute('data-rich-text') && [...p.attributes].every(a => a.name === 'style') && [...p.childNodes].filter(c => c.nodeType === 1 || c.nodeValue.length).length === 1; p = sp.parentElement) {
|
|
1742
|
+
for (let i = 0; i < sp.style.length; i++) { const name = sp.style.item(i); p.style.setProperty(name, sp.style.getPropertyValue(name)); }
|
|
1743
|
+
sp.replaceWith(...sp.childNodes);
|
|
1744
|
+
sp = p;
|
|
1745
|
+
}
|
|
1746
|
+
return sp;
|
|
1747
|
+
});
|
|
1748
|
+
if (made.length) { const r = document.createRange(), last = made[made.length - 1]; r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length); const s = sel(); s.removeAllRanges(); s.addRange(r); }
|
|
1749
|
+
return made;
|
|
1750
|
+
}
|
|
1392
1751
|
const nodes = selectedTextNodes(), spans = new Set();
|
|
1393
1752
|
nodes.forEach(n => { for (let el = elementAt(n); el && el.closest && !el.hasAttribute('data-rich-text'); el = el.parentElement) if (el.tagName === 'SPAN' && el.style[prop]) spans.add(el); });
|
|
1394
1753
|
spans.forEach(s => { s.style[prop] = ''; if (!s.getAttribute('style')) s.removeAttribute('style'); if (!s.attributes.length) unwrapEl(s); });
|
|
@@ -1412,6 +1771,17 @@
|
|
|
1412
1771
|
const next = values && values[k];
|
|
1413
1772
|
if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
|
|
1414
1773
|
});
|
|
1774
|
+
const allKey = (area) => JSON.stringify(allStyle(area));
|
|
1775
|
+
// .all styles set from code: every history entry takes the change, so undo never removes it
|
|
1776
|
+
function rebaseAll(area, was, now) {
|
|
1777
|
+
const h = area._history; if (!h) return;
|
|
1778
|
+
const keys = [...new Set([...Object.keys(was), ...Object.keys(now)])].filter(k => was[k] !== now[k]);
|
|
1779
|
+
h.stack.forEach(e => {
|
|
1780
|
+
const o = e.all ? JSON.parse(e.all) : {};
|
|
1781
|
+
keys.forEach(k => { if (now[k]) o[k] = now[k]; else delete o[k]; });
|
|
1782
|
+
e.all = JSON.stringify(Object.fromEntries(Object.keys(ALL_VARS).filter(k => o[k]).map(k => [k, o[k]])));
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1415
1785
|
|
|
1416
1786
|
/* ---- Command table ---- */
|
|
1417
1787
|
const COMMANDS = Object.create(null);
|
|
@@ -1592,20 +1962,10 @@
|
|
|
1592
1962
|
{ re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
|
|
1593
1963
|
{ re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
|
|
1594
1964
|
{ re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
|
|
1595
|
-
{ re:
|
|
1965
|
+
{ re: /^```([\w+#.-]{0,40})\s*$/, make: (b, m) => { const pre = retagBlock(b, 'pre'); if (m[1]) pre.setAttribute('data-lang', m[1]); return pre; } },
|
|
1596
1966
|
{ re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
|
|
1597
1967
|
];
|
|
1598
1968
|
|
|
1599
|
-
const INLINE_RULES = [
|
|
1600
|
-
{ re: /\*\*([^*]+)\*\*/, tag: 'strong' },
|
|
1601
|
-
{ re: /__([^_]+)__/, tag: 'strong' },
|
|
1602
|
-
{ re: /(^|[^*])\*([^*\s][^*]*)\*/, tag: 'em', pre: 1, content: 2 },
|
|
1603
|
-
{ re: /~~([^~]+)~~/, tag: 'del' },
|
|
1604
|
-
{ re: /`([^`]+)`/, tag: 'code' },
|
|
1605
|
-
{ re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
|
|
1606
|
-
{ re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
|
|
1607
|
-
];
|
|
1608
|
-
|
|
1609
1969
|
function retagBlock(b, tag) {
|
|
1610
1970
|
const n = document.createElement(tag);
|
|
1611
1971
|
while (b.firstChild) n.appendChild(b.firstChild);
|
|
@@ -1640,44 +2000,35 @@
|
|
|
1640
2000
|
}
|
|
1641
2001
|
}
|
|
1642
2002
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
2003
|
+
// Markdown typed in a run of inline content (up to the caret, or all of it) re-read with the
|
|
2004
|
+
// markdown reader, so what converts while typing is what a saved value reads back as. Elements
|
|
2005
|
+
// already there go through as opaque characters. Null when nothing new would be made.
|
|
2006
|
+
const ATOM = 0xE000;
|
|
2007
|
+
function reparse(box, until, upto, after) {
|
|
2008
|
+
const kids = [...box.childNodes], end = until ? kids.indexOf(until) : kids.length;
|
|
2009
|
+
let from = 0;
|
|
2010
|
+
for (let i = 0; i < end; i++) if (kids[i].nodeType === 1 && (kids[i].tagName === 'BR' || BLOCK.includes(kids[i].tagName))) from = i + 1;
|
|
2011
|
+
const run = kids.slice(from, end), atoms = [];
|
|
2012
|
+
let src = '';
|
|
2013
|
+
for (const n of run) {
|
|
2014
|
+
if (n.nodeType === 3) { if (/[\uE000-\uF8FF]/.test(n.nodeValue)) return null; src += n.nodeValue; }
|
|
2015
|
+
else if (n.nodeType === 1) { if (atoms.length > 6000) return null; src += String.fromCharCode(ATOM + atoms.length); atoms.push(n); }
|
|
1649
2016
|
}
|
|
1650
|
-
|
|
1651
|
-
if (
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
// Only a pattern ending exactly at the caret; earliest start wins (image over link)
|
|
1667
|
-
function endInline(text) {
|
|
1668
|
-
let best = null;
|
|
1669
|
-
for (const rule of INLINE_RULES) {
|
|
1670
|
-
const re = new RegExp(rule.re.source, 'g');
|
|
1671
|
-
let m;
|
|
1672
|
-
while ((m = re.exec(text))) {
|
|
1673
|
-
if (m.index + m[0].length !== text.length) continue;
|
|
1674
|
-
const skip = rule.pre ? m[rule.pre].length : 0;
|
|
1675
|
-
const start = m.index + skip;
|
|
1676
|
-
if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
|
|
1677
|
-
break;
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
return best;
|
|
2017
|
+
if (until) src += until.nodeValue.slice(0, upto);
|
|
2018
|
+
if (!/[*_~`\[]/.test(src)) return null;
|
|
2019
|
+
const t = document.createElement('template');
|
|
2020
|
+
t.innerHTML = inlineHtml(src, { raw: true, after });
|
|
2021
|
+
const made = [...t.content.querySelectorAll('*')];
|
|
2022
|
+
if (!made.length) return null;
|
|
2023
|
+
// Elements already there land whole and once, never in a code span or an attribute
|
|
2024
|
+
if (made.some(e => [...e.attributes].some(x => /[\uE000-\uF8FF]/.test(x.value)))) return null;
|
|
2025
|
+
const texts = []; const w = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT);
|
|
2026
|
+
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); }
|
|
2027
|
+
const found = texts.flatMap(n => n.nodeValue.match(/[\uE000-\uF8FF]/g)).map(c => c.charCodeAt(0) - ATOM);
|
|
2028
|
+
if (found.length !== atoms.length || new Set(found).size !== atoms.length) return null;
|
|
2029
|
+
// The elements move in only when the conversion is taken
|
|
2030
|
+
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))));
|
|
2031
|
+
return { run, frag: t.content, fill };
|
|
1681
2032
|
}
|
|
1682
2033
|
|
|
1683
2034
|
function convertInlineAtCaret(area) {
|
|
@@ -1686,17 +2037,24 @@
|
|
|
1686
2037
|
if (!node || node.nodeType !== 3 || !node.parentElement) return false;
|
|
1687
2038
|
const host = node.parentElement.closest('code, pre, a');
|
|
1688
2039
|
if (host && area.contains(host)) return false;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
const
|
|
2040
|
+
if (!/[*_~`)]/.test(node.nodeValue[r.startOffset - 1] || '')) return false;
|
|
2041
|
+
const next = node.nodeValue[r.startOffset] != null ? node.nodeValue[r.startOffset] : textEdge(node.nextSibling, false);
|
|
2042
|
+
const hit = reparse(node.parentElement, node, r.startOffset, next == null ? '' : next);
|
|
2043
|
+
// Only a construct closed by the character just typed, and whole: an opener with marker
|
|
2044
|
+
// left over (`**test*`, so far) waits for the rest of the closer
|
|
2045
|
+
const last = hit && hit.frag.lastChild, typed = node.nodeValue[r.startOffset - 1];
|
|
2046
|
+
if (!last || last.nodeType !== 1 || (!last.textContent && last.tagName !== 'IMG')) return false;
|
|
2047
|
+
const before = last.previousSibling;
|
|
2048
|
+
if (/[*_~]/.test(typed) && before && before.nodeType === 3 && before.nodeValue.endsWith(typed)) return false;
|
|
1692
2049
|
record(area);
|
|
1693
|
-
const tail = node.splitText(
|
|
1694
|
-
|
|
1695
|
-
|
|
2050
|
+
const tail = node.splitText(r.startOffset);
|
|
2051
|
+
hit.run.forEach(n => n.remove()); node.remove();
|
|
2052
|
+
hit.fill();
|
|
2053
|
+
tail.parentNode.insertBefore(hit.frag, tail);
|
|
1696
2054
|
const s = sel(), nr = document.createRange();
|
|
1697
2055
|
nr.setStart(tail, 0); nr.collapse(true);
|
|
1698
2056
|
s.removeAllRanges(); s.addRange(nr);
|
|
1699
|
-
area._exit =
|
|
2057
|
+
area._exit = last.tagName === 'IMG' ? null : last;
|
|
1700
2058
|
record(area);
|
|
1701
2059
|
return true;
|
|
1702
2060
|
}
|
|
@@ -1775,21 +2133,26 @@
|
|
|
1775
2133
|
return true;
|
|
1776
2134
|
}
|
|
1777
2135
|
|
|
2136
|
+
// A finished line's markdown, in each run of inline content (marks' own content first)
|
|
1778
2137
|
function convertInline(root) {
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
const tail = cur.splitText(hit.start);
|
|
1789
|
-
tail.deleteData(0, hit.len);
|
|
1790
|
-
tail.parentNode.insertBefore(el, tail);
|
|
1791
|
-
cur = tail;
|
|
2138
|
+
[...root.children].forEach(c => { if (!/^(CODE|PRE|A|BR|IMG)$/.test(c.tagName)) convertInline(c); });
|
|
2139
|
+
if (root.closest('code, pre, a')) return;
|
|
2140
|
+
const segments = [];
|
|
2141
|
+
let start = root.firstChild;
|
|
2142
|
+
for (let n = root.firstChild; ; n = n ? n.nextSibling : null) {
|
|
2143
|
+
if (!n || (n.nodeType === 1 && (n.tagName === 'BR' || BLOCK.includes(n.tagName)))) {
|
|
2144
|
+
if (start !== n) segments.push([start, n]);
|
|
2145
|
+
if (!n) break;
|
|
2146
|
+
start = n.nextSibling;
|
|
1792
2147
|
}
|
|
2148
|
+
}
|
|
2149
|
+
segments.forEach(([first, stop]) => {
|
|
2150
|
+
const box = document.createElement('span'), anchor = document.createComment('');
|
|
2151
|
+
first.before(anchor);
|
|
2152
|
+
for (let n = first; n && n !== stop;) { const nx = n.nextSibling; box.appendChild(n); n = nx; }
|
|
2153
|
+
const hit = reparse(box, null, 0, '');
|
|
2154
|
+
if (hit) { hit.fill(); box.replaceChildren(hit.frag); }
|
|
2155
|
+
anchor.replaceWith(...box.childNodes);
|
|
1793
2156
|
});
|
|
1794
2157
|
}
|
|
1795
2158
|
|
|
@@ -1828,7 +2191,10 @@
|
|
|
1828
2191
|
}
|
|
1829
2192
|
|
|
1830
2193
|
// Native Enter inside code and for Shift+Enter; Chrome writes <br> pairs in a <pre>
|
|
2194
|
+
// A code block's lines: in its <code> (the shape markdown gives), else in the <pre> itself
|
|
2195
|
+
const codeBox = (pre) => { const c = pre.firstElementChild; return c && c.tagName === 'CODE' && pre.children.length === 1 && !pre.textContent.replace(c.textContent, '').trim() ? c : pre; };
|
|
1831
2196
|
const codeEndsBlank = (pre) => {
|
|
2197
|
+
pre = codeBox(pre);
|
|
1832
2198
|
const last = pre.lastChild;
|
|
1833
2199
|
if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
|
|
1834
2200
|
return /\n[^\S\n]*$/.test(pre.textContent);
|
|
@@ -1899,10 +2265,13 @@
|
|
|
1899
2265
|
// An empty last line leaves the code block (bounded to the area)
|
|
1900
2266
|
const pre = holder.closest('pre');
|
|
1901
2267
|
if (pre && pre !== area && area.contains(pre)) {
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2268
|
+
const box = codeBox(pre);
|
|
2269
|
+
while (box.lastChild && (box.lastChild.nodeName === 'BR' || !box.lastChild.textContent.trim())) box.lastChild.remove();
|
|
2270
|
+
if (/\n[^\S\n]*$/.test(box.textContent)) box.textContent = box.textContent.replace(/\n[^\S\n]*$/, '');
|
|
2271
|
+
// onto the empty line after it, if there is one
|
|
2272
|
+
const after = pre.nextElementSibling, blank = after && after.tagName === 'P' && !after.textContent.trim() && !after.querySelector('img');
|
|
2273
|
+
const p = blank ? after : document.createElement('p');
|
|
2274
|
+
if (!blank) pre.after(p);
|
|
1906
2275
|
if (!pre.textContent.length) pre.remove();
|
|
1907
2276
|
caretIn(p, 0);
|
|
1908
2277
|
return;
|
|
@@ -1941,7 +2310,14 @@
|
|
|
1941
2310
|
if (holder.tagName === 'HR') { caretIn(next, 0); return; }
|
|
1942
2311
|
|
|
1943
2312
|
// A finished ``` line opens the code block
|
|
1944
|
-
|
|
2313
|
+
// (its text in a <code>, the shape a code block read from markdown has)
|
|
2314
|
+
if (holder.tagName === 'PRE') {
|
|
2315
|
+
next.remove();
|
|
2316
|
+
let code = holder.querySelector(':scope > code');
|
|
2317
|
+
if (!code) { code = document.createElement('code'); code.append(...holder.childNodes); holder.appendChild(code); }
|
|
2318
|
+
caretIn(code, 0);
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
1945
2321
|
|
|
1946
2322
|
if (holder.tagName === 'LI') {
|
|
1947
2323
|
const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
|
|
@@ -1972,16 +2348,16 @@
|
|
|
1972
2348
|
const h = area._history; if (!h || h.restoring) return;
|
|
1973
2349
|
clearTimeout(h.timer); h.timer = null;
|
|
1974
2350
|
if (area._rt && area._rt.settle) area._rt.settle();
|
|
1975
|
-
const html = area.innerHTML;
|
|
2351
|
+
const html = area.innerHTML, all = allKey(area);
|
|
1976
2352
|
// Unchanged: the entry learns where the next edit starts (`before`, for undo);
|
|
1977
2353
|
// `caret` stays where the edit that produced it ended (for redo)
|
|
1978
|
-
if (h.stack[h.at] && h.stack[h.at].html === html) {
|
|
2354
|
+
if (h.stack[h.at] && h.stack[h.at].html === html && h.stack[h.at].all === all) {
|
|
1979
2355
|
const caret = offsetIn(area);
|
|
1980
2356
|
if (caret != null) h.stack[h.at].before = caret;
|
|
1981
2357
|
return;
|
|
1982
2358
|
}
|
|
1983
2359
|
h.stack.length = h.at + 1;
|
|
1984
|
-
h.stack.push({ html, caret: offsetIn(area) });
|
|
2360
|
+
h.stack.push({ html, caret: offsetIn(area), all });
|
|
1985
2361
|
if (h.stack.length > HISTORY_CAP) h.stack.shift();
|
|
1986
2362
|
h.at = h.stack.length - 1;
|
|
1987
2363
|
}
|
|
@@ -2003,12 +2379,14 @@
|
|
|
2003
2379
|
function step(area, by) {
|
|
2004
2380
|
const h = area._history; if (!h) return;
|
|
2005
2381
|
// Land a pending burst; walking history alone leaves each entry's carets as recorded
|
|
2006
|
-
if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML) record(area);
|
|
2382
|
+
if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML || h.stack[h.at].all !== allKey(area)) record(area);
|
|
2007
2383
|
const next = h.at + by;
|
|
2008
2384
|
if (next < 0 || next >= h.stack.length) return;
|
|
2009
2385
|
h.at = next; h.restoring = true;
|
|
2010
2386
|
const entry = h.stack[next];
|
|
2011
2387
|
area.innerHTML = entry.html;
|
|
2388
|
+
// .all styles are steps too
|
|
2389
|
+
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) })); }
|
|
2012
2390
|
if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
|
|
2013
2391
|
area.focus();
|
|
2014
2392
|
caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
|
|
@@ -2142,8 +2520,10 @@
|
|
|
2142
2520
|
}
|
|
2143
2521
|
if (!allows(area, id, ctrl)) return;
|
|
2144
2522
|
if (all) {
|
|
2523
|
+
record(area);
|
|
2145
2524
|
area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
|
|
2146
2525
|
if (!arg) area.style.removeProperty(ALL_VARS[id]);
|
|
2526
|
+
record(area);
|
|
2147
2527
|
area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
|
|
2148
2528
|
sync();
|
|
2149
2529
|
return;
|
|
@@ -2151,7 +2531,12 @@
|
|
|
2151
2531
|
const arming = INLINE_TAGS[id] && !hasSelection();
|
|
2152
2532
|
if (arming) { spec.run(arg, area); sync(); return; }
|
|
2153
2533
|
if (!spec.history) record(area);
|
|
2534
|
+
const before = textSpan(area);
|
|
2154
2535
|
spec.run(arg, area);
|
|
2536
|
+
// A command that rebuilt the blocks under the selection (a block style) leaves it where it was
|
|
2537
|
+
const after = range();
|
|
2538
|
+
if (before && before[1] > before[0] && (!after || !area.contains(after.startContainer) || after.collapsed)) selectSpan(area, before);
|
|
2539
|
+
mergeAdjacent(area);
|
|
2155
2540
|
normalizeLists(area);
|
|
2156
2541
|
if (!spec.history) record(area);
|
|
2157
2542
|
saveRange(area);
|
|
@@ -2159,8 +2544,45 @@
|
|
|
2159
2544
|
sync();
|
|
2160
2545
|
}
|
|
2161
2546
|
|
|
2547
|
+
// $rich's reads (value, selection, can, active, …) depend on one reactive beat, bumped
|
|
2548
|
+
// (once a microtask) whenever the selection, value or formatting may have changed
|
|
2549
|
+
let pulse = null, beating = false;
|
|
2550
|
+
const watch = () => { if (pulse) void pulse.n; };
|
|
2551
|
+
// Bumped only when what $rich reports changed, so an effect that reads and writes it settles
|
|
2552
|
+
let lastSurface = null;
|
|
2553
|
+
const selKey = (x) => x ? [x.collapsed, x.text, x.x, x.y, x.width, x.height].join() : '';
|
|
2554
|
+
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');
|
|
2555
|
+
const beat = () => {
|
|
2556
|
+
if (!pulse || beating) return;
|
|
2557
|
+
beating = true;
|
|
2558
|
+
queueMicrotask(() => { beating = false; const now = surface(); if (now === lastSurface) return; lastSurface = now; pulse.n++; });
|
|
2559
|
+
};
|
|
2560
|
+
|
|
2561
|
+
// Any CSS colour (oklch(), a name, rgb()) as #rrggbb, read off a canvas pixel; null when it cannot be
|
|
2562
|
+
let colorCanvas = null;
|
|
2563
|
+
const hexCache = new Map();
|
|
2564
|
+
function hexColor(v) {
|
|
2565
|
+
if (v == null || v === '') return null;
|
|
2566
|
+
const s = String(v).trim();
|
|
2567
|
+
if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
|
|
2568
|
+
if (hexCache.has(s)) return hexCache.get(s);
|
|
2569
|
+
let out = null;
|
|
2570
|
+
try {
|
|
2571
|
+
colorCanvas = colorCanvas || document.createElement('canvas');
|
|
2572
|
+
const g = colorCanvas.getContext && colorCanvas.getContext('2d', { willReadFrequently: true });
|
|
2573
|
+
if (g) {
|
|
2574
|
+
g.clearRect(0, 0, 1, 1); g.fillStyle = '#000'; g.fillStyle = s; g.fillRect(0, 0, 1, 1);
|
|
2575
|
+
const [r, gr, b] = g.getImageData(0, 0, 1, 1).data;
|
|
2576
|
+
out = '#' + [r, gr, b].map(x => x.toString(16).padStart(2, '0')).join('');
|
|
2577
|
+
}
|
|
2578
|
+
} catch { out = null; }
|
|
2579
|
+
hexCache.set(s, out);
|
|
2580
|
+
return out;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2162
2583
|
// Reflect caret state onto every control
|
|
2163
2584
|
function sync() {
|
|
2585
|
+
beat();
|
|
2164
2586
|
controls.forEach(c => {
|
|
2165
2587
|
const cfg = c._rt, area = resolve(c);
|
|
2166
2588
|
const usable = !!area && allows(area, cfg.id, cfg);
|
|
@@ -2171,9 +2593,10 @@
|
|
|
2171
2593
|
c.setAttribute('aria-disabled', String(!usable));
|
|
2172
2594
|
if (usable && (all || spec.reflect) && 'value' in c && c !== document.activeElement) {
|
|
2173
2595
|
const v = all ? area.style.getPropertyValue(ALL_VARS[cfg.id]).trim() : judged(area, () => spec.reflect());
|
|
2174
|
-
// Never blank a <select> with a value it doesn't offer
|
|
2596
|
+
// Never blank a <select> with a value it doesn't offer; a colour input takes #rrggbb only
|
|
2175
2597
|
const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
|
|
2176
|
-
|
|
2598
|
+
const val = c.type === 'color' ? hexColor(v) : v;
|
|
2599
|
+
if (val != null && offered && c.value !== val) c.value = val;
|
|
2177
2600
|
}
|
|
2178
2601
|
if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-rich-text-active')));
|
|
2179
2602
|
});
|
|
@@ -2235,6 +2658,7 @@
|
|
|
2235
2658
|
if (!window.Alpine || !Alpine.directive) return;
|
|
2236
2659
|
// Alpine walks only x-data trees: a page with none still gets its fields
|
|
2237
2660
|
if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
|
|
2661
|
+
if (Alpine.reactive && !pulse) pulse = Alpine.reactive({ n: 0 });
|
|
2238
2662
|
listen();
|
|
2239
2663
|
|
|
2240
2664
|
Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
|
|
@@ -2252,20 +2676,37 @@
|
|
|
2252
2676
|
Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
|
|
2253
2677
|
|
|
2254
2678
|
// $rich: the area this element sits in, else the one controls would resolve to
|
|
2679
|
+
// Unresolved, it depends on the beat, so it resolves once a field is there
|
|
2255
2680
|
const richMagic = (el) => {
|
|
2256
|
-
const
|
|
2257
|
-
|
|
2681
|
+
const a = fieldFor(el);
|
|
2682
|
+
if (!a) watch();
|
|
2258
2683
|
return a ? a._rt.api : idleApi();
|
|
2259
2684
|
};
|
|
2260
2685
|
Alpine.magic('rich', richMagic);
|
|
2261
2686
|
// The loader's stand-in answered $rich until now: it hands over, and effects that read it
|
|
2262
2687
|
// run again once adopted areas exist
|
|
2263
2688
|
window.__manifestRichMagic = richMagic;
|
|
2264
|
-
if (window.__manifestRichStub) setTimeout(() => { window.__manifestRichStub.v++; }, 0);
|
|
2689
|
+
if (window.__manifestRichStub) setTimeout(() => { flushStubWrites(); window.__manifestRichStub.v++; }, 0);
|
|
2265
2690
|
|
|
2266
2691
|
adopt();
|
|
2267
2692
|
}
|
|
2268
2693
|
|
|
2694
|
+
// $rich's field for an element: the one it sits in, else the one controls would resolve to
|
|
2695
|
+
const fieldFor = (el) => { const own = el.closest('[data-rich-text]'); return own && areas.has(own) ? own : resolve(el); };
|
|
2696
|
+
// Writes made on the loader's stand-in ($rich.value = … before Rich Text arrived) land on
|
|
2697
|
+
// their element's field once it exists
|
|
2698
|
+
function flushStubWrites() {
|
|
2699
|
+
const w = window.__manifestRichStubWrites; if (!w || !w.length) return;
|
|
2700
|
+
for (let i = 0; i < w.length;) {
|
|
2701
|
+
const [ref, k, v] = w[i], el = ref && typeof ref.deref === 'function' ? ref.deref() : ref;
|
|
2702
|
+
if (!el || !el.isConnected) { w.splice(i, 1); continue; }
|
|
2703
|
+
const a = fieldFor(el);
|
|
2704
|
+
if (!a) { i++; continue; }
|
|
2705
|
+
w.splice(i, 1);
|
|
2706
|
+
try { a._rt.api[k] = v; } catch { }
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2269
2710
|
// Loaded after Alpine started: elements it already walked get their x-rich-text*
|
|
2270
2711
|
// attributes re-set, which Alpine handles as newly added directives
|
|
2271
2712
|
function adopt() {
|
|
@@ -2318,12 +2759,40 @@
|
|
|
2318
2759
|
t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
|
|
2319
2760
|
}
|
|
2320
2761
|
|
|
2762
|
+
// While a field holds a selection, scrolling and resizing re-report its geometry (once a frame)
|
|
2763
|
+
let tracked = null, trackFrame = 0;
|
|
2764
|
+
const untrack = () => {
|
|
2765
|
+
if (!tracked) return;
|
|
2766
|
+
tracked = null;
|
|
2767
|
+
window.removeEventListener('scroll', follow, { capture: true });
|
|
2768
|
+
window.removeEventListener('resize', follow);
|
|
2769
|
+
};
|
|
2770
|
+
function follow() {
|
|
2771
|
+
if (trackFrame) return;
|
|
2772
|
+
trackFrame = requestAnimationFrame(() => {
|
|
2773
|
+
trackFrame = 0;
|
|
2774
|
+
const area = tracked, shape = area && area.isConnected ? selectionOf(area) : null;
|
|
2775
|
+
if (!shape || shape.collapsed) return untrack();
|
|
2776
|
+
report(area);
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
function track(area, on) {
|
|
2780
|
+
if (!on) { if (tracked === area) untrack(); return; }
|
|
2781
|
+
if (!tracked) {
|
|
2782
|
+
window.addEventListener('scroll', follow, { capture: true, passive: true });
|
|
2783
|
+
window.addEventListener('resize', follow, { passive: true });
|
|
2784
|
+
}
|
|
2785
|
+
tracked = area;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2321
2788
|
function report(area) {
|
|
2322
2789
|
const live = sel();
|
|
2323
2790
|
if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
|
|
2324
2791
|
if (area.querySelector('table')) paintCells(area);
|
|
2325
2792
|
const shape = selectionOf(area);
|
|
2326
2793
|
area._selection = shape;
|
|
2794
|
+
beat();
|
|
2795
|
+
track(area, !!shape && !shape.collapsed);
|
|
2327
2796
|
place(area, shape);
|
|
2328
2797
|
area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
2329
2798
|
document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
|
|
@@ -2335,29 +2804,10 @@
|
|
|
2335
2804
|
el._rtMenu = true;
|
|
2336
2805
|
if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
|
|
2337
2806
|
el.setAttribute('data-rich-text-menu', '');
|
|
2338
|
-
let shown = null, frame = 0;
|
|
2339
|
-
const follow = () => {
|
|
2340
|
-
if (frame) return;
|
|
2341
|
-
frame = requestAnimationFrame(() => { frame = 0; if (shown && shown.isConnected) place(shown, selectionOf(shown)); });
|
|
2342
|
-
};
|
|
2343
|
-
const track = (area) => {
|
|
2344
|
-
if (!shown) {
|
|
2345
|
-
window.addEventListener('scroll', follow, { capture: true, passive: true });
|
|
2346
|
-
window.addEventListener('resize', follow, { passive: true });
|
|
2347
|
-
}
|
|
2348
|
-
shown = area;
|
|
2349
|
-
};
|
|
2350
|
-
const untrack = () => {
|
|
2351
|
-
if (!shown) return;
|
|
2352
|
-
shown = null;
|
|
2353
|
-
window.removeEventListener('scroll', follow, { capture: true });
|
|
2354
|
-
window.removeEventListener('resize', follow);
|
|
2355
|
-
};
|
|
2356
2807
|
const onSel = (e) => {
|
|
2357
2808
|
const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
|
|
2358
2809
|
if (!area || resolve(el) !== area) return;
|
|
2359
2810
|
const want = !!e.detail && !e.detail.collapsed;
|
|
2360
|
-
if (want) track(area); else untrack();
|
|
2361
2811
|
try {
|
|
2362
2812
|
if (want && !el.matches(':popover-open')) el.showPopover();
|
|
2363
2813
|
if (!want && el.matches(':popover-open')) el.hidePopover();
|
|
@@ -2366,7 +2816,6 @@
|
|
|
2366
2816
|
document.addEventListener('rich-text:selection', onSel);
|
|
2367
2817
|
cleanup(() => {
|
|
2368
2818
|
document.removeEventListener('rich-text:selection', onSel);
|
|
2369
|
-
untrack(); if (frame) cancelAnimationFrame(frame);
|
|
2370
2819
|
try { el.hidePopover(); } catch { }
|
|
2371
2820
|
});
|
|
2372
2821
|
}
|
|
@@ -2523,8 +2972,8 @@
|
|
|
2523
2972
|
}
|
|
2524
2973
|
}
|
|
2525
2974
|
|
|
2526
|
-
el._history = { stack: [{ html: el.innerHTML, caret: 0 }], at: 0, timer: null, restoring: false };
|
|
2527
|
-
const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0; };
|
|
2975
|
+
el._history = { stack: [{ html: el.innerHTML, caret: 0, all: allKey(el) }], at: 0, timer: null, restoring: false };
|
|
2976
|
+
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; };
|
|
2528
2977
|
|
|
2529
2978
|
// Empty flag for the placeholder; a wiped document restarts as a paragraph
|
|
2530
2979
|
function settle() {
|
|
@@ -2544,7 +2993,9 @@
|
|
|
2544
2993
|
if (el.querySelector('table')) normalizeTables(el);
|
|
2545
2994
|
if (format !== 'plain') looseInline(el);
|
|
2546
2995
|
ensureTrailingBlock(el);
|
|
2547
|
-
|
|
2996
|
+
// A code block just opened (``` + Enter) holds the caret: it stays
|
|
2997
|
+
const r = range(), first = el.firstElementChild, opened = !!first && first.tagName === 'PRE' && !!r && first.contains(r.startContainer);
|
|
2998
|
+
if (!opened && !el.textContent.trim() && !el.querySelector('img, table, hr')) {
|
|
2548
2999
|
if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
|
|
2549
3000
|
if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
|
|
2550
3001
|
el.toggleAttribute('data-rich-text-empty', true);
|
|
@@ -2555,6 +3006,8 @@
|
|
|
2555
3006
|
writing = true; write(v); writing = false;
|
|
2556
3007
|
resetHistory();
|
|
2557
3008
|
settle();
|
|
3009
|
+
el._selection = selectionOf(el);
|
|
3010
|
+
sync();
|
|
2558
3011
|
};
|
|
2559
3012
|
|
|
2560
3013
|
const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
|
|
@@ -2608,7 +3061,7 @@
|
|
|
2608
3061
|
on('input', (e) => {
|
|
2609
3062
|
// Mid-composition the IME owns the text
|
|
2610
3063
|
if (!writing && e.data && !e.isComposing) {
|
|
2611
|
-
if (applyPending(el, e.data)) commit();
|
|
3064
|
+
if (applyPending(el, e.data)) { mergeAdjacent(el); commit(); }
|
|
2612
3065
|
if (leaveMark(el, e.data)) commit();
|
|
2613
3066
|
if (autoformat && convertInlineAtCaret(el)) commit();
|
|
2614
3067
|
}
|
|
@@ -2622,6 +3075,8 @@
|
|
|
2622
3075
|
on('compositionend', (e) => {
|
|
2623
3076
|
if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
|
|
2624
3077
|
else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
|
|
3078
|
+
// A closing marker committed through an IME converts as a typed one does
|
|
3079
|
+
else if (!writing && autoformat && e.data && /[*_~`)]$/.test(e.data) && convertInlineAtCaret(el)) { commit(); sync(); }
|
|
2625
3080
|
if (!writing) relaxSpaces(el);
|
|
2626
3081
|
});
|
|
2627
3082
|
on('change', e => {
|
|
@@ -2762,7 +3217,7 @@
|
|
|
2762
3217
|
});
|
|
2763
3218
|
|
|
2764
3219
|
const api = {
|
|
2765
|
-
get value() { return read(); },
|
|
3220
|
+
get value() { watch(); return read(); },
|
|
2766
3221
|
// The value it already holds keeps the markup and caret; history restarts either way
|
|
2767
3222
|
set value(v) {
|
|
2768
3223
|
if (v !== read()) write(v);
|
|
@@ -2772,15 +3227,25 @@
|
|
|
2772
3227
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2773
3228
|
el._history.restoring = false;
|
|
2774
3229
|
},
|
|
2775
|
-
get all() { return allStyle(el); },
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
3230
|
+
get all() { watch(); return allStyle(el); },
|
|
3231
|
+
// Page styling, not an edit: no history step (the history takes it as its baseline)
|
|
3232
|
+
set all(v) {
|
|
3233
|
+
if (!allStyles) return;
|
|
3234
|
+
const was = allStyle(el);
|
|
3235
|
+
setAllStyle(el, v);
|
|
3236
|
+
const now = allStyle(el);
|
|
3237
|
+
if (JSON.stringify(now) === JSON.stringify(was)) return;
|
|
3238
|
+
rebaseAll(el, was, now);
|
|
3239
|
+
el.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: now }));
|
|
3240
|
+
sync();
|
|
3241
|
+
},
|
|
3242
|
+
get selection() { watch(); return el._selection || null; },
|
|
3243
|
+
get link() { watch(); return linkHref(); },
|
|
3244
|
+
set link(v) { if ((v == null ? '' : String(v)) === (linkHref() || '')) return; run(el, 'a', v); },
|
|
2780
3245
|
focus: () => el.focus(),
|
|
2781
3246
|
run: (id, arg) => run(el, id, arg),
|
|
2782
|
-
active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
|
|
2783
|
-
can: (id) => allows(el, id),
|
|
3247
|
+
active: (id) => { watch(); return allows(el, id) && COMMANDS[id].active(undefined, el); },
|
|
3248
|
+
can: (id) => { watch(); return allows(el, id); },
|
|
2784
3249
|
selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
|
|
2785
3250
|
// The newest step in this field's history (a pending typing burst lands first)
|
|
2786
3251
|
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; },
|
|
@@ -2792,12 +3257,14 @@
|
|
|
2792
3257
|
|
|
2793
3258
|
el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
|
|
2794
3259
|
areas.add(el);
|
|
3260
|
+
if (window.__manifestRichStubWrites && window.__manifestRichStubWrites.length) setTimeout(flushStubWrites, 0);
|
|
2795
3261
|
settle();
|
|
2796
|
-
el._history.stack[0].html = el.innerHTML;
|
|
3262
|
+
el._history.stack[0].html = el.innerHTML; el._history.stack[0].all = allKey(el);
|
|
2797
3263
|
setTimeout(sync, 0);
|
|
2798
3264
|
|
|
2799
3265
|
const destroy = () => {
|
|
2800
3266
|
if (!areas.has(el)) return;
|
|
3267
|
+
if (tracked === el) untrack();
|
|
2801
3268
|
ac.abort();
|
|
2802
3269
|
clearTimeout(el._history && el._history.timer);
|
|
2803
3270
|
if (lastFocused === el) release(el);
|