@gilav21/shadcn-angular-rte 0.1.1 → 0.1.2
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/fesm2022/{gilav21-shadcn-angular-rte-docx-parser-CFx_4GC5.mjs → gilav21-shadcn-angular-rte-docx-parser-B8BeJWtY.mjs} +2 -2
- package/fesm2022/{gilav21-shadcn-angular-rte-docx-parser-CFx_4GC5.mjs.map → gilav21-shadcn-angular-rte-docx-parser-B8BeJWtY.mjs.map} +1 -1
- package/fesm2022/{gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-DWSBW7Gw.mjs → gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-CfjU_y-y.mjs} +864 -60
- package/fesm2022/gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-CfjU_y-y.mjs.map +1 -0
- package/fesm2022/gilav21-shadcn-angular-rte.mjs +1 -1
- package/package.json +1 -1
- package/types/gilav21-shadcn-angular-rte.d.ts +138 -3
- package/types/gilav21-shadcn-angular-rte.d.ts.map +1 -1
- package/fesm2022/gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-DWSBW7Gw.mjs.map +0 -1
|
@@ -72,6 +72,494 @@ function prefersReducedMotion() {
|
|
|
72
72
|
return globalThis.window?.matchMedia('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The syntax tokenizer, shared by every surface that shows code.
|
|
77
|
+
*
|
|
78
|
+
* It began inside `ui-code-block` as private methods over a private pattern
|
|
79
|
+
* table. The rich text editor and its read-only view need exactly the same
|
|
80
|
+
* tokens — `rich-text-markdown.service.ts` has always stored and round-tripped
|
|
81
|
+
* a code block's language, and the sanitizer has always allowed the
|
|
82
|
+
* `language-*` and `token-*` classes — so the tokenizer moved here rather than
|
|
83
|
+
* being written a second time. Rendering the whole `ui-code-block` COMPONENT
|
|
84
|
+
* inside a document was the alternative and is wrong: it brings a copy button,
|
|
85
|
+
* a line-number gutter, fold chevrons and a hard-coded dark surface into prose,
|
|
86
|
+
* and none of that can live inside a contenteditable.
|
|
87
|
+
*
|
|
88
|
+
* Two rules the callers rely on:
|
|
89
|
+
*
|
|
90
|
+
* 1. **A line is tokenized on its own.** Highlighting never spans lines, so a
|
|
91
|
+
* multi-line string or block comment is coloured per line. That is what lets
|
|
92
|
+
* the editor re-highlight one line without re-reading the block.
|
|
93
|
+
* 2. **An unknown language is not a guess.** {@link languagePatternsFor}
|
|
94
|
+
* returns `null` for a language it does not know, and for none at all.
|
|
95
|
+
* `ui-code-block` is a code viewer and keeps its documented TypeScript
|
|
96
|
+
* fallback, but a DOCUMENT is mostly prose: a fence with no language, or one
|
|
97
|
+
* tagged `text`, `log` or `diff`, would otherwise be coloured by TypeScript
|
|
98
|
+
* rules, inventing keywords in the reader's notes.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* Build a word-boundary keyword matcher from a list. Constructed at runtime so
|
|
102
|
+
* the (legitimately long) per-language keyword set is a maintainable array
|
|
103
|
+
* rather than one giant, hard-to-read regex literal.
|
|
104
|
+
*/
|
|
105
|
+
const keywordPattern = (words) => new RegExp(String.raw `\b(${words.join('|')})\b`);
|
|
106
|
+
const TS_KEYWORDS = ['const', 'let', 'var', 'function', 'class', 'import', 'from', 'return', 'if', 'else', 'for', 'while', 'export', 'interface', 'type', 'public', 'private', 'protected', 'implements', 'extends', 'new', 'this', 'true', 'false', 'null', 'undefined', 'void', 'async', 'await'];
|
|
107
|
+
const JS_KEYWORDS = ['const', 'let', 'var', 'function', 'class', 'import', 'from', 'return', 'if', 'else', 'for', 'while', 'export', 'new', 'this', 'true', 'false', 'null', 'undefined', 'void', 'async', 'await'];
|
|
108
|
+
const PYTHON_KEYWORDS = ['def', 'class', 'import', 'from', 'if', 'else', 'elif', 'for', 'while', 'return', 'try', 'except', 'finally', 'with', 'as', 'pass', 'break', 'continue', 'lambda', 'yield', 'async', 'await', 'True', 'False', 'None'];
|
|
109
|
+
const JAVA_KEYWORDS = ['public', 'private', 'protected', 'class', 'interface', 'enum', 'extends', 'implements', 'new', 'this', 'super', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'throws', 'import', 'package', 'void', 'int', 'boolean', 'char', 'byte', 'short', 'long', 'float', 'double', 'static', 'final', 'abstract', 'synchronized', 'volatile', 'transient', 'native', 'strictfp', 'instanceof', 'null', 'true', 'false'];
|
|
110
|
+
const CSHARP_KEYWORDS = ['public', 'private', 'protected', 'internal', 'class', 'struct', 'record', 'interface', 'enum', 'delegate', 'event', 'void', 'int', 'string', 'bool', 'var', 'async', 'await', 'Task', 'return', 'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'try', 'catch', 'finally', 'throw', 'new', 'this', 'base', 'using', 'namespace', 'static', 'readonly', 'const', 'override', 'virtual', 'abstract', 'sealed', 'get', 'set', 'value'];
|
|
111
|
+
/** The built-in language rules, keyed by lowercase language name. */
|
|
112
|
+
const LANGUAGE_PATTERNS = {
|
|
113
|
+
typescript: [
|
|
114
|
+
{ type: 'comment', regex: /\/\/.*/ },
|
|
115
|
+
{ type: 'string', regex: /(["'])(?:(?=(\\?))\2.)*?\1/ },
|
|
116
|
+
{ type: 'keyword', regex: keywordPattern(TS_KEYWORDS) },
|
|
117
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
118
|
+
{ type: 'function', regex: /\b[a-zA-Z_$]\w*(?=\()/ },
|
|
119
|
+
],
|
|
120
|
+
javascript: [
|
|
121
|
+
{ type: 'comment', regex: /\/\/.*/ },
|
|
122
|
+
{ type: 'string', regex: /(["'])(?:(?=(\\?))\2.)*?\1/ },
|
|
123
|
+
{ type: 'keyword', regex: keywordPattern(JS_KEYWORDS) },
|
|
124
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
125
|
+
{ type: 'function', regex: /\b[a-zA-Z_$]\w*(?=\()/ },
|
|
126
|
+
],
|
|
127
|
+
python: [
|
|
128
|
+
{ type: 'comment', regex: /#.*/ },
|
|
129
|
+
{ type: 'string', regex: /(["'])(?:(?=(\\?))\2.)*?\1/ },
|
|
130
|
+
{ type: 'decorator', regex: /@[\w.]+/ },
|
|
131
|
+
{ type: 'keyword', regex: keywordPattern(PYTHON_KEYWORDS) },
|
|
132
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
133
|
+
{ type: 'function', regex: /\b[a-zA-Z_]\w*(?=\()/ },
|
|
134
|
+
],
|
|
135
|
+
java: [
|
|
136
|
+
{ type: 'comment', regex: /\/\/.*/ },
|
|
137
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"/ },
|
|
138
|
+
{ type: 'keyword', regex: keywordPattern(JAVA_KEYWORDS) },
|
|
139
|
+
{ type: 'decorator', regex: /@\w+/ },
|
|
140
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
141
|
+
{ type: 'function', regex: /\b[a-zA-Z_$]\w*(?=\()/ },
|
|
142
|
+
],
|
|
143
|
+
html: [
|
|
144
|
+
{ type: 'comment', regex: /<!--[\s\S]*?-->/ },
|
|
145
|
+
{ type: 'tag', regex: /<\/?[a-z0-9-]+/i },
|
|
146
|
+
{ type: 'attr', regex: /[a-z0-9-]{1,256}(?==)/i },
|
|
147
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"/ },
|
|
148
|
+
],
|
|
149
|
+
xml: [
|
|
150
|
+
{ type: 'comment', regex: /<!--[\s\S]*?-->/ },
|
|
151
|
+
{ type: 'decorator', regex: /<\?xml[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<!DOCTYPE[^>]*>/i },
|
|
152
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/ },
|
|
153
|
+
{ type: 'tag', regex: /<\/?[a-zA-Z_][\w.\-:]{0,256}/ },
|
|
154
|
+
{ type: 'attr', regex: /[a-zA-Z_][\w.\-:]{0,256}(?==)/ },
|
|
155
|
+
{ type: 'keyword', regex: /&(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/ },
|
|
156
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
157
|
+
],
|
|
158
|
+
css: [
|
|
159
|
+
{ type: 'comment', regex: /\/\*[\s\S]*?\*\// },
|
|
160
|
+
{ type: 'selector', regex: /[.#]?[a-zA-Z0-9_-]{1,256}(?=\{)/ },
|
|
161
|
+
{ type: 'property', regex: /[a-z0-9-]{1,256}(?=:)/i },
|
|
162
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/ },
|
|
163
|
+
{ type: 'number', regex: /\b\d+(?:px|rem|em|%|vh|vw|s|ms|deg)?\b/ },
|
|
164
|
+
],
|
|
165
|
+
json: [
|
|
166
|
+
{ type: 'function', regex: /"[^"]+":/ },
|
|
167
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"/ },
|
|
168
|
+
{ type: 'keyword', regex: /\b(true|false|null)\b/ },
|
|
169
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
170
|
+
],
|
|
171
|
+
csharp: [
|
|
172
|
+
{ type: 'comment', regex: /\/\/.*/ },
|
|
173
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"/ },
|
|
174
|
+
{ type: 'decorator', regex: /\[[a-zA-Z]\w*\]/ },
|
|
175
|
+
{ type: 'keyword', regex: keywordPattern(CSHARP_KEYWORDS) },
|
|
176
|
+
{ type: 'number', regex: /\b\d+\b/ },
|
|
177
|
+
{ type: 'function', regex: /\b[a-zA-Z_$]\w*(?=\()/ },
|
|
178
|
+
],
|
|
179
|
+
yaml: [
|
|
180
|
+
{ type: 'comment', regex: /#.*/ },
|
|
181
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/ },
|
|
182
|
+
{ type: 'attr', regex: /[a-zA-Z0-9_-]{1,256}(?=:)/ },
|
|
183
|
+
{ type: 'keyword', regex: /\b(true|false|null|yes|no|on|off)\b/ },
|
|
184
|
+
{ type: 'number', regex: /\b\d+(\.\d+)?\b/ },
|
|
185
|
+
],
|
|
186
|
+
bash: [
|
|
187
|
+
{ type: 'comment', regex: /#.*/ },
|
|
188
|
+
{ type: 'string', regex: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/ },
|
|
189
|
+
{ type: 'keyword', regex: /\b(echo|ls|cd|pwd|mkdir|rm|cp|mv|touch|cat|grep|open|ssh|git|npm|node|ng|sudo|chmod|chown)\b/ },
|
|
190
|
+
{ type: 'decorator', regex: /\$\w+/ },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Spellings an author writes in a fence that mean a language the table already
|
|
195
|
+
* knows. A markdown fence carries whatever the author typed, and "ts" is far
|
|
196
|
+
* more common than "typescript".
|
|
197
|
+
*/
|
|
198
|
+
const LANGUAGE_ALIASES = {
|
|
199
|
+
ts: 'typescript',
|
|
200
|
+
tsx: 'typescript',
|
|
201
|
+
js: 'javascript',
|
|
202
|
+
jsx: 'javascript',
|
|
203
|
+
mjs: 'javascript',
|
|
204
|
+
cjs: 'javascript',
|
|
205
|
+
py: 'python',
|
|
206
|
+
'c#': 'csharp',
|
|
207
|
+
cs: 'csharp',
|
|
208
|
+
yml: 'yaml',
|
|
209
|
+
sh: 'bash',
|
|
210
|
+
shell: 'bash',
|
|
211
|
+
zsh: 'bash',
|
|
212
|
+
htm: 'html',
|
|
213
|
+
svg: 'xml',
|
|
214
|
+
scss: 'css',
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* The rules for a language, or `null` when it is one this module does not know.
|
|
218
|
+
*
|
|
219
|
+
* `null` is a real answer, not a failure: see rule 2 in the module comment. The
|
|
220
|
+
* caller decides what to do with it — `ui-code-block` substitutes TypeScript,
|
|
221
|
+
* a document leaves the code alone.
|
|
222
|
+
*/
|
|
223
|
+
function languagePatternsFor(language) {
|
|
224
|
+
if (!language)
|
|
225
|
+
return null;
|
|
226
|
+
const key = language.toLowerCase();
|
|
227
|
+
return LANGUAGE_PATTERNS[LANGUAGE_ALIASES[key] ?? key] ?? null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Tokenize ONE line. See rule 1 in the module comment: highlighting never spans
|
|
231
|
+
* lines, so the caller splits and calls this per line.
|
|
232
|
+
*/
|
|
233
|
+
function tokenizeLine(line, patterns) {
|
|
234
|
+
if (!line)
|
|
235
|
+
return [];
|
|
236
|
+
const tokens = [];
|
|
237
|
+
let cursor = 0;
|
|
238
|
+
while (cursor < line.length) {
|
|
239
|
+
const remaining = line.slice(cursor);
|
|
240
|
+
const best = findBestMatch(remaining, patterns);
|
|
241
|
+
if (best?.index === 0) {
|
|
242
|
+
tokens.push({ type: best.type, text: best.text });
|
|
243
|
+
cursor += best.text.length;
|
|
244
|
+
}
|
|
245
|
+
else if (best) {
|
|
246
|
+
tokens.push({ type: 'text', text: remaining.slice(0, best.index) });
|
|
247
|
+
cursor += best.index;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
tokens.push({ type: 'text', text: remaining });
|
|
251
|
+
cursor += remaining.length;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return tokens;
|
|
255
|
+
}
|
|
256
|
+
function findBestMatch(remaining, patterns) {
|
|
257
|
+
let best = null;
|
|
258
|
+
for (const pattern of patterns) {
|
|
259
|
+
const match = pattern.regex.exec(remaining);
|
|
260
|
+
if (match && (best === null || match.index < best.index)) {
|
|
261
|
+
best = { type: pattern.type, text: match[0], index: match.index };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return best;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The class a document's highlighter marks a token with.
|
|
268
|
+
*
|
|
269
|
+
* `token` plus `token-<type>`, which `rich-text-sanitizer.service.ts` has
|
|
270
|
+
* allowed since before there was a highlighter: markup coloured elsewhere and
|
|
271
|
+
* pasted in already survived sanitization with these exact classes, so the
|
|
272
|
+
* colours this produces and the colours a paste brings are one set of rules.
|
|
273
|
+
* The colours themselves live in RICH_TEXT_PROSE_CLASSES, where the editor and
|
|
274
|
+
* the view share them and light and dark each get their own.
|
|
275
|
+
*
|
|
276
|
+
* A `text` run gets no class at all: it is the code's own colour, and a span
|
|
277
|
+
* per unstyled run would triple the node count for nothing.
|
|
278
|
+
*/
|
|
279
|
+
function tokenClassFor(type) {
|
|
280
|
+
return type === 'text' ? null : `token token-${type}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* A code block's text with its line breaks, whichever form each one takes.
|
|
284
|
+
*
|
|
285
|
+
* The rule every reader of a code block has to follow: a line break is a line
|
|
286
|
+
* break, whether it is a `\n` character or a `<br>` element. `textContent`
|
|
287
|
+
* does not follow it — a `<br>` contributes nothing — and Shift+Enter, the
|
|
288
|
+
* editor's key for a new line inside a block, inserts `<br>`. So a function
|
|
289
|
+
* typed line by line was saved as one line, and the highlighter, which refused
|
|
290
|
+
* any block holding an element, stopped colouring from the first new line on.
|
|
291
|
+
*/
|
|
292
|
+
function readCodeLines(node) {
|
|
293
|
+
const lines = { text: '', breaks: [], trailingBr: false };
|
|
294
|
+
collectCodeLines(node, lines);
|
|
295
|
+
if (lines.breaks.at(-1) === 'br' && lines.text.endsWith('\n') && endsWithBr(node)) {
|
|
296
|
+
lines.text = lines.text.slice(0, -1);
|
|
297
|
+
lines.breaks.pop();
|
|
298
|
+
lines.trailingBr = true;
|
|
299
|
+
}
|
|
300
|
+
return lines;
|
|
301
|
+
}
|
|
302
|
+
function collectCodeLines(node, into) {
|
|
303
|
+
for (const child of Array.from(node.childNodes)) {
|
|
304
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
305
|
+
const data = child.data;
|
|
306
|
+
into.text += data;
|
|
307
|
+
into.breaks.push(...Array.from(data.matchAll(/\n/g), () => 'newline'));
|
|
308
|
+
}
|
|
309
|
+
else if (child.nodeName === 'BR') {
|
|
310
|
+
into.text += '\n';
|
|
311
|
+
into.breaks.push('br');
|
|
312
|
+
}
|
|
313
|
+
else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
314
|
+
collectCodeLines(child, into);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Whether the last thing in `node`, looking into nested elements, is a `<br>`. */
|
|
319
|
+
function endsWithBr(node) {
|
|
320
|
+
let last = node.lastChild;
|
|
321
|
+
while (last?.nodeType === Node.ELEMENT_NODE && last.nodeName !== 'BR')
|
|
322
|
+
last = last.lastChild;
|
|
323
|
+
return last?.nodeName === 'BR';
|
|
324
|
+
}
|
|
325
|
+
/** A code block's text with every line break as `\n`; see {@link readCodeLines}. */
|
|
326
|
+
function codeTextOf(node) {
|
|
327
|
+
return readCodeLines(node).text;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Elements that only STYLE the code text around them, which the paint reads
|
|
331
|
+
* through and does not keep.
|
|
332
|
+
*
|
|
333
|
+
* A browser writes these into a contenteditable on its own. Editing next to a
|
|
334
|
+
* coloured token, Chrome preserves the token's computed look as literal markup:
|
|
335
|
+
* `<font color="…">` with the keyword colour, `<b>` for its weight,
|
|
336
|
+
* `<span style="font-weight: 400">` to undo it. The bold, italic, underline and
|
|
337
|
+
* strike buttons write the rest. None of it can survive a save — a fence is
|
|
338
|
+
* text — and the colour of code is the highlighter's to decide. Refusing a
|
|
339
|
+
* block that held any of it switched highlighting off for the whole block the
|
|
340
|
+
* first time the browser did this, and it never came back.
|
|
341
|
+
*/
|
|
342
|
+
const STYLE_ONLY_TAGS = new Set(['SPAN', 'FONT', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'STRIKE', 'DEL']);
|
|
343
|
+
/**
|
|
344
|
+
* Whether the paint can rebuild the block without losing anything.
|
|
345
|
+
*
|
|
346
|
+
* The paint is rebuilt from the block's lines, so it may only run where lines
|
|
347
|
+
* are all there is. A code block CAN hold more: the markdown writer keeps an
|
|
348
|
+
* image in one by writing the block in its tag form, and rebuilding that block
|
|
349
|
+
* deleted the image outright; a link is content too. A `<br>` is a line break
|
|
350
|
+
* and is rebuilt. Styling wrappers carry nothing but a look (see
|
|
351
|
+
* {@link STYLE_ONLY_TAGS}), and a span this module painted is exactly what the
|
|
352
|
+
* rebuild replaces.
|
|
353
|
+
*/
|
|
354
|
+
function isTextOnlyCode(code) {
|
|
355
|
+
return Array.from(code.querySelectorAll('*')).every((element) => element.nodeName === 'BR' || STYLE_ONLY_TAGS.has(element.nodeName));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Marks a document's `<pre><code>` element up with token spans, in place.
|
|
359
|
+
*
|
|
360
|
+
* The element's own TEXT is the source of truth and is never changed — the
|
|
361
|
+
* children are rebuilt from `textContent`, so running this twice is the same as
|
|
362
|
+
* running it once and a block that was already highlighted re-highlights
|
|
363
|
+
* cleanly. That idempotence is what lets the editor re-run it on every quiet
|
|
364
|
+
* moment without the markup growing.
|
|
365
|
+
*
|
|
366
|
+
* Returns whether anything was painted, so a caller that has to restore a caret
|
|
367
|
+
* can skip the work when nothing moved.
|
|
368
|
+
*/
|
|
369
|
+
function highlightCodeElement(code, language) {
|
|
370
|
+
const painted = paintedFragment(code, language);
|
|
371
|
+
if (!painted)
|
|
372
|
+
return false;
|
|
373
|
+
code.replaceChildren(painted);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The token markup for a block, built detached, or `null` when the block is not
|
|
378
|
+
* one to paint. Built apart from the element so a caller can compare it with
|
|
379
|
+
* what is already there BEFORE any node is replaced.
|
|
380
|
+
*/
|
|
381
|
+
function paintedFragment(code, language) {
|
|
382
|
+
const patterns = languagePatternsFor(language);
|
|
383
|
+
if (!patterns || !isTextOnlyCode(code))
|
|
384
|
+
return null;
|
|
385
|
+
const { text, breaks, trailingBr } = readCodeLines(code);
|
|
386
|
+
if (text === '')
|
|
387
|
+
return null;
|
|
388
|
+
const doc = code.ownerDocument;
|
|
389
|
+
const painted = doc.createDocumentFragment();
|
|
390
|
+
const lines = text.split('\n');
|
|
391
|
+
lines.forEach((line, index) => {
|
|
392
|
+
// Every break is written back as its own node AND in the form it had.
|
|
393
|
+
// As `\n` inside the last token, a trailing blank line was dropped on
|
|
394
|
+
// every pass; converted from `<br>` to `\n`, the empty line a
|
|
395
|
+
// Shift+Enter had just opened collapsed and took the caret with it.
|
|
396
|
+
if (index > 0)
|
|
397
|
+
painted.appendChild(breaks[index - 1] === 'br' ? doc.createElement('br') : doc.createTextNode('\n'));
|
|
398
|
+
for (const token of tokenizeLine(line, patterns)) {
|
|
399
|
+
const className = tokenClassFor(token.type);
|
|
400
|
+
if (className === null) {
|
|
401
|
+
painted.appendChild(doc.createTextNode(token.text));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const span = doc.createElement('span');
|
|
405
|
+
span.className = className;
|
|
406
|
+
span.textContent = token.text;
|
|
407
|
+
painted.appendChild(span);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
if (trailingBr)
|
|
411
|
+
painted.appendChild(doc.createElement('br'));
|
|
412
|
+
return painted;
|
|
413
|
+
}
|
|
414
|
+
/** A fragment's markup, for comparing with an element's `innerHTML`. */
|
|
415
|
+
function markupOf(fragment) {
|
|
416
|
+
const holder = fragment.ownerDocument.createElement('div');
|
|
417
|
+
holder.appendChild(fragment.cloneNode(true));
|
|
418
|
+
return holder.innerHTML;
|
|
419
|
+
}
|
|
420
|
+
/** The language a rendered `<pre><code>` carries: `data-language`, else the `language-*` class. */
|
|
421
|
+
function languageOfCodeElement(code) {
|
|
422
|
+
const attribute = code.dataset['language'];
|
|
423
|
+
if (attribute)
|
|
424
|
+
return attribute;
|
|
425
|
+
const fromClass = /(?:^|\s)language-([\w+#.-]+)/.exec(code.className);
|
|
426
|
+
return fromClass?.[1] ?? null;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Highlights every code block under `root` that names a language this module
|
|
430
|
+
* knows, and returns how many it painted.
|
|
431
|
+
*/
|
|
432
|
+
function highlightCodeBlocks(root) {
|
|
433
|
+
let painted = 0;
|
|
434
|
+
for (const code of Array.from(root.querySelectorAll('pre > code'))) {
|
|
435
|
+
if (highlightCodeElement(code, languageOfCodeElement(code)))
|
|
436
|
+
painted++;
|
|
437
|
+
}
|
|
438
|
+
return painted;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Removes the spans the highlighter painted, in place.
|
|
442
|
+
*
|
|
443
|
+
* Only from a block whose language {@link languagePatternsFor} knows, which is
|
|
444
|
+
* exactly the set this module paints. Spans in a block it does NOT know came
|
|
445
|
+
* from the author — a paste from an already-highlighted source, which the
|
|
446
|
+
* sanitizer deliberately keeps — and are content, not decoration.
|
|
447
|
+
*
|
|
448
|
+
* The spans are unwrapped rather than the element flattened to its text: a code
|
|
449
|
+
* block may hold an image (the markdown writer keeps one by writing the block
|
|
450
|
+
* in its tag form), and `textContent = textContent` would delete it.
|
|
451
|
+
*/
|
|
452
|
+
function stripCodeHighlighting(root) {
|
|
453
|
+
for (const code of Array.from(root.querySelectorAll('pre > code'))) {
|
|
454
|
+
if (!languagePatternsFor(languageOfCodeElement(code)))
|
|
455
|
+
continue;
|
|
456
|
+
for (const span of Array.from(code.querySelectorAll('span.token'))) {
|
|
457
|
+
span.replaceWith(...Array.from(span.childNodes));
|
|
458
|
+
}
|
|
459
|
+
code.normalize();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* How many characters come before the boundary (`container`, `offset`) in
|
|
464
|
+
* `root`, counting each `<br>` as one — the newline it stands for.
|
|
465
|
+
*
|
|
466
|
+
* `Range.toString()` counts a `<br>` as nothing, so on a line opened with
|
|
467
|
+
* Shift+Enter the caret was remembered one character early per break above it,
|
|
468
|
+
* and every repaint walked it back towards the top of the block.
|
|
469
|
+
*/
|
|
470
|
+
function characterOffsetIn(root, container, offset) {
|
|
471
|
+
if (!root.contains(container))
|
|
472
|
+
return null;
|
|
473
|
+
const measure = root.ownerDocument.createRange();
|
|
474
|
+
measure.setStart(root, 0);
|
|
475
|
+
measure.setEnd(container, offset);
|
|
476
|
+
return rawLengthOf(measure.cloneContents());
|
|
477
|
+
}
|
|
478
|
+
/** Text length with every `<br>` counted as one character, the trailing one included. */
|
|
479
|
+
function rawLengthOf(node) {
|
|
480
|
+
let length = 0;
|
|
481
|
+
for (const child of Array.from(node.childNodes)) {
|
|
482
|
+
if (child.nodeType === Node.TEXT_NODE)
|
|
483
|
+
length += child.data.length;
|
|
484
|
+
else if (child.nodeName === 'BR')
|
|
485
|
+
length += 1;
|
|
486
|
+
else
|
|
487
|
+
length += rawLengthOf(child);
|
|
488
|
+
}
|
|
489
|
+
return length;
|
|
490
|
+
}
|
|
491
|
+
function collapsedRange(doc, place) {
|
|
492
|
+
const range = doc.createRange();
|
|
493
|
+
place(range);
|
|
494
|
+
range.collapse(true);
|
|
495
|
+
return range;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* A collapsed range `offset` characters into `root`, counted as
|
|
499
|
+
* {@link characterOffsetIn} counts them, or `null` when the block is empty.
|
|
500
|
+
*/
|
|
501
|
+
function rangeAtCharacterOffset(root, offset) {
|
|
502
|
+
const doc = root.ownerDocument;
|
|
503
|
+
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
504
|
+
let seen = 0;
|
|
505
|
+
let tail = null;
|
|
506
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
507
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
508
|
+
const text = node;
|
|
509
|
+
if (seen + text.data.length >= offset)
|
|
510
|
+
return collapsedRange(doc, (range) => range.setStart(text, offset - seen));
|
|
511
|
+
seen += text.data.length;
|
|
512
|
+
tail = text;
|
|
513
|
+
}
|
|
514
|
+
else if (node.nodeName === 'BR') {
|
|
515
|
+
const br = node;
|
|
516
|
+
if (seen >= offset)
|
|
517
|
+
return collapsedRange(doc, (range) => range.setStartBefore(br));
|
|
518
|
+
seen += 1;
|
|
519
|
+
tail = br;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const end = tail;
|
|
523
|
+
if (!end)
|
|
524
|
+
return null;
|
|
525
|
+
// Past a break with nothing after it: the empty line a Shift+Enter opened.
|
|
526
|
+
if (end.nodeType === Node.TEXT_NODE)
|
|
527
|
+
return collapsedRange(doc, (range) => range.setStart(end, end.data.length));
|
|
528
|
+
return collapsedRange(doc, (range) => range.setStartAfter(end));
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Highlights one code block and puts the caret back where it was.
|
|
532
|
+
*
|
|
533
|
+
* The caret is remembered as a CHARACTER OFFSET into the block's text, not as a
|
|
534
|
+
* node and an index: every node inside the block is replaced, so a remembered
|
|
535
|
+
* node would be detached by the time it was restored — the caret would jump to
|
|
536
|
+
* the top of the document and the next keystroke would land there. The text
|
|
537
|
+
* itself does not change, so the offset always still means the same place.
|
|
538
|
+
*
|
|
539
|
+
* Does nothing when the block is already painted exactly as it would be, so a
|
|
540
|
+
* keystroke that changes no token does not move the selection at all. That
|
|
541
|
+
* comparison is made on the DETACHED paint, before any node is replaced: made
|
|
542
|
+
* after, as it first was, the nodes were already swapped, the "nothing changed"
|
|
543
|
+
* exit skipped the restore, and the browser dropped the caret to the start of
|
|
544
|
+
* the block -- so every second keystroke in a word landed at the front.
|
|
545
|
+
*/
|
|
546
|
+
function highlightCodeElementKeepingCaret(code, language, selection) {
|
|
547
|
+
const painted = paintedFragment(code, language);
|
|
548
|
+
if (!painted || markupOf(painted) === code.innerHTML)
|
|
549
|
+
return false;
|
|
550
|
+
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
551
|
+
const caret = range?.collapsed ? characterOffsetIn(code, range.startContainer, range.startOffset) : null;
|
|
552
|
+
code.replaceChildren(painted);
|
|
553
|
+
if (caret !== null && selection) {
|
|
554
|
+
const restored = rangeAtCharacterOffset(code, caret);
|
|
555
|
+
if (restored) {
|
|
556
|
+
selection.removeAllRanges();
|
|
557
|
+
selection.addRange(restored);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
|
|
75
563
|
/**
|
|
76
564
|
* Counting and cutting text the way a reader sees it.
|
|
77
565
|
*/
|
|
@@ -829,6 +1317,32 @@ function firstTextNode(node) {
|
|
|
829
1317
|
return null;
|
|
830
1318
|
return doc.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode();
|
|
831
1319
|
}
|
|
1320
|
+
/**
|
|
1321
|
+
* How deep blocks nest before the markdown reader leaves the rest as text; see
|
|
1322
|
+
* parseToggleBlocks and buildBlockquote in rich-text-markdown.service.ts. The
|
|
1323
|
+
* reader counts a level for every details body, quote holding a nested quote
|
|
1324
|
+
* and list item's block, so a details block nested through a list or a quote
|
|
1325
|
+
* meets the same cap; a sub-list, read in its parent's pass, adds none. The
|
|
1326
|
+
* writer counts the same list items and every quote (see nestingDepthOf), which
|
|
1327
|
+
* is never less.
|
|
1328
|
+
*
|
|
1329
|
+
* It lives here rather than beside the reader so the EDITOR can respect the
|
|
1330
|
+
* same ceiling. A structure typed past it is unwrapped the next time the
|
|
1331
|
+
* document is read back, so building one is building something that will not
|
|
1332
|
+
* survive; the nested-blockquote input rule clamps to this for that reason.
|
|
1333
|
+
*/
|
|
1334
|
+
const MAX_NESTING_DEPTH = 32;
|
|
1335
|
+
/** How many `<blockquote>` elements `node` sits inside, within `root`. */
|
|
1336
|
+
function blockquoteDepthOf(node, root) {
|
|
1337
|
+
let depth = 0;
|
|
1338
|
+
let at = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
1339
|
+
while (at && at !== root && root.contains(at)) {
|
|
1340
|
+
if (at.tagName === 'BLOCKQUOTE')
|
|
1341
|
+
depth++;
|
|
1342
|
+
at = at.parentElement;
|
|
1343
|
+
}
|
|
1344
|
+
return depth;
|
|
1345
|
+
}
|
|
832
1346
|
|
|
833
1347
|
const SVG_CHECK_LENGTH = 256;
|
|
834
1348
|
function isJpeg(b) {
|
|
@@ -2864,14 +3378,15 @@ function parseListLine(line) {
|
|
|
2864
3378
|
// runs after this one, so the refusal has to live here.
|
|
2865
3379
|
if (THEMATIC_BREAK.test(line))
|
|
2866
3380
|
return null;
|
|
2867
|
-
const taskMatch = new RegExp(/^(\s*)[-*+]\s+\[([ xX])\]\s*(\S.*|)$/).exec(line);
|
|
3381
|
+
const taskMatch = new RegExp(/^(\s*)([-*+])\s+\[([ xX])\]\s*(\S.*|)$/).exec(line);
|
|
2868
3382
|
if (taskMatch) {
|
|
2869
|
-
const checked = taskMatch[
|
|
3383
|
+
const checked = taskMatch[3] !== ' ';
|
|
2870
3384
|
return {
|
|
2871
3385
|
indent: taskMatch[1].length,
|
|
2872
3386
|
type: 'task',
|
|
2873
|
-
content: `[${checked ? 'x' : ' '}] ${taskMatch[
|
|
3387
|
+
content: `[${checked ? 'x' : ' '}] ${taskMatch[4]}`,
|
|
2874
3388
|
column: contentColumn(line, taskMatch[1].length + 1),
|
|
3389
|
+
marker: taskMatch[2],
|
|
2875
3390
|
};
|
|
2876
3391
|
}
|
|
2877
3392
|
// The content group is OPTIONAL. Requiring \s+ then a non-space meant an
|
|
@@ -2881,27 +3396,56 @@ function parseListLine(line) {
|
|
|
2881
3396
|
// when a user opens a bullet and clicks away, so this hit an everyday
|
|
2882
3397
|
// keystroke; for <ol> the second list also renumbered from 1.
|
|
2883
3398
|
// A rule (--- / *** / ___) still does not match: it has no space.
|
|
2884
|
-
const ulMatch = new RegExp(/^([ \t]*)[-*+]([ \t].*)?$/).exec(line);
|
|
3399
|
+
const ulMatch = new RegExp(/^([ \t]*)([-*+])([ \t].*)?$/).exec(line);
|
|
2885
3400
|
if (ulMatch) {
|
|
2886
3401
|
return {
|
|
2887
3402
|
indent: ulMatch[1].length,
|
|
2888
3403
|
type: 'ul',
|
|
2889
|
-
content: listItemContent(ulMatch[
|
|
3404
|
+
content: listItemContent(ulMatch[3]),
|
|
2890
3405
|
column: contentColumn(line, ulMatch[1].length + 1),
|
|
3406
|
+
marker: ulMatch[2],
|
|
2891
3407
|
};
|
|
2892
3408
|
}
|
|
2893
|
-
|
|
3409
|
+
// Both ordered delimiters, "." and ")". CommonMark has always allowed the
|
|
3410
|
+
// second, and the writer needs it to end one ordered list before the next.
|
|
3411
|
+
const olMatch = new RegExp(/^([ \t]*)(\d{1,9})([.)])([ \t].*)?$/).exec(line);
|
|
2894
3412
|
if (olMatch) {
|
|
2895
3413
|
return {
|
|
2896
3414
|
indent: olMatch[1].length,
|
|
2897
3415
|
type: 'ol',
|
|
2898
|
-
content: listItemContent(olMatch[
|
|
3416
|
+
content: listItemContent(olMatch[4]),
|
|
2899
3417
|
column: contentColumn(line, olMatch[1].length + olMatch[2].length + 1),
|
|
2900
3418
|
number: Number.parseInt(olMatch[2], 10),
|
|
3419
|
+
marker: olMatch[3],
|
|
2901
3420
|
};
|
|
2902
3421
|
}
|
|
2903
3422
|
return null;
|
|
2904
3423
|
}
|
|
3424
|
+
/** The two markers each kind of list alternates between; see {@link listMarkerFor}. */
|
|
3425
|
+
const UL_MARKERS = ['-', '*'];
|
|
3426
|
+
const OL_MARKERS = ['.', ')'];
|
|
3427
|
+
/** The previous sibling when it is a list of the same kind, so the two would merge. */
|
|
3428
|
+
function adjacentSiblingList(element) {
|
|
3429
|
+
const previous = element.previousElementSibling;
|
|
3430
|
+
return previous?.tagName === element.tagName ? previous : null;
|
|
3431
|
+
}
|
|
3432
|
+
/**
|
|
3433
|
+
* The marker a list is written with, alternating along a run of sibling lists.
|
|
3434
|
+
*
|
|
3435
|
+
* A blank line does not end a list -- CommonMark ends one at a marker CHANGE --
|
|
3436
|
+
* so two `<ol>`s side by side were written `1. a` / `1. b` and read back as ONE
|
|
3437
|
+
* list with b renumbered to 2. Alternating the bullet character (for an ordered
|
|
3438
|
+
* list, the delimiter after the number) puts a boundary the reader honours
|
|
3439
|
+
* between every adjacent pair, and leaves a list with no list beside it written
|
|
3440
|
+
* exactly as before.
|
|
3441
|
+
*/
|
|
3442
|
+
function listMarkerFor(listEl, type) {
|
|
3443
|
+
const markers = type === 'ol' ? OL_MARKERS : UL_MARKERS;
|
|
3444
|
+
let runs = 0;
|
|
3445
|
+
for (let previous = adjacentSiblingList(listEl); previous; previous = adjacentSiblingList(previous))
|
|
3446
|
+
runs++;
|
|
3447
|
+
return markers[runs % markers.length];
|
|
3448
|
+
}
|
|
2905
3449
|
/** How far a line is indented. */
|
|
2906
3450
|
function indentOf(line) {
|
|
2907
3451
|
return line.length - line.trimStart().length;
|
|
@@ -2991,7 +3535,7 @@ function holdLine(line, continuation, pendingBlank) {
|
|
|
2991
3535
|
pendingBlank.length = 0;
|
|
2992
3536
|
}
|
|
2993
3537
|
/** Close the list levels that a line at `indent` of kind `type` does not continue. */
|
|
2994
|
-
function closeLevelsFor(stack, indent, type) {
|
|
3538
|
+
function closeLevelsFor(stack, indent, type, marker) {
|
|
2995
3539
|
// Pop only DEEPER levels. Popping the current one too (`>=`) threw away the
|
|
2996
3540
|
// list a sibling belongs to, so every line started a fresh one — an ordered
|
|
2997
3541
|
// list renumbered from 1 on every row, and a screen reader announced
|
|
@@ -3000,8 +3544,12 @@ function closeLevelsFor(stack, indent, type) {
|
|
|
3000
3544
|
stack.pop();
|
|
3001
3545
|
}
|
|
3002
3546
|
// A same-indent line of a DIFFERENT kind (bullet after numbered) is its own
|
|
3003
|
-
// list, so that one context is replaced rather than appended.
|
|
3004
|
-
|
|
3547
|
+
// list, so that one context is replaced rather than appended. A different
|
|
3548
|
+
// MARKER of the same kind ends it just as surely -- that is CommonMark's
|
|
3549
|
+
// only way to put two sibling lists of one kind next to each other, and the
|
|
3550
|
+
// writer relies on it (see listMarkerFor).
|
|
3551
|
+
const open = stack.at(-1);
|
|
3552
|
+
if (open?.indent === indent && (open.type !== type || open.marker !== marker)) {
|
|
3005
3553
|
stack.pop();
|
|
3006
3554
|
}
|
|
3007
3555
|
}
|
|
@@ -3012,7 +3560,7 @@ function pushListItem(stack, rootLists, line) {
|
|
|
3012
3560
|
parent.items.push(item);
|
|
3013
3561
|
return;
|
|
3014
3562
|
}
|
|
3015
|
-
const list = { type: line.type, items: [item], indent: line.indent, start: line.number ?? 1 };
|
|
3563
|
+
const list = { type: line.type, items: [item], indent: line.indent, start: line.number ?? 1, marker: line.marker };
|
|
3016
3564
|
if (parent)
|
|
3017
3565
|
parent.items.at(-1)?.parts.push(list);
|
|
3018
3566
|
else
|
|
@@ -3317,8 +3865,12 @@ function touchesWord(element) {
|
|
|
3317
3865
|
|| touches(nodeBeside(element, 'nextSibling'), /^[\p{L}\p{N}]/u);
|
|
3318
3866
|
}
|
|
3319
3867
|
function touches(node, letter) {
|
|
3868
|
+
// A BLOCK sibling is not on this line and cannot run into the delimiters.
|
|
3869
|
+
// Counted as touching, a task row's nested <ul> flipped the bold beside it
|
|
3870
|
+
// to its tag form on the SECOND save -- the first save had moved the list
|
|
3871
|
+
// next to it -- so the document kept changing after it had been saved once.
|
|
3320
3872
|
if (node?.nodeType === Node.ELEMENT_NODE)
|
|
3321
|
-
return (node.textContent ?? '') !== '';
|
|
3873
|
+
return isPhrasing(node) && (node.textContent ?? '') !== '';
|
|
3322
3874
|
return node?.nodeType === Node.TEXT_NODE && letter.test(node.textContent ?? '');
|
|
3323
3875
|
}
|
|
3324
3876
|
/**
|
|
@@ -3616,10 +4168,34 @@ function buildListContextHtml(ctx) {
|
|
|
3616
4168
|
// half already does for parentheses. A flat [^\\]]* meant "see [1]" or
|
|
3617
4169
|
// "[Draft] spec" -- everyday link and alt text -- did not match at all, so the
|
|
3618
4170
|
// anchor was destroyed on save and its markdown source shown as page text.
|
|
4171
|
+
const TARGET_BODY = String.raw `(?:[^()]|\([^()]*\))`;
|
|
4172
|
+
const LINK_TEXT_BODY = String.raw `(?:[^[\]]|\[[^[\]]*\])`;
|
|
3619
4173
|
const MEDIA_TARGET_PATTERN = {
|
|
3620
|
-
image:
|
|
3621
|
-
link:
|
|
4174
|
+
image: new RegExp(String.raw `!\[(${LINK_TEXT_BODY}{0,4096})\]\((${TARGET_BODY}+)\)`, 'g'),
|
|
4175
|
+
link: new RegExp(String.raw `\[(${LINK_TEXT_BODY}{1,4096})\]\((${TARGET_BODY}{1,4096})\)`, 'g'),
|
|
3622
4176
|
};
|
|
4177
|
+
/**
|
|
4178
|
+
* The writer's half of {@link TARGET_BODY}: an address the reader hands back
|
|
4179
|
+
* unchanged. It also refuses a backslash, which `protectEscapes` consumes
|
|
4180
|
+
* before any target is matched, so a plain address holding one would not
|
|
4181
|
+
* survive either.
|
|
4182
|
+
*/
|
|
4183
|
+
const PLAIN_TARGET = /^(?:[^()\\]|\([^()\\]*\))*$/;
|
|
4184
|
+
/**
|
|
4185
|
+
* A link or image address as markdown source.
|
|
4186
|
+
*
|
|
4187
|
+
* The reader's grammar allows at most ONE level of balanced parentheses, so an
|
|
4188
|
+
* address holding an odd or a nested ")" was cut at that character: the link
|
|
4189
|
+
* pointed at the truncated prefix and the rest of the address landed on the
|
|
4190
|
+
* page as visible text. CommonMark's answer is the backslash escape, and
|
|
4191
|
+
* `protectEscapes` parks those before any target is matched, so no parenthesis
|
|
4192
|
+
* is left for the grammar to trip on. An address the grammar already returns
|
|
4193
|
+
* unchanged keeps its plain form, so ordinary URLs -- Wikipedia's
|
|
4194
|
+
* "X_(disambiguation)" among them -- are written exactly as before.
|
|
4195
|
+
*/
|
|
4196
|
+
function markdownTarget(target) {
|
|
4197
|
+
return PLAIN_TARGET.test(target) ? target : target.replaceAll(/[\\()]/g, String.raw `\$&`);
|
|
4198
|
+
}
|
|
3623
4199
|
/**
|
|
3624
4200
|
* Marker standing in for a character with Markdown meaning while the emphasis
|
|
3625
4201
|
* passes run. Those passes regex over the WHOLE string, attribute values
|
|
@@ -3690,6 +4266,38 @@ const INDENTED_FENCE_TOKEN = /^\s+\d{1,9}\s*$/;
|
|
|
3690
4266
|
* list item above it, whatever block it turns out to hold.
|
|
3691
4267
|
*/
|
|
3692
4268
|
const CONTINUATION_LINE = /^ {2,}\S/;
|
|
4269
|
+
/** Columns a tab advances to, per CommonMark. */
|
|
4270
|
+
const TAB_STOP = 4;
|
|
4271
|
+
/**
|
|
4272
|
+
* Every line's leading whitespace with its tabs advanced to the next
|
|
4273
|
+
* four-column tab stop.
|
|
4274
|
+
*
|
|
4275
|
+
* CommonMark measures block indentation in COLUMNS (§2.2), while every
|
|
4276
|
+
* indentation predicate here counts characters -- CONTINUATION_LINE, indentOf,
|
|
4277
|
+
* contentColumn. A tab therefore read as one column, fell short of the item's
|
|
4278
|
+
* continuation column, and the block escaped its list item: "- a" followed by a
|
|
4279
|
+
* tab-indented quote came back as a list and a separate paragraph, and the next
|
|
4280
|
+
* save wrote the quote marker as the literal characters "\> q". Expanding the
|
|
4281
|
+
* tabs once, up front, lets all of those predicates stay character-based.
|
|
4282
|
+
*
|
|
4283
|
+
* Only the leading run is rewritten: a tab inside prose is content, not
|
|
4284
|
+
* structure. Fenced and inline code are parked before this runs, so no tab
|
|
4285
|
+
* inside code is ever seen.
|
|
4286
|
+
*/
|
|
4287
|
+
function expandLeadingTabs(text) {
|
|
4288
|
+
if (!text.includes('\t'))
|
|
4289
|
+
return text;
|
|
4290
|
+
return text.split('\n').map(expandLineIndent).join('\n');
|
|
4291
|
+
}
|
|
4292
|
+
function expandLineIndent(line) {
|
|
4293
|
+
const indent = /^[ \t]*/.exec(line)?.[0] ?? '';
|
|
4294
|
+
if (!indent.includes('\t'))
|
|
4295
|
+
return line;
|
|
4296
|
+
let column = 0;
|
|
4297
|
+
for (const ch of indent)
|
|
4298
|
+
column += ch === '\t' ? TAB_STOP - (column % TAB_STOP) : 1;
|
|
4299
|
+
return ' '.repeat(column) + line.slice(indent.length);
|
|
4300
|
+
}
|
|
3693
4301
|
/** Private-use delimiters parking a raw HTML tag during the inline passes. */
|
|
3694
4302
|
const RAW_TAG_OPEN = '';
|
|
3695
4303
|
const RAW_TAG_CLOSE = '';
|
|
@@ -3971,6 +4579,10 @@ class RichTextMarkdownService {
|
|
|
3971
4579
|
// -- rendering "</div>" as visible "</div>".
|
|
3972
4580
|
const protectedCode = [];
|
|
3973
4581
|
html = this.protectCodeFences(html, protectedCode);
|
|
4582
|
+
// AFTER the fences are parked, so a tab inside code is never rewritten,
|
|
4583
|
+
// and BEFORE every block pass, all of which measure indentation in
|
|
4584
|
+
// characters. See expandLeadingTabs.
|
|
4585
|
+
html = expandLeadingTabs(html);
|
|
3974
4586
|
// Inline code is lifted out with the fences and for the same reason: a
|
|
3975
4587
|
// code span is inert text. parseInlineCode ran LAST, after the emphasis
|
|
3976
4588
|
// and line-break passes had already rewritten its contents, so
|
|
@@ -4003,8 +4615,8 @@ class RichTextMarkdownService {
|
|
|
4003
4615
|
// before this pass ever ran, and the break was silently dropped.
|
|
4004
4616
|
html = this.parseLineBreaks(html);
|
|
4005
4617
|
html = this.parseParagraphs(html, protectedTags);
|
|
4006
|
-
html = this.parseImages(html, protectedInline, inlineSources, protectedTags);
|
|
4007
|
-
html = this.parseLinks(html, inlineSources, protectedTags);
|
|
4618
|
+
html = this.parseImages(html, protectedInline, inlineSources, protectedTags, protectedEscapes);
|
|
4619
|
+
html = this.parseLinks(html, inlineSources, protectedTags, protectedEscapes);
|
|
4008
4620
|
html = this.parseBoldItalic(html);
|
|
4009
4621
|
html = this.parseStrikethrough(html);
|
|
4010
4622
|
// After the emphasis passes, so the characters hidden in link and image
|
|
@@ -4469,7 +5081,7 @@ class RichTextMarkdownService {
|
|
|
4469
5081
|
result.push(...orphans, line);
|
|
4470
5082
|
continue;
|
|
4471
5083
|
}
|
|
4472
|
-
closeLevelsFor(stack, parsed.indent, parsed.type);
|
|
5084
|
+
closeLevelsFor(stack, parsed.indent, parsed.type, parsed.marker);
|
|
4473
5085
|
flushContinuation();
|
|
4474
5086
|
pushListItem(stack, rootLists, parsed);
|
|
4475
5087
|
}
|
|
@@ -4734,9 +5346,17 @@ class RichTextMarkdownService {
|
|
|
4734
5346
|
* the query string broke. A target with a space is no target; only one that
|
|
4735
5347
|
* held a code span or a tag is checked for it, since a plain target with a
|
|
4736
5348
|
* space has always been read as a link.
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
5349
|
+
*
|
|
5350
|
+
* A backslash escape is resolved LAST, after the character references: it is
|
|
5351
|
+
* the author saying "this character, literally", so what it hands back must
|
|
5352
|
+
* not be read as syntax again -- `\&` means those five characters, not
|
|
5353
|
+
* an ampersand. Resolving it here at all is what lets the writer escape a
|
|
5354
|
+
* parenthesis the target grammar cannot carry (see markdownTarget): the real
|
|
5355
|
+
* character reaches the sanitizer and the attribute, rather than the parked
|
|
5356
|
+
* token, which sanitizeImageSrc percent-encoded into the address.
|
|
5357
|
+
*/
|
|
5358
|
+
targetSource(target, inlineSources, tagStore, escapes) {
|
|
5359
|
+
const typed = restoreParked(decodeCharacterReferences(restoreParked(restoreParked(target, INLINE_CODE_OPEN, INLINE_CODE_CLOSE, inlineSources), RAW_TAG_OPEN, RAW_TAG_CLOSE, tagStore)), ESCAPED_OPEN, ESCAPED_CLOSE, escapes);
|
|
4740
5360
|
return holdsParkedToken(target) && /\s/.test(typed) ? null : typed;
|
|
4741
5361
|
}
|
|
4742
5362
|
/**
|
|
@@ -4748,12 +5368,49 @@ class RichTextMarkdownService {
|
|
|
4748
5368
|
altText(alt, inlineStore, tagStore) {
|
|
4749
5369
|
return restoreParked(resolveInlineCodeText(alt, inlineStore), RAW_TAG_OPEN, RAW_TAG_CLOSE, tagStore, (tag) => this.escapeHtml(tag));
|
|
4750
5370
|
}
|
|
4751
|
-
|
|
5371
|
+
/**
|
|
5372
|
+
* Park a tag this pass wrote in the raw-tag store, so no later pass can see
|
|
5373
|
+
* inside it.
|
|
5374
|
+
*
|
|
5375
|
+
* The tag is written UNSHIELDED because parking supersedes the shield:
|
|
5376
|
+
* restoreRawTags runs after unshieldUrls, so a shielded character parked in
|
|
5377
|
+
* here would never be given back.
|
|
5378
|
+
*/
|
|
5379
|
+
parkWrittenTag(tag, tagStore) {
|
|
5380
|
+
const token = `${RAW_TAG_OPEN}${tagStore.length}${RAW_TAG_CLOSE}`;
|
|
5381
|
+
tagStore.push(tag);
|
|
5382
|
+
return token;
|
|
5383
|
+
}
|
|
5384
|
+
/**
|
|
5385
|
+
* An `<img>` this pass wrote, with its attributes escaped but not shielded.
|
|
5386
|
+
*
|
|
5387
|
+
* Its alt text is DOCUMENT TEXT, which escapeHtmlInContent has already
|
|
5388
|
+
* escaped, so only the quote is escaped again here -- escaping it twice
|
|
5389
|
+
* brought a "<" back as the characters "<", one more layer per save.
|
|
5390
|
+
*/
|
|
5391
|
+
imageTag(attribute, target, alt) {
|
|
5392
|
+
return `<img ${attribute}="${this.escapeHtml(target)}" alt="${alt.replaceAll('"', '"')}">`;
|
|
5393
|
+
}
|
|
5394
|
+
/**
|
|
5395
|
+
* Parse images .
|
|
5396
|
+
*
|
|
5397
|
+
* The tag is parked, not returned live. Left in the stream, its alt
|
|
5398
|
+
* attribute was still document text as far as the passes that follow were
|
|
5399
|
+
* concerned: parseLinks read ` c](i.png)` as a link INSIDE the
|
|
5400
|
+
* attribute and wrote an `<a href="` into it, which ended the attribute and
|
|
5401
|
+
* put the rest of the tag on the page. Shielding only hid `* _ \` ~`, so it
|
|
5402
|
+
* could never have covered the bracket forms.
|
|
5403
|
+
*/
|
|
5404
|
+
parseImages(html, inlineStore, inlineSources, tagStore, escapes) {
|
|
4752
5405
|
return html.replaceAll(MEDIA_TARGET_PATTERN.image, (match, alt, target) => {
|
|
4753
|
-
const src = this.targetSource(target, inlineSources, tagStore);
|
|
5406
|
+
const src = this.targetSource(target, inlineSources, tagStore, escapes);
|
|
4754
5407
|
if (src === null)
|
|
4755
5408
|
return match;
|
|
4756
5409
|
const safeSrc = this.sanitizer.sanitizeImageSrc(src);
|
|
5410
|
+
// An alt attribute is plain text: a parked code span restored in
|
|
5411
|
+
// there would land as the literal string "<code>x</code>". Resolve
|
|
5412
|
+
// it back to the text the author typed instead.
|
|
5413
|
+
const plainAlt = this.altText(alt, inlineStore, tagStore);
|
|
4757
5414
|
if (!safeSrc) {
|
|
4758
5415
|
// A POLICY-blocked image keeps its element and alt, so the
|
|
4759
5416
|
// reader sees a labelled frame rather than nothing and the block
|
|
@@ -4763,22 +5420,17 @@ class RichTextMarkdownService {
|
|
|
4763
5420
|
const blocked = this.sanitizer.takeBlockedByPolicy();
|
|
4764
5421
|
if (blocked === null)
|
|
4765
5422
|
return '';
|
|
4766
|
-
|
|
4767
|
-
return `<img data-blocked-src="${this.attr(blocked)}" alt="${this.textAttr(blockedAlt)}">`;
|
|
5423
|
+
return this.parkWrittenTag(this.imageTag('data-blocked-src', blocked, plainAlt), tagStore);
|
|
4768
5424
|
}
|
|
4769
|
-
|
|
4770
|
-
// there would land as the literal string "<code>x</code>". Resolve
|
|
4771
|
-
// it back to the text the author typed instead.
|
|
4772
|
-
const plainAlt = this.altText(alt, inlineStore, tagStore);
|
|
4773
|
-
return `<img src="${this.attr(safeSrc)}" alt="${this.textAttr(plainAlt)}">`;
|
|
5425
|
+
return this.parkWrittenTag(this.imageTag('src', safeSrc, plainAlt), tagStore);
|
|
4774
5426
|
});
|
|
4775
5427
|
}
|
|
4776
5428
|
/**
|
|
4777
5429
|
* Parse links [text](url).
|
|
4778
5430
|
*/
|
|
4779
|
-
parseLinks(html, inlineSources, tagStore) {
|
|
5431
|
+
parseLinks(html, inlineSources, tagStore, escapes) {
|
|
4780
5432
|
return html.replaceAll(MEDIA_TARGET_PATTERN.link, (match, text, target) => {
|
|
4781
|
-
const url = this.targetSource(target, inlineSources, tagStore);
|
|
5433
|
+
const url = this.targetSource(target, inlineSources, tagStore, escapes);
|
|
4782
5434
|
if (url === null)
|
|
4783
5435
|
return match;
|
|
4784
5436
|
const safeUrl = this.sanitizer.sanitizeUrl(url);
|
|
@@ -5070,7 +5722,7 @@ class RichTextMarkdownService {
|
|
|
5070
5722
|
// of the link fused.
|
|
5071
5723
|
if (inner.trim() === '')
|
|
5072
5724
|
return `<a href="${this.escapeHtml(href)}"></a>${inner}`;
|
|
5073
|
-
return `[${inner}](${href})`;
|
|
5725
|
+
return `[${inner}](${markdownTarget(href)})`;
|
|
5074
5726
|
}
|
|
5075
5727
|
handleImageTag(element) {
|
|
5076
5728
|
const alt = element.getAttribute('alt') ?? '';
|
|
@@ -5087,7 +5739,7 @@ class RichTextMarkdownService {
|
|
|
5087
5739
|
// the paragraph inside the attribute, backticks or asterisks were read as
|
|
5088
5740
|
// syntax, and "©" or "<i>" came back as a character or a tag.
|
|
5089
5741
|
const text = escapeMarkdownText(alt.replaceAll(/[ \t\n\r\f]+/g, ' '));
|
|
5090
|
-
return ``;
|
|
5742
|
+
return `})`;
|
|
5091
5743
|
}
|
|
5092
5744
|
/**
|
|
5093
5745
|
* Unwrap every span that is written as its text alone, so the text on either
|
|
@@ -5192,7 +5844,16 @@ class RichTextMarkdownService {
|
|
|
5192
5844
|
// from opening and the code came back as paragraphs.
|
|
5193
5845
|
const language = element.querySelector('code')?.dataset['language'] ?? '';
|
|
5194
5846
|
const lang = /^[\w+#.-]+$/.test(language) ? language : '';
|
|
5195
|
-
|
|
5847
|
+
// A fence is TEXT: it cannot carry an image, and a block written from
|
|
5848
|
+
// textContent alone deleted one outright on the first save. The tag form
|
|
5849
|
+
// can carry it and reads back verbatim, which is the answer inline code
|
|
5850
|
+
// already gives for the same content.
|
|
5851
|
+
if (element.querySelector('img'))
|
|
5852
|
+
return this.preTagForm(element, lang, inListItem);
|
|
5853
|
+
// Every line break, including a <br>. textContent counts a <br> as
|
|
5854
|
+
// nothing, and Shift+Enter -- how a line is added inside a block --
|
|
5855
|
+
// inserts one, so a function typed line by line was saved on one line.
|
|
5856
|
+
const codeContent = codeTextOf(element);
|
|
5196
5857
|
const indent = inListItem ? ' ' : '';
|
|
5197
5858
|
const body = codeContent
|
|
5198
5859
|
.split('\n')
|
|
@@ -5202,6 +5863,21 @@ class RichTextMarkdownService {
|
|
|
5202
5863
|
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
5203
5864
|
return `\n${indent}${fence}${lang}\n${body}\n${indent}${fence}\n`;
|
|
5204
5865
|
}
|
|
5866
|
+
/**
|
|
5867
|
+
* A code block as its tag pair, on one markdown line.
|
|
5868
|
+
*
|
|
5869
|
+
* One line, with every newline written as a character reference, because a
|
|
5870
|
+
* raw newline inside the pair would let the block passes split it: the
|
|
5871
|
+
* `<pre>` and `</pre>` are parked separately and the lines between them are
|
|
5872
|
+
* read as markdown. The reference decodes to the newline when the sanitizer
|
|
5873
|
+
* parses the restored tag, so the code keeps its own line breaks.
|
|
5874
|
+
*/
|
|
5875
|
+
preTagForm(element, language, inListItem) {
|
|
5876
|
+
const code = element.querySelector('code') ?? element;
|
|
5877
|
+
const lang = language ? ` data-language="${this.escapeHtml(language)}"` : '';
|
|
5878
|
+
const indent = inListItem ? ' ' : '';
|
|
5879
|
+
return `\n${indent}<pre><code${lang}>${this.codeTagContent(code)}</code></pre>\n`;
|
|
5880
|
+
}
|
|
5205
5881
|
handleUlTag(element) {
|
|
5206
5882
|
const result = ['\n'];
|
|
5207
5883
|
const isTask = 'taskList' in element.dataset;
|
|
@@ -5393,10 +6069,11 @@ class RichTextMarkdownService {
|
|
|
5393
6069
|
listToMarkdown(listEl, type, indent, result) {
|
|
5394
6070
|
const items = Array.from(listEl.children);
|
|
5395
6071
|
const first = type === 'ol' ? listStartOf(listEl) : 1;
|
|
6072
|
+
const marker = listMarkerFor(listEl, type);
|
|
5396
6073
|
items.forEach((li, index) => {
|
|
5397
6074
|
const [lead, ...rest] = itemRuns(li);
|
|
5398
6075
|
const ordinal = first + index <= MAX_LIST_NUMBER ? first + index : first;
|
|
5399
|
-
result.push(this.formatListItem(type, li, this.leadContent(li, lead, indent), indent, ordinal));
|
|
6076
|
+
result.push(this.formatListItem(type, li, this.leadContent(li, lead, indent), indent, ordinal, marker));
|
|
5400
6077
|
// Every sub-list, in order, with the content after each where it
|
|
5401
6078
|
// falls. Only the last sub-list was kept, so an item holding two --
|
|
5402
6079
|
// bullets under numbers, which indenting beside an existing sub-list of
|
|
@@ -5442,15 +6119,15 @@ class RichTextMarkdownService {
|
|
|
5442
6119
|
// text vanished from the saved markdown while the HTML looked fine.
|
|
5443
6120
|
return node.nodeType === Node.TEXT_NODE ? textToMarkdown(node) : this.elementToMarkdown(node);
|
|
5444
6121
|
}
|
|
5445
|
-
formatListItem(type, li, content, indent, ordinal) {
|
|
6122
|
+
formatListItem(type, li, content, indent, ordinal, marker) {
|
|
5446
6123
|
if (type === 'task') {
|
|
5447
6124
|
const checked = li.dataset['checked'] === 'true';
|
|
5448
|
-
return `${indent}
|
|
6125
|
+
return `${indent}${marker} [${checked ? 'x' : ' '}] ${breaksAsTags(content)}\n`;
|
|
5449
6126
|
}
|
|
5450
6127
|
if (type === 'ol') {
|
|
5451
|
-
return `${indent}${ordinal}
|
|
6128
|
+
return `${indent}${ordinal}${marker} ${content}\n`;
|
|
5452
6129
|
}
|
|
5453
|
-
return `${indent}
|
|
6130
|
+
return `${indent}${marker} ${content}\n`;
|
|
5454
6131
|
}
|
|
5455
6132
|
detectNestedListType(nestedList) {
|
|
5456
6133
|
if ('taskList' in nestedList.dataset)
|
|
@@ -5611,15 +6288,6 @@ const SUBTREE_INDEPENDENT_TAGS = new Set(['table', 'img', 'input', 'pre', 'ul',
|
|
|
5611
6288
|
const MAX_TABLE_ROWSPAN = 1000;
|
|
5612
6289
|
/** Widest row a table may emit, bounding paste amplification. */
|
|
5613
6290
|
const MAX_TABLE_COLUMNS = 1000;
|
|
5614
|
-
/**
|
|
5615
|
-
* How deep blocks nest before the reader leaves the rest as text; see
|
|
5616
|
-
* parseToggleBlocks and buildBlockquote. The reader counts a level for every
|
|
5617
|
-
* details body, quote holding a nested quote and list item's block, so a details
|
|
5618
|
-
* block nested through a list or a quote meets the same cap; a sub-list, read in
|
|
5619
|
-
* its parent's pass, adds none. The writer counts the same list items and every
|
|
5620
|
-
* quote (see nestingDepthOf), which is never less.
|
|
5621
|
-
*/
|
|
5622
|
-
const MAX_NESTING_DEPTH = 32;
|
|
5623
6291
|
/** HTML's own limit for a column span. */
|
|
5624
6292
|
const MAX_COLSPAN = 1000;
|
|
5625
6293
|
/** Blank-line boundary between blocks, captured so joining restores the text. */
|
|
@@ -9335,7 +10003,9 @@ const BLOCK_RULES = [
|
|
|
9335
10003
|
{ kind: 'heading3', pattern: /^#{3}$/, terminators: [' '] },
|
|
9336
10004
|
{ kind: 'bulletList', pattern: /^[-*]$/, terminators: [' '] },
|
|
9337
10005
|
{ kind: 'orderedList', pattern: /^\d{1,3}\.$/, terminators: [' '] },
|
|
9338
|
-
|
|
10006
|
+
// A RUN of markers, one quote each. `/^>$/` matched exactly one, so a reader
|
|
10007
|
+
// could load and round-trip a nested quote but an author could not type one.
|
|
10008
|
+
{ kind: 'blockquote', pattern: /^(>+)$/, terminators: [' '] },
|
|
9339
10009
|
{ kind: 'taskUnchecked', pattern: /^\[]$/, terminators: [' '] },
|
|
9340
10010
|
{ kind: 'taskChecked', pattern: /^\[[xX]]$/, terminators: [' '] },
|
|
9341
10011
|
{ kind: 'horizontalRule', pattern: /^-{3}$/, terminators: [''] },
|
|
@@ -9364,9 +10034,11 @@ function matchBlockInputRule(textBeforeCaret, terminator) {
|
|
|
9364
10034
|
if (!match)
|
|
9365
10035
|
continue;
|
|
9366
10036
|
const markerLength = textBeforeCaret.length + terminator.length;
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
10037
|
+
if (rule.kind === 'codeBlock')
|
|
10038
|
+
return { kind: rule.kind, markerLength, language: match[1] ?? '' };
|
|
10039
|
+
if (rule.kind === 'blockquote')
|
|
10040
|
+
return { kind: rule.kind, markerLength, depth: match[1].length };
|
|
10041
|
+
return { kind: rule.kind, markerLength };
|
|
9370
10042
|
}
|
|
9371
10043
|
return null;
|
|
9372
10044
|
}
|
|
@@ -9683,6 +10355,25 @@ const RICH_TEXT_PROSE_CLASSES = [
|
|
|
9683
10355
|
'[&_details>:not(summary)]:px-3 [&_details>:not(summary)]:py-2',
|
|
9684
10356
|
'[&_hr]:border-t [&_hr]:border-border [&_hr]:my-4',
|
|
9685
10357
|
'[&_blockquote]:border-s-4 [&_blockquote]:border-border [&_blockquote]:ps-4 [&_blockquote]:py-1 [&_blockquote]:my-2 [&_blockquote]:text-muted-foreground [&_blockquote]:italic',
|
|
10358
|
+
// Syntax highlighting. The classes are the ones the sanitizer has always
|
|
10359
|
+
// allowed (`token`, `token-*`), so a document highlighted here and one
|
|
10360
|
+
// pasted in already highlighted colour by one set of rules — see
|
|
10361
|
+
// `lib/code-highlight.ts`.
|
|
10362
|
+
//
|
|
10363
|
+
// A code block sits on `bg-muted`, which is light in the light theme and
|
|
10364
|
+
// dark in the dark one, so every colour needs both. `ui-code-block`'s own
|
|
10365
|
+
// palette is not reused: it is tuned for that component's fixed
|
|
10366
|
+
// `bg-zinc-950` surface and is unreadable on a light page.
|
|
10367
|
+
'[&_.token-keyword]:text-violet-700 dark:[&_.token-keyword]:text-violet-300 [&_.token-keyword]:font-semibold',
|
|
10368
|
+
'[&_.token-string]:text-emerald-700 dark:[&_.token-string]:text-emerald-300',
|
|
10369
|
+
'[&_.token-comment]:text-muted-foreground [&_.token-comment]:italic',
|
|
10370
|
+
'[&_.token-number]:text-amber-700 dark:[&_.token-number]:text-amber-300',
|
|
10371
|
+
'[&_.token-function]:text-blue-700 dark:[&_.token-function]:text-blue-300',
|
|
10372
|
+
'[&_.token-decorator]:text-fuchsia-700 dark:[&_.token-decorator]:text-fuchsia-300',
|
|
10373
|
+
'[&_.token-tag]:text-rose-700 dark:[&_.token-tag]:text-rose-300',
|
|
10374
|
+
'[&_.token-selector]:text-rose-700 dark:[&_.token-selector]:text-rose-300 [&_.token-selector]:font-semibold',
|
|
10375
|
+
'[&_.token-attr]:text-sky-700 dark:[&_.token-attr]:text-sky-300',
|
|
10376
|
+
'[&_.token-property]:text-sky-700 dark:[&_.token-property]:text-sky-300',
|
|
9686
10377
|
];
|
|
9687
10378
|
|
|
9688
10379
|
/**
|
|
@@ -10039,6 +10730,9 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
10039
10730
|
if (!this.editorDiv)
|
|
10040
10731
|
return;
|
|
10041
10732
|
this.editorDiv.nativeElement.innerHTML = html;
|
|
10733
|
+
// Every block, because every block is new. This is the only path that
|
|
10734
|
+
// repaints the whole document; typing repaints the caret's block alone.
|
|
10735
|
+
highlightCodeBlocks(this.editorDiv.nativeElement);
|
|
10042
10736
|
this.labelBlockedImages();
|
|
10043
10737
|
this.selectedImageNode.set(null);
|
|
10044
10738
|
this.tableCellSelected.set([]);
|
|
@@ -10228,6 +10922,18 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
10228
10922
|
* on every keystroke.
|
|
10229
10923
|
*/
|
|
10230
10924
|
this.findDebounceMs = input(150, ...(ngDevMode ? [{ debugName: "findDebounceMs" }] : /* istanbul ignore next */ []));
|
|
10925
|
+
/**
|
|
10926
|
+
* Milliseconds of quiet before the caret's code block is re-highlighted, so
|
|
10927
|
+
* a burst of keystrokes repaints once. `0` repaints synchronously on every
|
|
10928
|
+
* keystroke, which is what the unit suite uses.
|
|
10929
|
+
*
|
|
10930
|
+
* Highlighting is presentational only: the spans it paints never reach the
|
|
10931
|
+
* value (`readContentFromEditor` unwraps them) and never reach the history,
|
|
10932
|
+
* so this knob changes when colour catches up with typing and nothing else.
|
|
10933
|
+
* Set it to a large number, or highlight nothing, by giving your fences a
|
|
10934
|
+
* language the highlighter does not know.
|
|
10935
|
+
*/
|
|
10936
|
+
this.codeHighlightDebounceMs = input(150, ...(ngDevMode ? [{ debugName: "codeHighlightDebounceMs" }] : /* istanbul ignore next */ []));
|
|
10231
10937
|
/**
|
|
10232
10938
|
* Hosts whose remote images and CSS backgrounds may load. Empty (the
|
|
10233
10939
|
* default) means no policy: any `https://` source is permitted, which is how
|
|
@@ -10493,6 +11199,7 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
10493
11199
|
.replace('{total}', String(total));
|
|
10494
11200
|
}, ...(ngDevMode ? [{ debugName: "findCounterText" }] : /* istanbul ignore next */ []));
|
|
10495
11201
|
this.findDebounceTimer = null;
|
|
11202
|
+
this.codeHighlightTimer = null;
|
|
10496
11203
|
this.findOverlay = null;
|
|
10497
11204
|
this.findRepaintHandle = null;
|
|
10498
11205
|
this.findResizeObserver = null;
|
|
@@ -11068,7 +11775,7 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
11068
11775
|
this.lastInputRule = null;
|
|
11069
11776
|
const transformed = this.applyInputRules(event);
|
|
11070
11777
|
this.sweepSpentCaretAnchors(div);
|
|
11071
|
-
const html = this.sanitizer.sanitize(div
|
|
11778
|
+
const html = this.sanitizer.sanitize(this.undecoratedMarkup(div)).replaceAll('\u200B', '');
|
|
11072
11779
|
const triggerTextContent = this.buildTriggerAwareText(div.innerHTML);
|
|
11073
11780
|
const selection = this.document.getSelection();
|
|
11074
11781
|
const hasSelection = !!selection && selection.rangeCount > 0;
|
|
@@ -11087,6 +11794,10 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
11087
11794
|
else {
|
|
11088
11795
|
this.scheduleDebouncedHistoryPush();
|
|
11089
11796
|
}
|
|
11797
|
+
// LAST, and after the value has already been read: the repaint must
|
|
11798
|
+
// never be what a reader of the document sees, only what the author
|
|
11799
|
+
// does.
|
|
11800
|
+
this.scheduleCodeHighlight();
|
|
11090
11801
|
}
|
|
11091
11802
|
/**
|
|
11092
11803
|
* Template-bound `keydown` handler on the editable area, in strict priority
|
|
@@ -14840,6 +15551,7 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
14840
15551
|
closeFindReplace() {
|
|
14841
15552
|
const current = this.findMatches()[this.findCurrentIndex()]?.cloneRange();
|
|
14842
15553
|
this.cancelPendingFind();
|
|
15554
|
+
this.cancelPendingCodeHighlight();
|
|
14843
15555
|
this.teardownFindOverlay();
|
|
14844
15556
|
this.findReplaceVisible.set(false);
|
|
14845
15557
|
this.findQuery.set('');
|
|
@@ -15482,10 +16194,30 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
15482
16194
|
const editorElement = this.getEditorElement();
|
|
15483
16195
|
if (!editorElement)
|
|
15484
16196
|
return null;
|
|
15485
|
-
const html = this.sanitizer.sanitize(editorElement
|
|
16197
|
+
const html = this.sanitizer.sanitize(this.undecoratedMarkup(editorElement)).replaceAll('\u200B', '');
|
|
15486
16198
|
this.htmlContent.set(html);
|
|
15487
16199
|
return html;
|
|
15488
16200
|
}
|
|
16201
|
+
/**
|
|
16202
|
+
* The editable's markup with the highlighter's spans unwrapped.
|
|
16203
|
+
*
|
|
16204
|
+
* Syntax colour is DECORATION: the author typed the code, not the spans
|
|
16205
|
+
* around it, so they must not reach the value, the history or the markdown.
|
|
16206
|
+
* Left in, every keystroke inside a fence emitted a different HTML string
|
|
16207
|
+
* for the same code, and each undo step carried a repaint of its own.
|
|
16208
|
+
*
|
|
16209
|
+
* Read from a copy, so the live DOM \u2014 and with it the caret and the colour
|
|
16210
|
+
* the author is looking at \u2014 is never touched. The copy is skipped entirely
|
|
16211
|
+
* when the document carries no token span, which is every document with no
|
|
16212
|
+
* code in it.
|
|
16213
|
+
*/
|
|
16214
|
+
undecoratedMarkup(editorElement) {
|
|
16215
|
+
if (!editorElement.querySelector('pre > code span.token'))
|
|
16216
|
+
return editorElement.innerHTML;
|
|
16217
|
+
const copy = editorElement.cloneNode(true);
|
|
16218
|
+
stripCodeHighlighting(copy);
|
|
16219
|
+
return copy.innerHTML;
|
|
16220
|
+
}
|
|
15489
16221
|
getMentionElementsInSelection() {
|
|
15490
16222
|
const editor = this.getEditorElement();
|
|
15491
16223
|
const selection = this.document.getSelection();
|
|
@@ -16260,6 +16992,29 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
16260
16992
|
const quoted = line ? this.quoteLines([line]) : [];
|
|
16261
16993
|
return quoted[0] ?? block;
|
|
16262
16994
|
}
|
|
16995
|
+
/**
|
|
16996
|
+
* Quote one block `levels` deep, or as deep as the markdown reader's
|
|
16997
|
+
* ceiling still allows.
|
|
16998
|
+
*
|
|
16999
|
+
* The ceiling is the READER's ({@link MAX_NESTING_DEPTH}), not one this rule
|
|
17000
|
+
* invents: a quote nested past it is unwrapped when the document is read
|
|
17001
|
+
* back, so building one here types a structure the next load throws away.
|
|
17002
|
+
* Quotes the block ALREADY sits in count towards it, which is also what
|
|
17003
|
+
* makes typing ">" inside a quote nest one level deeper instead of doing
|
|
17004
|
+
* nothing.
|
|
17005
|
+
*
|
|
17006
|
+
* Every level is built inside the one snapshot `tryBlockRule` took, so the
|
|
17007
|
+
* whole transform is a single undo step and Backspace restores all of the
|
|
17008
|
+
* markers the author typed, not just the last one.
|
|
17009
|
+
*/
|
|
17010
|
+
quoteBlockToDepth(block, levels) {
|
|
17011
|
+
const editor = this.editorDiv?.nativeElement;
|
|
17012
|
+
const room = MAX_NESTING_DEPTH - (editor ? blockquoteDepthOf(block, editor) : 0);
|
|
17013
|
+
let quoted = block;
|
|
17014
|
+
for (let level = 0; level < Math.min(levels, room); level++)
|
|
17015
|
+
quoted = this.quoteBlock(quoted);
|
|
17016
|
+
return quoted;
|
|
17017
|
+
}
|
|
16263
17018
|
/**
|
|
16264
17019
|
* Wrap lines in one `<blockquote>` and return them as its lines.
|
|
16265
17020
|
*
|
|
@@ -17181,7 +17936,7 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
17181
17936
|
case 'orderedList':
|
|
17182
17937
|
return this.finishBlockRule(this.wrapBlockInList(block, 'ol'));
|
|
17183
17938
|
case 'blockquote':
|
|
17184
|
-
return this.finishBlockRule(this.
|
|
17939
|
+
return this.finishBlockRule(this.quoteBlockToDepth(block, match.depth ?? 1));
|
|
17185
17940
|
case 'taskUnchecked':
|
|
17186
17941
|
return this.buildTaskBlock(block, false);
|
|
17187
17942
|
case 'taskChecked':
|
|
@@ -17839,6 +18594,55 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
17839
18594
|
this.tableCellSelecting = false;
|
|
17840
18595
|
this.tableCellSelectAnchor = null;
|
|
17841
18596
|
}
|
|
18597
|
+
/**
|
|
18598
|
+
* Repaint the caret's code block after {@link codeHighlightDebounceMs} of
|
|
18599
|
+
* quiet. A debounce of 0 repaints inline.
|
|
18600
|
+
*/
|
|
18601
|
+
scheduleCodeHighlight() {
|
|
18602
|
+
this.cancelPendingCodeHighlight();
|
|
18603
|
+
const delay = this.codeHighlightDebounceMs();
|
|
18604
|
+
if (delay <= 0) {
|
|
18605
|
+
this.highlightCaretCodeBlock();
|
|
18606
|
+
return;
|
|
18607
|
+
}
|
|
18608
|
+
this.codeHighlightTimer = setTimeout(() => {
|
|
18609
|
+
this.codeHighlightTimer = null;
|
|
18610
|
+
this.highlightCaretCodeBlock();
|
|
18611
|
+
}, delay);
|
|
18612
|
+
}
|
|
18613
|
+
cancelPendingCodeHighlight() {
|
|
18614
|
+
if (this.codeHighlightTimer !== null) {
|
|
18615
|
+
clearTimeout(this.codeHighlightTimer);
|
|
18616
|
+
this.codeHighlightTimer = null;
|
|
18617
|
+
}
|
|
18618
|
+
}
|
|
18619
|
+
/**
|
|
18620
|
+
* Repaint the one code block the caret is in.
|
|
18621
|
+
*
|
|
18622
|
+
* The caret's block alone, not the document: a repaint replaces every node
|
|
18623
|
+
* in the block, and doing that to a block the author is not in would detach
|
|
18624
|
+
* whatever else held a reference to it for no visible gain. A wholesale
|
|
18625
|
+
* load repaints everything, through {@link replaceEditorHtml}.
|
|
18626
|
+
*
|
|
18627
|
+
* Nothing here touches the history or the value. The spans are decoration:
|
|
18628
|
+
* `readContentFromEditor` unwraps them before anything reads the document,
|
|
18629
|
+
* so typing in a code block produces the same value it always did, and an
|
|
18630
|
+
* undo step never has a repaint of its own to step through.
|
|
18631
|
+
*/
|
|
18632
|
+
highlightCaretCodeBlock() {
|
|
18633
|
+
const editor = this.editorDiv?.nativeElement;
|
|
18634
|
+
if (!editor || this.isDisabled() || this.readonly())
|
|
18635
|
+
return;
|
|
18636
|
+
const selection = this.document.getSelection();
|
|
18637
|
+
if (!selection || selection.rangeCount === 0)
|
|
18638
|
+
return;
|
|
18639
|
+
const start = selection.getRangeAt(0).startContainer;
|
|
18640
|
+
const node = start.nodeType === Node.TEXT_NODE ? start.parentElement : start;
|
|
18641
|
+
const code = node?.closest('pre > code');
|
|
18642
|
+
if (!code || !editor.contains(code))
|
|
18643
|
+
return;
|
|
18644
|
+
highlightCodeElementKeepingCaret(code, languageOfCodeElement(code), selection);
|
|
18645
|
+
}
|
|
17842
18646
|
ngOnDestroy() {
|
|
17843
18647
|
this.shortcutHandle?.unregister();
|
|
17844
18648
|
this.shortcutHandle = null;
|
|
@@ -17853,7 +18657,7 @@ class RichTextEditorComponent extends RichTextEditorAddonHost {
|
|
|
17853
18657
|
this.removeFloatingScrollListener();
|
|
17854
18658
|
}
|
|
17855
18659
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RichTextEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
17856
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: RichTextEditorComponent, isStandalone: true, selector: "ui-rich-text-editor", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null }, toolbarItems: { classPropertyName: "toolbarItems", publicName: "toolbarItems", isSignal: true, isRequired: false, transformFunction: null }, customToolbarItems: { classPropertyName: "customToolbarItems", publicName: "customToolbarItems", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, maxHeight: { classPropertyName: "maxHeight", publicName: "maxHeight", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, markdownShortcuts: { classPropertyName: "markdownShortcuts", publicName: "markdownShortcuts", isSignal: true, isRequired: false, transformFunction: null }, counter: { classPropertyName: "counter", publicName: "counter", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, history: { classPropertyName: "history", publicName: "history", isSignal: true, isRequired: false, transformFunction: null }, findDebounceMs: { classPropertyName: "findDebounceMs", publicName: "findDebounceMs", isSignal: true, isRequired: false, transformFunction: null }, allowedImageHosts: { classPropertyName: "allowedImageHosts", publicName: "allowedImageHosts", isSignal: true, isRequired: false, transformFunction: null }, allowedLinkSchemes: { classPropertyName: "allowedLinkSchemes", publicName: "allowedLinkSchemes", isSignal: true, isRequired: false, transformFunction: null }, blockedImageMessage: { classPropertyName: "blockedImageMessage", publicName: "blockedImageMessage", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedBy: { classPropertyName: "ariaDescribedBy", publicName: "ariaDescribedBy", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { customToolbarAction: "customToolbarAction", htmlChange: "htmlChange", markdownChange: "markdownChange", wordCountChange: "wordCountChange", historyChange: "historyChange", imageBlocked: "imageBlocked", focused: "focused", blurred: "blurred" }, host: { classAttribute: "block" }, providers: [
|
|
18660
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: RichTextEditorComponent, isStandalone: true, selector: "ui-rich-text-editor", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null }, toolbarItems: { classPropertyName: "toolbarItems", publicName: "toolbarItems", isSignal: true, isRequired: false, transformFunction: null }, customToolbarItems: { classPropertyName: "customToolbarItems", publicName: "customToolbarItems", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, maxHeight: { classPropertyName: "maxHeight", publicName: "maxHeight", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, markdownShortcuts: { classPropertyName: "markdownShortcuts", publicName: "markdownShortcuts", isSignal: true, isRequired: false, transformFunction: null }, counter: { classPropertyName: "counter", publicName: "counter", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, history: { classPropertyName: "history", publicName: "history", isSignal: true, isRequired: false, transformFunction: null }, findDebounceMs: { classPropertyName: "findDebounceMs", publicName: "findDebounceMs", isSignal: true, isRequired: false, transformFunction: null }, codeHighlightDebounceMs: { classPropertyName: "codeHighlightDebounceMs", publicName: "codeHighlightDebounceMs", isSignal: true, isRequired: false, transformFunction: null }, allowedImageHosts: { classPropertyName: "allowedImageHosts", publicName: "allowedImageHosts", isSignal: true, isRequired: false, transformFunction: null }, allowedLinkSchemes: { classPropertyName: "allowedLinkSchemes", publicName: "allowedLinkSchemes", isSignal: true, isRequired: false, transformFunction: null }, blockedImageMessage: { classPropertyName: "blockedImageMessage", publicName: "blockedImageMessage", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedBy: { classPropertyName: "ariaDescribedBy", publicName: "ariaDescribedBy", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { customToolbarAction: "customToolbarAction", htmlChange: "htmlChange", markdownChange: "markdownChange", wordCountChange: "wordCountChange", historyChange: "historyChange", imageBlocked: "imageBlocked", focused: "focused", blurred: "blurred" }, host: { classAttribute: "block" }, providers: [
|
|
17857
18661
|
{
|
|
17858
18662
|
provide: NG_VALUE_ACCESSOR,
|
|
17859
18663
|
useExisting: forwardRef(() => RichTextEditorComponent),
|
|
@@ -17915,7 +18719,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
17915
18719
|
}], tableContextMenuRef: [{
|
|
17916
18720
|
type: ViewChild,
|
|
17917
18721
|
args: ['tableContextMenuRef']
|
|
17918
|
-
}], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], toolbarItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbarItems", required: false }] }], customToolbarItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "customToolbarItems", required: false }] }], customToolbarAction: [{ type: i0.Output, args: ["customToolbarAction"] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], maxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxHeight", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], markdownShortcuts: [{ type: i0.Input, args: [{ isSignal: true, alias: "markdownShortcuts", required: false }] }], counter: [{ type: i0.Input, args: [{ isSignal: true, alias: "counter", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", required: false }] }], history: [{ type: i0.Input, args: [{ isSignal: true, alias: "history", required: false }] }], findDebounceMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "findDebounceMs", required: false }] }], allowedImageHosts: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowedImageHosts", required: false }] }], allowedLinkSchemes: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowedLinkSchemes", required: false }] }], blockedImageMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockedImageMessage", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaDescribedBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaDescribedBy", required: false }] }], dir: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], htmlChange: [{ type: i0.Output, args: ["htmlChange"] }], markdownChange: [{ type: i0.Output, args: ["markdownChange"] }], wordCountChange: [{ type: i0.Output, args: ["wordCountChange"] }], historyChange: [{ type: i0.Output, args: ["historyChange"] }], imageBlocked: [{ type: i0.Output, args: ["imageBlocked"] }], focused: [{ type: i0.Output, args: ["focused"] }], blurred: [{ type: i0.Output, args: ["blurred"] }] } });
|
|
18722
|
+
}], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], toolbarItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbarItems", required: false }] }], customToolbarItems: [{ type: i0.Input, args: [{ isSignal: true, alias: "customToolbarItems", required: false }] }], customToolbarAction: [{ type: i0.Output, args: ["customToolbarAction"] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], maxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxHeight", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], markdownShortcuts: [{ type: i0.Input, args: [{ isSignal: true, alias: "markdownShortcuts", required: false }] }], counter: [{ type: i0.Input, args: [{ isSignal: true, alias: "counter", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", required: false }] }], history: [{ type: i0.Input, args: [{ isSignal: true, alias: "history", required: false }] }], findDebounceMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "findDebounceMs", required: false }] }], codeHighlightDebounceMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "codeHighlightDebounceMs", required: false }] }], allowedImageHosts: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowedImageHosts", required: false }] }], allowedLinkSchemes: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowedLinkSchemes", required: false }] }], blockedImageMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "blockedImageMessage", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaDescribedBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaDescribedBy", required: false }] }], dir: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], htmlChange: [{ type: i0.Output, args: ["htmlChange"] }], markdownChange: [{ type: i0.Output, args: ["markdownChange"] }], wordCountChange: [{ type: i0.Output, args: ["wordCountChange"] }], historyChange: [{ type: i0.Output, args: ["historyChange"] }], imageBlocked: [{ type: i0.Output, args: ["imageBlocked"] }], focused: [{ type: i0.Output, args: ["focused"] }], blurred: [{ type: i0.Output, args: ["blurred"] }] } });
|
|
17919
18723
|
/** The last child element of `parent` whose tag is `tagName`, or null. */
|
|
17920
18724
|
function lastChildTagged(parent, tagName) {
|
|
17921
18725
|
for (let child = parent.lastElementChild; child; child = child.previousElementSibling) {
|
|
@@ -30778,7 +31582,7 @@ class RichTextFileImportDirective {
|
|
|
30778
31582
|
async importDocx(file) {
|
|
30779
31583
|
this.assertImportableSize(file);
|
|
30780
31584
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
30781
|
-
const { parseDocx } = await import('./gilav21-shadcn-angular-rte-docx-parser-
|
|
31585
|
+
const { parseDocx } = await import('./gilav21-shadcn-angular-rte-docx-parser-B8BeJWtY.mjs');
|
|
30782
31586
|
const { renderDocxForEditor } = await import('./gilav21-shadcn-angular-rte-docx-to-editor-html-Cl9eIhkC.mjs');
|
|
30783
31587
|
const html = renderDocxForEditor(parseDocx(bytes));
|
|
30784
31588
|
this.insertImported(html);
|
|
@@ -37740,5 +38544,5 @@ const RTE_FULL = [
|
|
|
37740
38544
|
* Generated bundle index. Do not edit.
|
|
37741
38545
|
*/
|
|
37742
38546
|
|
|
37743
|
-
export {
|
|
37744
|
-
//# sourceMappingURL=gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-
|
|
38547
|
+
export { RichTextAllowHost as $, ACTION_ATTRS as A, RICH_TEXT_LOCALES as B, RICH_TEXT_MENTIONS_LOCALES as C, DEFAULT_COLOR_PALETTE as D, EMPTINESS_FIXTURES as E, FIND_MAX_PAINTED_RECTS as F, RICH_TEXT_OUTLINE_CONTEXT as G, HEADER_BYTES as H, IMAGE_IMPORT_ACCEPT as I, RICH_TEXT_OUTLINE_LOCALES as J, RICH_TEXT_PROSE_CLASSES as K, RICH_TEXT_SHORTCUT_DEFINITIONS as L, MAX_NESTING_DEPTH as M, RICH_TEXT_SLASH_COMMANDS_LOCALES as N, RICH_TEXT_TABLES_BUTTON_CONTEXT as O, PDF_MIME as P, RICH_TEXT_TABLES_LOCALES as Q, RICH_TEXT_ACTIONS_SANITIZER_RULES as R, RICH_TEXT_TYPOGRAPHY_BUTTON_CONTEXT as S, RICH_TEXT_TYPOGRAPHY_LOCALES as T, RTE_FULL as U, RichTextActionsBindDirective as V, RichTextActionsDirective as W, RichTextAiChipComponent as X, RichTextAiDirective as Y, RichTextAiPanelComponent as Z, RichTextAllowDirective as _, ACTION_PARAMS as a, hasUnsafeCssFunction as a$, RichTextColorsButtonComponent as a0, RichTextColorsDirective as a1, RichTextCommandRegistry as a2, RichTextEditorAddonHost as a3, RichTextEditorComponent as a4, RichTextEmojiButtonComponent as a5, RichTextEmojiDirective as a6, RichTextFileImportButtonComponent as a7, RichTextFileImportDirective as a8, RichTextFileImportOverlayComponent as a9, TRANSPARENT_PIXEL as aA, addonSetting as aB, anchorOverlay as aC, applyImageAlignment as aD, applyStarterStyle as aE, assertFlatParams as aF, bindRichTextActions as aG, blockquoteDepthOf as aH, buildDefaultSlashCommands as aI, buildEntityInsertNode as aJ, buildEntityRenderContext as aK, buildFindIndex as aL, buildLineIndex as aM, caretPosition as aN, classifyImport as aO, compileFindRegex as aP, computeSeedStyleString as aQ, containsCssUrl as aR, createImageElement as aS, cssFunctionCalls as aT, dataUrlToFile as aU, decodeCssEscapes as aV, detectTrigger as aW, directionOf as aX, dragHasSupportedDocument as aY, extractCssUrls as aZ, flattenIntoRowText as a_, RichTextHistoryDirective as aa, RichTextHistoryPanelComponent as ab, RichTextImageResizerComponent as ac, RichTextImagesButtonComponent as ad, RichTextImagesDirective as ae, RichTextImagesOverlayComponent as af, RichTextLinksButtonComponent as ag, RichTextLinksDirective as ah, RichTextLinksFormComponent as ai, RichTextMarkdownService as aj, RichTextMentionPopoverComponent as ak, RichTextMentionsDirective as al, RichTextOutlineDirective as am, RichTextOutlinePanelComponent as an, RichTextPasteNormalizerService as ao, RichTextSanitizerService as ap, RichTextSlashCommandsDirective as aq, RichTextSlashCommandsMenuComponent as ar, RichTextTablesButtonComponent as as, RichTextTablesDirective as at, RichTextToolbarComponent as au, RichTextToolbarViewContext as av, RichTextTypographyButtonComponent as aw, RichTextTypographyDirective as ax, TEXT_STYLE_OPTIONS as ay, TOOLBAR_BUTTONS as az, AddonSlotRegistry as b, writeAction as b$, holdsNothing as b0, hostOf as b1, hoverCardAction as b2, hoverCardHandlers as b3, isCombinedOnElement as b4, isHostAllowed as b5, isHostBearingUrl as b6, isInlineHoldingBlock as b7, isLineOwner as b8, isNestedList as b9, openDialogAction as bA, openDialogHandlers as bB, parseImageSize as bC, placeCaretIn as bD, placeImageAtSelection as bE, positionAfterLine as bF, rangeShowsNothing as bG, readActions as bH, readFileAsDataUrl as bI, remoteHostOf as bJ, removeAction as bK, reportResourceDecisions as bL, resolveFromPage as bM, richTextHasMedia as bN, richTextMaxLength as bO, richTextMinWords as bP, richTextRequired as bQ, richTextVisibleText as bR, rowRunsOf as bS, selectTriggerRange as bT, separateListKinds as bU, simpleHash as bV, stripCssComments as bW, stripStyleIfMatches as bX, structureAround as bY, validateActionId as bZ, validateActionParams as b_, isPdfHeader as ba, isPhrasing as bb, isRichTextEmpty as bc, isSupportedDocumentFile as bd, isZipHeader as be, labelBlockedImages as bf, lastOwnInlineNode as bg, lineAbove as bh, lineBelow as bi, lineIsEmpty as bj, lineIsTextOnly as bk, lineKeepsItsElement as bl, lineOf as bm, lineOwnNodes as bn, lineTagIsFixed as bo, lineText as bp, linesBetween as bq, linesMayJoin as br, linkedPreviewDialogAction as bs, linkedPreviewDialogHandlers as bt, matchBlockInputRule as bu, matchInlineInputRule as bv, mountTopLayer as bw, nodeShowsNothing as bx, normalizeHostEntry as by, offsetToPosition as bz, DEFAULT_FONT_FAMILIES as c, writeCombined as c0, DEFAULT_HIGHLIGHT_PALETTE as d, DEFAULT_LINK_SCHEMES as e, DEFAULT_TOOLBAR_ITEMS as f, DOCUMENT_IMPORT_ACCEPT as g, DOCX_MIME as h, isValidImageMagicBytes as i, FIND_MAX_QUERY_LENGTH as j, INLINE_RULE_LOOKBEHIND as k, PresetDialogComponent as l, PresetHoverCardComponent as m, RICH_TEXT_AI_CONTEXT as n, RICH_TEXT_AI_LOCALES as o, RICH_TEXT_COLORS_LOCALES as p, RICH_TEXT_COLOR_BUTTON_CONTEXT as q, RICH_TEXT_EMOJI_CONTEXT as r, RICH_TEXT_EMOJI_LOCALES as s, RICH_TEXT_FILE_IMPORT_BUTTON_CONTEXT as t, RICH_TEXT_FILE_IMPORT_LOCALES as u, RICH_TEXT_HISTORY_LOCALES as v, RICH_TEXT_IMAGES_BUTTON_CONTEXT as w, RICH_TEXT_IMAGES_LOCALES as x, RICH_TEXT_LINKS_BUTTON_CONTEXT as y, RICH_TEXT_LINKS_LOCALES as z };
|
|
38548
|
+
//# sourceMappingURL=gilav21-shadcn-angular-rte-gilav21-shadcn-angular-rte-CfjU_y-y.mjs.map
|