@xui/rich-text-editor 2.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +217 -0
- package/fesm2022/xui-rich-text-editor.mjs +1082 -0
- package/fesm2022/xui-rich-text-editor.mjs.map +1 -0
- package/package.json +51 -0
- package/types/xui-rich-text-editor.d.ts +228 -0
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, forwardRef, viewChild, input, model, booleanAttribute, signal, computed, effect, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
4
|
+
import { provideIcons, NgIcon } from '@ng-icons/core';
|
|
5
|
+
import { matTerminalRound, matLinkRound, matHorizontalRuleRound, matFormatUnderlinedRound, matFormatStrikethroughRound, matFormatQuoteRound, matFormatListNumberedRound, matFormatListBulletedRound, matFormatItalicRound, matFormatBoldRound, matCodeRound } from '@ng-icons/material-icons/round';
|
|
6
|
+
import { xui } from '@xui/core';
|
|
7
|
+
import { XuiIcon } from '@xui/icon';
|
|
8
|
+
|
|
9
|
+
const MARK_TAGS = {
|
|
10
|
+
STRONG: 'bold',
|
|
11
|
+
B: 'bold',
|
|
12
|
+
EM: 'italic',
|
|
13
|
+
I: 'italic',
|
|
14
|
+
U: 'underline',
|
|
15
|
+
S: 'strike',
|
|
16
|
+
STRIKE: 'strike',
|
|
17
|
+
DEL: 'strike',
|
|
18
|
+
CODE: 'code',
|
|
19
|
+
A: 'link'
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Walks the editor's HTML and hands each piece to `writer`.
|
|
23
|
+
*
|
|
24
|
+
* Shared by every syntax so they only describe *what* their markup looks like,
|
|
25
|
+
* never how to traverse a DOM — and so a new syntax cannot get the traversal
|
|
26
|
+
* subtly wrong.
|
|
27
|
+
*/
|
|
28
|
+
function serializeHtml(root, writer) {
|
|
29
|
+
const blocks = [];
|
|
30
|
+
for (const node of Array.from(root.childNodes)) {
|
|
31
|
+
const block = serializeBlock(node, writer);
|
|
32
|
+
if (block !== null) {
|
|
33
|
+
blocks.push(block);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return blocks.filter(block => block !== '').join('\n\n');
|
|
37
|
+
}
|
|
38
|
+
function serializeBlock(node, writer) {
|
|
39
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
40
|
+
const text = node.textContent ?? '';
|
|
41
|
+
// Loose text between blocks — browsers leave it there after some edits.
|
|
42
|
+
return text.trim() ? writer.block('paragraph', writer.escape(text)) : null;
|
|
43
|
+
}
|
|
44
|
+
if (!(node instanceof Element)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
switch (node.tagName) {
|
|
48
|
+
case 'H1':
|
|
49
|
+
return writer.block('heading1', serializeInline(node, writer));
|
|
50
|
+
case 'H2':
|
|
51
|
+
return writer.block('heading2', serializeInline(node, writer));
|
|
52
|
+
case 'H3':
|
|
53
|
+
return writer.block('heading3', serializeInline(node, writer));
|
|
54
|
+
case 'BLOCKQUOTE':
|
|
55
|
+
return writer.block('quote', quoteContent(node, writer));
|
|
56
|
+
case 'PRE':
|
|
57
|
+
return writer.codeBlock(node.textContent ?? '');
|
|
58
|
+
case 'HR':
|
|
59
|
+
return writer.rule();
|
|
60
|
+
case 'UL':
|
|
61
|
+
case 'OL':
|
|
62
|
+
return writer.list(Array.from(node.children)
|
|
63
|
+
.filter(child => child.tagName === 'LI')
|
|
64
|
+
.map(item => serializeInline(item, writer)), node.tagName === 'OL');
|
|
65
|
+
case 'BR':
|
|
66
|
+
return null;
|
|
67
|
+
default:
|
|
68
|
+
// `p`, `div` (what some browsers still produce on Enter), and anything
|
|
69
|
+
// else that reached the top level: treat as a paragraph.
|
|
70
|
+
return writer.block('paragraph', serializeInline(node, writer));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** A quote may hold blocks of its own; flatten them to lines. */
|
|
74
|
+
function quoteContent(node, writer) {
|
|
75
|
+
const hasBlocks = Array.from(node.children).some(child => /^(P|DIV|H[1-3])$/.test(child.tagName));
|
|
76
|
+
if (!hasBlocks) {
|
|
77
|
+
return serializeInline(node, writer);
|
|
78
|
+
}
|
|
79
|
+
return Array.from(node.children)
|
|
80
|
+
.map(child => serializeInline(child, writer))
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join('\n');
|
|
83
|
+
}
|
|
84
|
+
function serializeInline(node, writer) {
|
|
85
|
+
let out = '';
|
|
86
|
+
for (const child of Array.from(node.childNodes)) {
|
|
87
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
88
|
+
out += writer.escape(child.textContent ?? '');
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!(child instanceof Element)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (child.tagName === 'BR') {
|
|
95
|
+
out += '\n';
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const mark = MARK_TAGS[child.tagName];
|
|
99
|
+
const inner = child.tagName === 'CODE' ? writer.escape(child.textContent ?? '') : serializeInline(child, writer);
|
|
100
|
+
out += mark ? writer.mark(mark, inner, child.getAttribute('href') ?? undefined) : inner;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Escapes text for insertion into the editor's HTML. */
|
|
105
|
+
function escapeHtml(text) {
|
|
106
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Keeps `javascript:` and friends out of an `href`.
|
|
110
|
+
*
|
|
111
|
+
* Source text is user input as far as this package is concerned — a comment, a
|
|
112
|
+
* forum post — so a link is only ever emitted when its scheme is one that can
|
|
113
|
+
* do nothing but navigate.
|
|
114
|
+
*/
|
|
115
|
+
function sanitizeUrl(url) {
|
|
116
|
+
const trimmed = url.trim();
|
|
117
|
+
return /^(https?:|mailto:|tel:|\/|#|\.)/i.test(trimmed) ? trimmed : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The BBCode this syntax reads and writes.
|
|
122
|
+
*
|
|
123
|
+
* The tags every forum agrees on. BBCode has underline, which Markdown lacks;
|
|
124
|
+
* it has no headings and no thematic break, so the toolbar drops those — the
|
|
125
|
+
* format decides what the toolbar offers, not the other way round.
|
|
126
|
+
*/
|
|
127
|
+
const FEATURES$1 = [
|
|
128
|
+
'bold',
|
|
129
|
+
'italic',
|
|
130
|
+
'underline',
|
|
131
|
+
'strike',
|
|
132
|
+
'link',
|
|
133
|
+
'bulletList',
|
|
134
|
+
'orderedList',
|
|
135
|
+
'quote',
|
|
136
|
+
'codeBlock'
|
|
137
|
+
];
|
|
138
|
+
const writer$1 = {
|
|
139
|
+
// BBCode has no escape character. A stray `[` is only ambiguous next to a tag
|
|
140
|
+
// name, so that is the one case worth neutralising.
|
|
141
|
+
escape: text => text.replace(/\[(?=\/?[a-z*]+[\]=])/gi, '['),
|
|
142
|
+
mark: (kind, inner, href) => {
|
|
143
|
+
switch (kind) {
|
|
144
|
+
case 'bold':
|
|
145
|
+
return `[b]${inner}[/b]`;
|
|
146
|
+
case 'italic':
|
|
147
|
+
return `[i]${inner}[/i]`;
|
|
148
|
+
case 'underline':
|
|
149
|
+
return `[u]${inner}[/u]`;
|
|
150
|
+
case 'strike':
|
|
151
|
+
return `[s]${inner}[/s]`;
|
|
152
|
+
case 'link':
|
|
153
|
+
return href ? `[url=${href}]${inner}[/url]` : inner;
|
|
154
|
+
// No inline-code tag in BBCode: `[code]` is a block everywhere.
|
|
155
|
+
default:
|
|
156
|
+
return inner;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
block: (kind, inner) => {
|
|
160
|
+
switch (kind) {
|
|
161
|
+
case 'quote':
|
|
162
|
+
return `[quote]${inner}[/quote]`;
|
|
163
|
+
// Headings degrade to plain paragraphs rather than to a `[size]` guess.
|
|
164
|
+
default:
|
|
165
|
+
return inner;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
list: (items, ordered) => [ordered ? '[list=1]' : '[list]', ...items.map(item => `[*]${item}`), '[/list]'].join('\n'),
|
|
169
|
+
codeBlock: text => `[code]\n${text.replace(/\n$/, '')}\n[/code]`,
|
|
170
|
+
rule: () => ''
|
|
171
|
+
};
|
|
172
|
+
/** BBCode ⇄ the editor's HTML. */
|
|
173
|
+
const bbcodeSyntax = {
|
|
174
|
+
name: 'bbcode',
|
|
175
|
+
features: FEATURES$1,
|
|
176
|
+
fromHtml: root => serializeHtml(root, writer$1),
|
|
177
|
+
toHtml: source => bbcodeToHtml(source)
|
|
178
|
+
};
|
|
179
|
+
/** `[tag]…[/tag]` → HTML element, innermost pair first. */
|
|
180
|
+
const PAIRS = [
|
|
181
|
+
[/\[b]([\s\S]*?)\[\/b]/gi, '<strong>$1</strong>'],
|
|
182
|
+
[/\[i]([\s\S]*?)\[\/i]/gi, '<em>$1</em>'],
|
|
183
|
+
[/\[u]([\s\S]*?)\[\/u]/gi, '<u>$1</u>'],
|
|
184
|
+
[/\[s]([\s\S]*?)\[\/s]/gi, '<s>$1</s>']
|
|
185
|
+
];
|
|
186
|
+
function bbcodeToHtml(source) {
|
|
187
|
+
const blocks = splitBlocks(source.replace(/\r\n?/g, '\n'));
|
|
188
|
+
return blocks.join('');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Block tags first — they own whole lines and may hold inline markup — then
|
|
192
|
+
* whatever is left becomes paragraphs.
|
|
193
|
+
*/
|
|
194
|
+
function splitBlocks(source) {
|
|
195
|
+
const html = [];
|
|
196
|
+
const pattern = /\[(code|quote|list)(=1)?]([\s\S]*?)\[\/\1]/gi;
|
|
197
|
+
let cursor = 0;
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
200
|
+
html.push(...paragraphs(source.slice(cursor, match.index)));
|
|
201
|
+
cursor = match.index + match[0].length;
|
|
202
|
+
const [, tag, ordered, body] = match;
|
|
203
|
+
if (tag.toLowerCase() === 'code') {
|
|
204
|
+
html.push(`<pre>${escapeHtml(body.replace(/^\n|\n$/g, ''))}</pre>`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (tag.toLowerCase() === 'quote') {
|
|
208
|
+
html.push(`<blockquote>${paragraphs(body).join('') || '<p></p>'}</blockquote>`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const items = body
|
|
212
|
+
.split(/\[\*]/)
|
|
213
|
+
.map(item => item.trim())
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.map(item => `<li>${inlineToHtml$1(item)}</li>`)
|
|
216
|
+
.join('');
|
|
217
|
+
html.push(ordered ? `<ol>${items}</ol>` : `<ul>${items}</ul>`);
|
|
218
|
+
}
|
|
219
|
+
html.push(...paragraphs(source.slice(cursor)));
|
|
220
|
+
return html;
|
|
221
|
+
}
|
|
222
|
+
function paragraphs(source) {
|
|
223
|
+
return source
|
|
224
|
+
.split(/\n{2,}/)
|
|
225
|
+
.map(block => block.replace(/^\n+|\n+$/g, ''))
|
|
226
|
+
.filter(block => block.trim())
|
|
227
|
+
.map(block => `<p>${block.split('\n').map(inlineToHtml$1).join('<br>')}</p>`);
|
|
228
|
+
}
|
|
229
|
+
function inlineToHtml$1(text) {
|
|
230
|
+
let out = escapeHtml(text).replace(/\[url(?:=([^\]]+))?]([\s\S]*?)\[\/url]/gi, (match, href, label) => {
|
|
231
|
+
const url = sanitizeUrl(href ?? label);
|
|
232
|
+
return url ? `<a href="${escapeHtml(url)}">${label || escapeHtml(url)}</a>` : match;
|
|
233
|
+
});
|
|
234
|
+
for (const [pattern, replacement] of PAIRS) {
|
|
235
|
+
// Non-greedy matches close the innermost pair first, so repeating until the
|
|
236
|
+
// text settles unwraps nested tags from the inside out.
|
|
237
|
+
let previous;
|
|
238
|
+
do {
|
|
239
|
+
previous = out;
|
|
240
|
+
out = out.replace(pattern, replacement);
|
|
241
|
+
} while (out !== previous);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The Markdown this syntax reads and writes.
|
|
248
|
+
*
|
|
249
|
+
* A deliberately small, portable subset — what a comment box needs and what
|
|
250
|
+
* every Markdown renderer agrees on. No tables, footnotes, images or HTML
|
|
251
|
+
* passthrough; no underline, which Markdown has no syntax for.
|
|
252
|
+
*/
|
|
253
|
+
const FEATURES = [
|
|
254
|
+
'bold',
|
|
255
|
+
'italic',
|
|
256
|
+
'strike',
|
|
257
|
+
'code',
|
|
258
|
+
'link',
|
|
259
|
+
'heading1',
|
|
260
|
+
'heading2',
|
|
261
|
+
'heading3',
|
|
262
|
+
'bulletList',
|
|
263
|
+
'orderedList',
|
|
264
|
+
'quote',
|
|
265
|
+
'codeBlock',
|
|
266
|
+
'rule'
|
|
267
|
+
];
|
|
268
|
+
const writer = {
|
|
269
|
+
escape: text => text.replace(/([\\`*_[\]#>~])/g, '\\$1'),
|
|
270
|
+
mark: (kind, inner, href) => {
|
|
271
|
+
switch (kind) {
|
|
272
|
+
case 'bold':
|
|
273
|
+
return `**${inner}**`;
|
|
274
|
+
case 'italic':
|
|
275
|
+
return `*${inner}*`;
|
|
276
|
+
case 'strike':
|
|
277
|
+
return `~~${inner}~~`;
|
|
278
|
+
case 'code':
|
|
279
|
+
// Code spans are verbatim, so the backslashes `escape` added are noise.
|
|
280
|
+
return `\`${inner.replace(/\\([\\`*_[\]#>~])/g, '$1')}\``;
|
|
281
|
+
case 'link':
|
|
282
|
+
return href ? `[${inner}](${href})` : inner;
|
|
283
|
+
// Markdown cannot underline. Rather than invent `<u>`, the text stays put.
|
|
284
|
+
default:
|
|
285
|
+
return inner;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
block: (kind, inner) => {
|
|
289
|
+
switch (kind) {
|
|
290
|
+
case 'heading1':
|
|
291
|
+
return `# ${inner}`;
|
|
292
|
+
case 'heading2':
|
|
293
|
+
return `## ${inner}`;
|
|
294
|
+
case 'heading3':
|
|
295
|
+
return `### ${inner}`;
|
|
296
|
+
case 'quote':
|
|
297
|
+
return inner
|
|
298
|
+
.split('\n')
|
|
299
|
+
.map(line => `> ${line}`)
|
|
300
|
+
.join('\n');
|
|
301
|
+
default:
|
|
302
|
+
return inner;
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
list: (items, ordered) => items.map((item, index) => (ordered ? `${index + 1}. ${item}` : `- ${item}`)).join('\n'),
|
|
306
|
+
codeBlock: text => `\`\`\`\n${text.replace(/\n$/, '')}\n\`\`\``,
|
|
307
|
+
rule: () => '---'
|
|
308
|
+
};
|
|
309
|
+
/** Markdown ⇄ the editor's HTML. */
|
|
310
|
+
const markdownSyntax = {
|
|
311
|
+
name: 'markdown',
|
|
312
|
+
features: FEATURES,
|
|
313
|
+
fromHtml: root => serializeHtml(root, writer),
|
|
314
|
+
toHtml: source => blocksToHtml(source)
|
|
315
|
+
};
|
|
316
|
+
function blocksToHtml(source) {
|
|
317
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
318
|
+
const html = [];
|
|
319
|
+
let index = 0;
|
|
320
|
+
while (index < lines.length) {
|
|
321
|
+
const line = lines[index];
|
|
322
|
+
if (!line.trim()) {
|
|
323
|
+
index++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// Fenced code: verbatim until the closing fence, or the end of the input.
|
|
327
|
+
if (/^```/.test(line)) {
|
|
328
|
+
const code = [];
|
|
329
|
+
index++;
|
|
330
|
+
while (index < lines.length && !/^```/.test(lines[index])) {
|
|
331
|
+
code.push(lines[index++]);
|
|
332
|
+
}
|
|
333
|
+
index++;
|
|
334
|
+
html.push(`<pre>${escapeHtml(code.join('\n'))}</pre>`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
338
|
+
if (heading) {
|
|
339
|
+
const level = heading[1].length;
|
|
340
|
+
html.push(`<h${level}>${inlineToHtml(heading[2])}</h${level}>`);
|
|
341
|
+
index++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
|
|
345
|
+
html.push('<hr>');
|
|
346
|
+
index++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (/^>\s?/.test(line)) {
|
|
350
|
+
const quoted = [];
|
|
351
|
+
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
|
352
|
+
quoted.push(lines[index++].replace(/^>\s?/, ''));
|
|
353
|
+
}
|
|
354
|
+
html.push(`<blockquote>${quoted.map(text => `<p>${inlineToHtml(text)}</p>`).join('')}</blockquote>`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const bullet = /^\s*[-*+]\s+/;
|
|
358
|
+
const ordered = /^\s*\d+[.)]\s+/;
|
|
359
|
+
if (bullet.test(line) || ordered.test(line)) {
|
|
360
|
+
const isOrdered = ordered.test(line);
|
|
361
|
+
const marker = isOrdered ? ordered : bullet;
|
|
362
|
+
const items = [];
|
|
363
|
+
while (index < lines.length && marker.test(lines[index])) {
|
|
364
|
+
items.push(`<li>${inlineToHtml(lines[index++].replace(marker, ''))}</li>`);
|
|
365
|
+
}
|
|
366
|
+
html.push(isOrdered ? `<ol>${items.join('')}</ol>` : `<ul>${items.join('')}</ul>`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// Anything else runs on as a paragraph until a blank line or a new block.
|
|
370
|
+
const paragraph = [];
|
|
371
|
+
while (index < lines.length && lines[index].trim() && !startsBlock(lines[index])) {
|
|
372
|
+
paragraph.push(lines[index++]);
|
|
373
|
+
}
|
|
374
|
+
html.push(`<p>${paragraph.map(inlineToHtml).join('<br>')}</p>`);
|
|
375
|
+
}
|
|
376
|
+
return html.join('');
|
|
377
|
+
}
|
|
378
|
+
function startsBlock(line) {
|
|
379
|
+
return /^(```|#{1,3}\s|>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(line) || /^(\s*[-*_]){3,}\s*$/.test(line);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Stand-ins for the two things that must sit out the emphasis passes: a code
|
|
383
|
+
* span, and a backslash-escaped character. Private-use codepoints, so nothing a
|
|
384
|
+
* user can type collides with them.
|
|
385
|
+
*/
|
|
386
|
+
const SPAN = String.fromCharCode(0xe000);
|
|
387
|
+
const ESCAPED = String.fromCharCode(0xe001);
|
|
388
|
+
/**
|
|
389
|
+
* Inline Markdown → HTML.
|
|
390
|
+
*
|
|
391
|
+
* Code spans and escapes are lifted out first and put back last. That is what
|
|
392
|
+
* keeps `**` inside a code span from turning bold, and `\*` from being read as
|
|
393
|
+
* emphasis at all — both are invisible to the passes in between.
|
|
394
|
+
*/
|
|
395
|
+
function inlineToHtml(text) {
|
|
396
|
+
const codeSpans = [];
|
|
397
|
+
const escapes = [];
|
|
398
|
+
let out = escapeHtml(text)
|
|
399
|
+
.replace(/`([^`]+)`/g, (_, code) => {
|
|
400
|
+
codeSpans.push(code);
|
|
401
|
+
return `${SPAN}${codeSpans.length - 1}${SPAN}`;
|
|
402
|
+
})
|
|
403
|
+
.replace(/\\([\\`*_[\]#>~])/g, (_, char) => {
|
|
404
|
+
escapes.push(char);
|
|
405
|
+
return `${ESCAPED}${escapes.length - 1}${ESCAPED}`;
|
|
406
|
+
});
|
|
407
|
+
out = out
|
|
408
|
+
.replace(/\[([^\]]+)]\(([^)\s]+)\)/g, (match, label, href) => {
|
|
409
|
+
const url = sanitizeUrl(href);
|
|
410
|
+
return url ? `<a href="${escapeHtml(url)}">${label}</a>` : match;
|
|
411
|
+
})
|
|
412
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>')
|
|
413
|
+
.replace(/(?<![*\w])\*(?=\S)([^*]*?\S)\*(?![*\w])/g, '<em>$1</em>')
|
|
414
|
+
.replace(/(?<![_\w])_(?=\S)([^_]*?\S)_(?![_\w])/g, '<em>$1</em>')
|
|
415
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<s>$1</s>');
|
|
416
|
+
return out
|
|
417
|
+
.replace(new RegExp(`${ESCAPED}(\\d+)${ESCAPED}`, 'g'), (_, index) => escapes[Number(index)])
|
|
418
|
+
.replace(new RegExp(`${SPAN}(\\d+)${SPAN}`, 'g'), (_, index) => `<code>${codeSpans[Number(index)]}</code>`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const defaultConfig = {
|
|
422
|
+
format: 'markdown',
|
|
423
|
+
placeholder: '',
|
|
424
|
+
sourceView: true
|
|
425
|
+
};
|
|
426
|
+
const XuiRichTextEditorConfigToken = new InjectionToken('XuiRichTextEditorConfig');
|
|
427
|
+
/** Application-wide defaults for every editor. Individual inputs still win. */
|
|
428
|
+
function provideXuiRichTextEditorConfig(config) {
|
|
429
|
+
return { provide: XuiRichTextEditorConfigToken, useValue: { ...defaultConfig, ...config } };
|
|
430
|
+
}
|
|
431
|
+
function injectXuiRichTextEditorConfig() {
|
|
432
|
+
return inject(XuiRichTextEditorConfigToken, { optional: true }) ?? defaultConfig;
|
|
433
|
+
}
|
|
434
|
+
const XuiRichTextSyntaxToken = new InjectionToken('XuiRichTextSyntax');
|
|
435
|
+
/**
|
|
436
|
+
* Teaches the editor a format.
|
|
437
|
+
*
|
|
438
|
+
* Markdown and BBCode are built in; this adds to them, or replaces one by
|
|
439
|
+
* registering a syntax under the same name — the last one provided wins, so an
|
|
440
|
+
* app can swap in its own flavour of Markdown without forking the editor.
|
|
441
|
+
*
|
|
442
|
+
* ```ts
|
|
443
|
+
* provideXuiRichTextSyntax({
|
|
444
|
+
* name: 'plain',
|
|
445
|
+
* features: ['bold'],
|
|
446
|
+
* toHtml: text => `<p>${text}</p>`,
|
|
447
|
+
* fromHtml: root => root.textContent ?? ''
|
|
448
|
+
* });
|
|
449
|
+
* ```
|
|
450
|
+
*/
|
|
451
|
+
function provideXuiRichTextSyntax(...syntaxes) {
|
|
452
|
+
return { provide: XuiRichTextSyntaxToken, useValue: syntaxes, multi: true };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Every known syntax, by name. Built-ins first, so anything provided by the app
|
|
456
|
+
* that reuses a name replaces it.
|
|
457
|
+
*/
|
|
458
|
+
function injectXuiRichTextSyntaxes() {
|
|
459
|
+
const provided = inject(XuiRichTextSyntaxToken, { optional: true });
|
|
460
|
+
return new Map([markdownSyntax, bbcodeSyntax, ...(provided ?? []).flat()].map(syntax => [syntax.name, syntax]));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The full toolbar, in order. Each editor shows the entries its format can
|
|
465
|
+
* express — the same button set can therefore never produce markup the format
|
|
466
|
+
* cannot round-trip.
|
|
467
|
+
*/
|
|
468
|
+
const TOOLS = [
|
|
469
|
+
{ feature: 'bold', label: 'Bold', icon: 'matFormatBoldRound', command: 'bold', state: 'command', key: 'b' },
|
|
470
|
+
{ feature: 'italic', label: 'Italic', icon: 'matFormatItalicRound', command: 'italic', state: 'command', key: 'i' },
|
|
471
|
+
{
|
|
472
|
+
feature: 'underline',
|
|
473
|
+
label: 'Underline',
|
|
474
|
+
icon: 'matFormatUnderlinedRound',
|
|
475
|
+
command: 'underline',
|
|
476
|
+
state: 'command',
|
|
477
|
+
key: 'u'
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
feature: 'strike',
|
|
481
|
+
label: 'Strikethrough',
|
|
482
|
+
icon: 'matFormatStrikethroughRound',
|
|
483
|
+
command: 'strikeThrough',
|
|
484
|
+
state: 'command'
|
|
485
|
+
},
|
|
486
|
+
{ feature: 'code', label: 'Inline code', icon: 'matCodeRound', command: 'xuiCode', state: 'none' },
|
|
487
|
+
{ feature: 'link', label: 'Link', icon: 'matLinkRound', command: 'xuiLink', state: 'none', key: 'k' },
|
|
488
|
+
{ feature: 'heading1', label: 'Heading 1', text: 'H1', command: 'formatBlock', argument: 'h1', state: 'block' },
|
|
489
|
+
{ feature: 'heading2', label: 'Heading 2', text: 'H2', command: 'formatBlock', argument: 'h2', state: 'block' },
|
|
490
|
+
{ feature: 'heading3', label: 'Heading 3', text: 'H3', command: 'formatBlock', argument: 'h3', state: 'block' },
|
|
491
|
+
{
|
|
492
|
+
feature: 'bulletList',
|
|
493
|
+
label: 'Bulleted list',
|
|
494
|
+
icon: 'matFormatListBulletedRound',
|
|
495
|
+
command: 'insertUnorderedList',
|
|
496
|
+
state: 'command'
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
feature: 'orderedList',
|
|
500
|
+
label: 'Numbered list',
|
|
501
|
+
icon: 'matFormatListNumberedRound',
|
|
502
|
+
command: 'insertOrderedList',
|
|
503
|
+
state: 'command'
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
feature: 'quote',
|
|
507
|
+
label: 'Quote',
|
|
508
|
+
icon: 'matFormatQuoteRound',
|
|
509
|
+
command: 'formatBlock',
|
|
510
|
+
argument: 'blockquote',
|
|
511
|
+
state: 'block'
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
feature: 'codeBlock',
|
|
515
|
+
label: 'Code block',
|
|
516
|
+
icon: 'matTerminalRound',
|
|
517
|
+
command: 'formatBlock',
|
|
518
|
+
argument: 'pre',
|
|
519
|
+
state: 'block'
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
feature: 'rule',
|
|
523
|
+
label: 'Divider',
|
|
524
|
+
icon: 'matHorizontalRuleRound',
|
|
525
|
+
command: 'insertHorizontalRule',
|
|
526
|
+
state: 'none'
|
|
527
|
+
}
|
|
528
|
+
];
|
|
529
|
+
const XUI_RICH_TEXT_EDITOR_VALUE_ACCESSOR = {
|
|
530
|
+
provide: NG_VALUE_ACCESSOR,
|
|
531
|
+
useExisting: forwardRef(() => XuiRichTextEditor),
|
|
532
|
+
multi: true
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* A WYSIWYG editor whose value is source text.
|
|
536
|
+
*
|
|
537
|
+
* The user works on formatted content; the binding is the Markdown or BBCode
|
|
538
|
+
* you store — so nothing downstream has to know an editor was involved, and
|
|
539
|
+
* nothing has to be sanitised on the way out.
|
|
540
|
+
*
|
|
541
|
+
* ```html
|
|
542
|
+
* <xui-rich-text-editor [(value)]="comment" format="markdown" placeholder="Say something…" />
|
|
543
|
+
* ```
|
|
544
|
+
*
|
|
545
|
+
* ```html
|
|
546
|
+
* <xui-rich-text-editor formControlName="post" format="bbcode" />
|
|
547
|
+
* ```
|
|
548
|
+
*
|
|
549
|
+
* The toolbar is the format's capabilities: Markdown offers headings and a
|
|
550
|
+
* divider but no underline, BBCode the reverse. Teach it a new format with
|
|
551
|
+
* {@link provideXuiRichTextSyntax} and the toolbar follows.
|
|
552
|
+
*
|
|
553
|
+
* The source view (`</>`) shows the text itself, editable, and the two stay in
|
|
554
|
+
* step. Pasting always lands as plain text — foreign HTML never enters the
|
|
555
|
+
* document, so the value cannot carry markup the format does not define.
|
|
556
|
+
*/
|
|
557
|
+
class XuiRichTextEditor {
|
|
558
|
+
config = injectXuiRichTextEditorConfig();
|
|
559
|
+
syntaxes = injectXuiRichTextSyntaxes();
|
|
560
|
+
surface = viewChild('surface', /* @ts-ignore */
|
|
561
|
+
...(ngDevMode ? [{ debugName: "surface" }] : /* istanbul ignore next */ []));
|
|
562
|
+
linkField = viewChild('linkField', /* @ts-ignore */
|
|
563
|
+
...(ngDevMode ? [{ debugName: "linkField" }] : /* istanbul ignore next */ []));
|
|
564
|
+
class = input('', /* @ts-ignore */
|
|
565
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
566
|
+
/** The source text, in the active `format`. Two-way bindable, and the form value. */
|
|
567
|
+
value = model('', /* @ts-ignore */
|
|
568
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
569
|
+
/** `'markdown'`, `'bbcode'`, or any format registered with `provideXuiRichTextSyntax`. */
|
|
570
|
+
format = input(this.config.format, /* @ts-ignore */
|
|
571
|
+
...(ngDevMode ? [{ debugName: "format" }] : /* istanbul ignore next */ []));
|
|
572
|
+
placeholder = input(this.config.placeholder, /* @ts-ignore */
|
|
573
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
574
|
+
/** Offer the source-text toggle. */
|
|
575
|
+
sourceView = input(this.config.sourceView, { ...(ngDevMode ? { debugName: "sourceView" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
576
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
577
|
+
/** Height of the writing area before it grows with the content. */
|
|
578
|
+
minHeight = input(180, /* @ts-ignore */
|
|
579
|
+
...(ngDevMode ? [{ debugName: "minHeight" }] : /* istanbul ignore next */ []));
|
|
580
|
+
ariaLabel = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
|
|
581
|
+
/** Whether the source view is showing. Two-way bindable. */
|
|
582
|
+
source = model(false, /* @ts-ignore */
|
|
583
|
+
...(ngDevMode ? [{ debugName: "source" }] : /* istanbul ignore next */ []));
|
|
584
|
+
linkDraft = signal(null, /* @ts-ignore */
|
|
585
|
+
...(ngDevMode ? [{ debugName: "linkDraft" }] : /* istanbul ignore next */ []));
|
|
586
|
+
active = signal([], /* @ts-ignore */
|
|
587
|
+
...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
|
|
588
|
+
onChange;
|
|
589
|
+
onTouched;
|
|
590
|
+
disabledByForm = signal(false, /* @ts-ignore */
|
|
591
|
+
...(ngDevMode ? [{ debugName: "disabledByForm" }] : /* istanbul ignore next */ []));
|
|
592
|
+
isDisabled = computed(() => this.disabled() || this.disabledByForm(), /* @ts-ignore */
|
|
593
|
+
...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
|
|
594
|
+
/**
|
|
595
|
+
* The value the surface already shows, tagged with the format it was rendered
|
|
596
|
+
* in. Re-rendering the user's own keystrokes would reset the caret on every
|
|
597
|
+
* one, so the effect below writes only when this no longer matches.
|
|
598
|
+
*/
|
|
599
|
+
synced = null;
|
|
600
|
+
/** The element that value went into — the source toggle builds a new one. */
|
|
601
|
+
syncedSurface = null;
|
|
602
|
+
/** What the link is to wrap, held while the URL field has the selection. */
|
|
603
|
+
savedRange = null;
|
|
604
|
+
syntax = computed(() => {
|
|
605
|
+
const syntax = this.syntaxes.get(this.format());
|
|
606
|
+
if (!syntax) {
|
|
607
|
+
throw new Error(`@xui/rich-text-editor: unknown format "${this.format()}". ` +
|
|
608
|
+
`Known formats: ${[...this.syntaxes.keys()].join(', ')}. Register others with provideXuiRichTextSyntax().`);
|
|
609
|
+
}
|
|
610
|
+
return syntax;
|
|
611
|
+
}, /* @ts-ignore */
|
|
612
|
+
...(ngDevMode ? [{ debugName: "syntax" }] : /* istanbul ignore next */ []));
|
|
613
|
+
tools = computed(() => {
|
|
614
|
+
const features = this.syntax().features;
|
|
615
|
+
return TOOLS.filter(tool => features.includes(tool.feature));
|
|
616
|
+
}, /* @ts-ignore */
|
|
617
|
+
...(ngDevMode ? [{ debugName: "tools" }] : /* istanbul ignore next */ []));
|
|
618
|
+
computedClass = computed(() => xui('border-border bg-surface-inset text-foreground block overflow-hidden rounded-lg border', 'focus-within:border-focus transition-colors', 'data-disabled:cursor-not-allowed data-disabled:opacity-50', '[&.ng-invalid.ng-touched]:border-error', this.class()), /* @ts-ignore */
|
|
619
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
620
|
+
toolClass = computed(() => xui('text-foreground-muted hover:bg-surface-raised hover:text-foreground flex items-center rounded px-1.5 py-1', 'data-active:bg-surface-raised data-active:text-foreground disabled:pointer-events-none disabled:opacity-50', 'focus-visible:outline-focus focus-visible:outline-2'), /* @ts-ignore */
|
|
621
|
+
...(ngDevMode ? [{ debugName: "toolClass" }] : /* istanbul ignore next */ []));
|
|
622
|
+
/** Tailwind resets block elements, so the writing area re-states how prose looks. */
|
|
623
|
+
surfaceClass = computed(() => xui('block p-3 outline-none [&>*+*]:mt-2', '[&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold', '[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:ps-6 [&_ol]:ps-6', '[&_blockquote]:border-border-strong [&_blockquote]:text-foreground-muted [&_blockquote]:border-s-2 [&_blockquote]:ps-3', '[&_pre]:bg-surface-sunken [&_pre]:rounded [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-sm', '[&_code]:bg-surface-sunken [&_code]:rounded [&_code]:px-1 [&_code]:font-mono [&_code]:text-sm', '[&_pre_code]:bg-transparent [&_pre_code]:p-0', '[&_a]:text-link [&_a]:underline', '[&_hr]:border-border [&_hr]:my-3'), /* @ts-ignore */
|
|
624
|
+
...(ngDevMode ? [{ debugName: "surfaceClass" }] : /* istanbul ignore next */ []));
|
|
625
|
+
constructor() {
|
|
626
|
+
// Enter should start a paragraph, and marks should be tags rather than
|
|
627
|
+
// inline styles — otherwise there is nothing for a syntax to recognise.
|
|
628
|
+
safely(() => document.execCommand?.('defaultParagraphSeparator', false, 'p'));
|
|
629
|
+
safely(() => document.execCommand?.('styleWithCSS', false, 'false'));
|
|
630
|
+
// Renders whenever the value arrives from outside — a binding, a form, the
|
|
631
|
+
// source toggle — and skips the echo of the user's own typing, which would
|
|
632
|
+
// otherwise reset the caret on every keystroke.
|
|
633
|
+
effect(() => {
|
|
634
|
+
const surface = this.surface()?.nativeElement;
|
|
635
|
+
const value = this.value();
|
|
636
|
+
const key = this.syncKey(value);
|
|
637
|
+
if (!surface || (key === this.synced && surface === this.syncedSurface)) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
this.syncedSurface = surface;
|
|
641
|
+
// A format switch keeps the content and changes only how it is written
|
|
642
|
+
// down: the document is the same either way, so it is re-read in the new
|
|
643
|
+
// format rather than re-rendered from text the new format cannot parse.
|
|
644
|
+
if (this.synced && !this.synced.startsWith(`${this.format()}:`) && surface.innerHTML) {
|
|
645
|
+
const translated = this.syntax().fromHtml(surface);
|
|
646
|
+
this.synced = this.syncKey(translated);
|
|
647
|
+
this.value.set(translated);
|
|
648
|
+
this.onChange?.(translated);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
surface.innerHTML = this.syntax().toHtml(value);
|
|
652
|
+
this.synced = key;
|
|
653
|
+
});
|
|
654
|
+
effect(() => {
|
|
655
|
+
if (this.linkDraft() !== null) {
|
|
656
|
+
this.linkField()?.nativeElement.focus();
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
/** The editing surface, for consumers who need to focus it. */
|
|
661
|
+
focus() {
|
|
662
|
+
this.surface()?.nativeElement.focus();
|
|
663
|
+
}
|
|
664
|
+
/** Show the formatted view (`false`) or the source text (`true`). */
|
|
665
|
+
toggleSource() {
|
|
666
|
+
this.source.update(source => !source);
|
|
667
|
+
}
|
|
668
|
+
onSourceInput(event) {
|
|
669
|
+
this.commit(event.target.value);
|
|
670
|
+
}
|
|
671
|
+
/** Reads the surface back out as source text. */
|
|
672
|
+
pull() {
|
|
673
|
+
const surface = this.surface()?.nativeElement;
|
|
674
|
+
if (!surface) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.commit(this.syntax().fromHtml(surface));
|
|
678
|
+
this.refreshActive();
|
|
679
|
+
}
|
|
680
|
+
onKeydown(event) {
|
|
681
|
+
if (!(event.metaKey || event.ctrlKey)) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const tool = this.tools().find(candidate => candidate.key === event.key.toLowerCase());
|
|
685
|
+
if (tool) {
|
|
686
|
+
event.preventDefault();
|
|
687
|
+
this.run(tool);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Paste as plain text.
|
|
692
|
+
*
|
|
693
|
+
* Whatever is on the clipboard — a Word document, half a web page — would
|
|
694
|
+
* otherwise arrive as HTML the format cannot express and the editor would
|
|
695
|
+
* have to guess at. The text is inserted as typed instead.
|
|
696
|
+
*/
|
|
697
|
+
onPaste(event) {
|
|
698
|
+
event.preventDefault();
|
|
699
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
700
|
+
this.exec('insertText', text);
|
|
701
|
+
this.pull();
|
|
702
|
+
}
|
|
703
|
+
run(tool) {
|
|
704
|
+
if (this.isDisabled() || this.source()) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (tool.command === 'xuiLink') {
|
|
708
|
+
// Captured before anything touches focus: the URL field is about to take
|
|
709
|
+
// the document selection, and this is the range the link must wrap.
|
|
710
|
+
this.savedRange = currentRange();
|
|
711
|
+
// The field appears with the draft; an effect focuses it once it exists.
|
|
712
|
+
this.linkDraft.set('');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
this.focus();
|
|
716
|
+
if (tool.command === 'xuiCode') {
|
|
717
|
+
this.wrapSelection('code');
|
|
718
|
+
}
|
|
719
|
+
else if (tool.command === 'formatBlock') {
|
|
720
|
+
// A second press on the same block returns it to a paragraph.
|
|
721
|
+
const target = this.active().includes(tool.feature) ? 'p' : tool.argument;
|
|
722
|
+
this.exec('formatBlock', target);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
this.exec(tool.command, tool.argument);
|
|
726
|
+
}
|
|
727
|
+
this.pull();
|
|
728
|
+
}
|
|
729
|
+
applyLink() {
|
|
730
|
+
const href = this.linkDraft()?.trim();
|
|
731
|
+
this.linkDraft.set(null);
|
|
732
|
+
this.focus();
|
|
733
|
+
this.restoreSelection();
|
|
734
|
+
if (href) {
|
|
735
|
+
this.exec('createLink', href);
|
|
736
|
+
this.pull();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** Puts the caret back where it was before the URL field took focus. */
|
|
740
|
+
restoreSelection() {
|
|
741
|
+
const selection = getSelection();
|
|
742
|
+
if (!this.savedRange || !selection) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
selection.removeAllRanges();
|
|
746
|
+
selection.addRange(this.savedRange);
|
|
747
|
+
this.savedRange = null;
|
|
748
|
+
}
|
|
749
|
+
/** Reflects the caret's formatting on the toolbar. */
|
|
750
|
+
refreshActive() {
|
|
751
|
+
if (typeof document === 'undefined' || !document.queryCommandState) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const block = document.queryCommandValue?.('formatBlock')?.toLowerCase();
|
|
755
|
+
this.active.set(this.tools()
|
|
756
|
+
.filter(tool => {
|
|
757
|
+
if (tool.state === 'command') {
|
|
758
|
+
return safely(() => document.queryCommandState(tool.command));
|
|
759
|
+
}
|
|
760
|
+
return tool.state === 'block' && block === tool.argument;
|
|
761
|
+
})
|
|
762
|
+
.map(tool => tool.feature));
|
|
763
|
+
}
|
|
764
|
+
/** Wraps the selection in a tag, or unwraps it when it is already wrapped. */
|
|
765
|
+
wrapSelection(tag) {
|
|
766
|
+
const selection = getSelection();
|
|
767
|
+
if (!selection?.rangeCount) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const range = selection.getRangeAt(0);
|
|
771
|
+
const existing = closest(range.commonAncestorContainer, tag);
|
|
772
|
+
if (existing) {
|
|
773
|
+
existing.replaceWith(...Array.from(existing.childNodes));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (!range.collapsed) {
|
|
777
|
+
this.exec('insertHTML', `<${tag}>${escapeText(range.toString())}</${tag}>`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
exec(command, argument) {
|
|
781
|
+
// `execCommand` is the only editing API every browser implements for
|
|
782
|
+
// contenteditable; absent under a test DOM, where nothing is being edited.
|
|
783
|
+
safely(() => document.execCommand?.(command, false, argument));
|
|
784
|
+
}
|
|
785
|
+
commit(next) {
|
|
786
|
+
if (next === this.value()) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
// The surface already shows this — recorded before the write so the render
|
|
790
|
+
// effect sees it and leaves the caret where the user put it.
|
|
791
|
+
this.synced = this.syncKey(next);
|
|
792
|
+
this.value.set(next);
|
|
793
|
+
this.onChange?.(next);
|
|
794
|
+
}
|
|
795
|
+
syncKey(value) {
|
|
796
|
+
return `${this.format()}:${value}`;
|
|
797
|
+
}
|
|
798
|
+
// --- ControlValueAccessor ---
|
|
799
|
+
writeValue(value) {
|
|
800
|
+
this.value.set(value ?? '');
|
|
801
|
+
// Force the next render: the value came from outside, so whatever is in the
|
|
802
|
+
// surface is stale even if the string matches what we last serialised.
|
|
803
|
+
this.synced = null;
|
|
804
|
+
}
|
|
805
|
+
registerOnChange(fn) {
|
|
806
|
+
this.onChange = fn;
|
|
807
|
+
}
|
|
808
|
+
registerOnTouched(fn) {
|
|
809
|
+
this.onTouched = fn;
|
|
810
|
+
}
|
|
811
|
+
setDisabledState(isDisabled) {
|
|
812
|
+
this.disabledByForm.set(isDisabled);
|
|
813
|
+
}
|
|
814
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiRichTextEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
815
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiRichTextEditor, isStandalone: true, selector: "xui-rich-text-editor", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, sourceView: { classPropertyName: "sourceView", publicName: "sourceView", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", source: "sourceChange" }, host: { properties: { "class": "computedClass()", "attr.data-disabled": "isDisabled() ? \"\" : null" } }, providers: [XUI_RICH_TEXT_EDITOR_VALUE_ACCESSOR], viewQueries: [{ propertyName: "surface", first: true, predicate: ["surface"], descendants: true, isSignal: true }, { propertyName: "linkField", first: true, predicate: ["linkField"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
816
|
+
<div
|
|
817
|
+
role="toolbar"
|
|
818
|
+
aria-label="Formatting"
|
|
819
|
+
class="border-border bg-surface-sunken flex flex-wrap items-center gap-0.5 rounded-t-lg border-b p-1"
|
|
820
|
+
>
|
|
821
|
+
@for (tool of tools(); track tool.feature) {
|
|
822
|
+
<button
|
|
823
|
+
type="button"
|
|
824
|
+
[class]="toolClass()"
|
|
825
|
+
[attr.aria-label]="tool.label"
|
|
826
|
+
[attr.aria-pressed]="tool.state === 'none' ? null : active().includes(tool.feature)"
|
|
827
|
+
[attr.data-active]="active().includes(tool.feature) ? '' : null"
|
|
828
|
+
[disabled]="isDisabled() || source()"
|
|
829
|
+
[title]="tool.label"
|
|
830
|
+
(mousedown)="$event.preventDefault()"
|
|
831
|
+
(click)="run(tool)"
|
|
832
|
+
>
|
|
833
|
+
@if (tool.icon) {
|
|
834
|
+
<ng-icon xui size="sm" [name]="tool.icon" />
|
|
835
|
+
} @else {
|
|
836
|
+
<span class="px-0.5 text-xs font-semibold">{{ tool.text }}</span>
|
|
837
|
+
}
|
|
838
|
+
</button>
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
@if (sourceView()) {
|
|
842
|
+
<button
|
|
843
|
+
type="button"
|
|
844
|
+
[class]="toolClass()"
|
|
845
|
+
class="ms-auto"
|
|
846
|
+
aria-label="Source"
|
|
847
|
+
[attr.aria-pressed]="source()"
|
|
848
|
+
[attr.data-active]="source() ? '' : null"
|
|
849
|
+
[disabled]="isDisabled()"
|
|
850
|
+
title="Source"
|
|
851
|
+
(click)="toggleSource()"
|
|
852
|
+
>
|
|
853
|
+
<span class="px-0.5 text-xs font-semibold"></></span>
|
|
854
|
+
</button>
|
|
855
|
+
}
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
@if (linkDraft() !== null) {
|
|
859
|
+
<div class="border-border bg-surface-sunken flex items-center gap-1 border-b p-1">
|
|
860
|
+
<input
|
|
861
|
+
#linkField
|
|
862
|
+
type="url"
|
|
863
|
+
class="text-foreground placeholder:text-foreground-subtle min-w-0 flex-1 bg-transparent px-2 py-1 text-sm outline-none"
|
|
864
|
+
placeholder="https://example.com"
|
|
865
|
+
aria-label="Link URL"
|
|
866
|
+
[value]="linkDraft()"
|
|
867
|
+
(input)="linkDraft.set($any($event.target).value)"
|
|
868
|
+
(keydown.enter)="applyLink(); $event.preventDefault()"
|
|
869
|
+
(keydown.escape)="linkDraft.set(null)"
|
|
870
|
+
/>
|
|
871
|
+
<button type="button" [class]="toolClass()" class="text-xs" (click)="applyLink()">Apply</button>
|
|
872
|
+
<button type="button" [class]="toolClass()" class="text-xs" (click)="linkDraft.set(null)">Cancel</button>
|
|
873
|
+
</div>
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
@if (source()) {
|
|
877
|
+
<textarea
|
|
878
|
+
class="text-foreground placeholder:text-foreground-subtle block w-full resize-y bg-transparent p-3 font-mono text-sm outline-none"
|
|
879
|
+
[style.min-height.px]="minHeight()"
|
|
880
|
+
[attr.aria-label]="'Source (' + format() + ')'"
|
|
881
|
+
[disabled]="isDisabled()"
|
|
882
|
+
[value]="value()"
|
|
883
|
+
(input)="onSourceInput($event)"
|
|
884
|
+
(blur)="onTouched?.()"
|
|
885
|
+
></textarea>
|
|
886
|
+
} @else {
|
|
887
|
+
<div class="relative">
|
|
888
|
+
@if (!value().trim() && placeholder()) {
|
|
889
|
+
<p class="text-foreground-subtle pointer-events-none absolute p-3 select-none">{{ placeholder() }}</p>
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
|
|
893
|
+
<div
|
|
894
|
+
#surface
|
|
895
|
+
role="textbox"
|
|
896
|
+
aria-multiline="true"
|
|
897
|
+
[class]="surfaceClass()"
|
|
898
|
+
[style.min-height.px]="minHeight()"
|
|
899
|
+
[attr.contenteditable]="isDisabled() ? null : true"
|
|
900
|
+
[attr.aria-label]="ariaLabel()"
|
|
901
|
+
[attr.aria-disabled]="isDisabled() ? true : null"
|
|
902
|
+
(input)="pull()"
|
|
903
|
+
(blur)="onTouched?.()"
|
|
904
|
+
(paste)="onPaste($event)"
|
|
905
|
+
(keydown)="onKeydown($event)"
|
|
906
|
+
(keyup)="refreshActive()"
|
|
907
|
+
(mouseup)="refreshActive()"
|
|
908
|
+
></div>
|
|
909
|
+
</div>
|
|
910
|
+
}
|
|
911
|
+
`, isInline: true, dependencies: [{ kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }, { kind: "directive", type: XuiIcon, selector: "ng-icon[xui]", inputs: ["class", "size", "color", "title"], exportAs: ["xuiIcon"] }], viewProviders: [
|
|
912
|
+
provideIcons({
|
|
913
|
+
matCodeRound,
|
|
914
|
+
matFormatBoldRound,
|
|
915
|
+
matFormatItalicRound,
|
|
916
|
+
matFormatListBulletedRound,
|
|
917
|
+
matFormatListNumberedRound,
|
|
918
|
+
matFormatQuoteRound,
|
|
919
|
+
matFormatStrikethroughRound,
|
|
920
|
+
matFormatUnderlinedRound,
|
|
921
|
+
matHorizontalRuleRound,
|
|
922
|
+
matLinkRound,
|
|
923
|
+
matTerminalRound
|
|
924
|
+
})
|
|
925
|
+
], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
926
|
+
}
|
|
927
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiRichTextEditor, decorators: [{
|
|
928
|
+
type: Component,
|
|
929
|
+
args: [{
|
|
930
|
+
selector: 'xui-rich-text-editor',
|
|
931
|
+
imports: [NgIcon, XuiIcon],
|
|
932
|
+
template: `
|
|
933
|
+
<div
|
|
934
|
+
role="toolbar"
|
|
935
|
+
aria-label="Formatting"
|
|
936
|
+
class="border-border bg-surface-sunken flex flex-wrap items-center gap-0.5 rounded-t-lg border-b p-1"
|
|
937
|
+
>
|
|
938
|
+
@for (tool of tools(); track tool.feature) {
|
|
939
|
+
<button
|
|
940
|
+
type="button"
|
|
941
|
+
[class]="toolClass()"
|
|
942
|
+
[attr.aria-label]="tool.label"
|
|
943
|
+
[attr.aria-pressed]="tool.state === 'none' ? null : active().includes(tool.feature)"
|
|
944
|
+
[attr.data-active]="active().includes(tool.feature) ? '' : null"
|
|
945
|
+
[disabled]="isDisabled() || source()"
|
|
946
|
+
[title]="tool.label"
|
|
947
|
+
(mousedown)="$event.preventDefault()"
|
|
948
|
+
(click)="run(tool)"
|
|
949
|
+
>
|
|
950
|
+
@if (tool.icon) {
|
|
951
|
+
<ng-icon xui size="sm" [name]="tool.icon" />
|
|
952
|
+
} @else {
|
|
953
|
+
<span class="px-0.5 text-xs font-semibold">{{ tool.text }}</span>
|
|
954
|
+
}
|
|
955
|
+
</button>
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
@if (sourceView()) {
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
[class]="toolClass()"
|
|
962
|
+
class="ms-auto"
|
|
963
|
+
aria-label="Source"
|
|
964
|
+
[attr.aria-pressed]="source()"
|
|
965
|
+
[attr.data-active]="source() ? '' : null"
|
|
966
|
+
[disabled]="isDisabled()"
|
|
967
|
+
title="Source"
|
|
968
|
+
(click)="toggleSource()"
|
|
969
|
+
>
|
|
970
|
+
<span class="px-0.5 text-xs font-semibold"></></span>
|
|
971
|
+
</button>
|
|
972
|
+
}
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
@if (linkDraft() !== null) {
|
|
976
|
+
<div class="border-border bg-surface-sunken flex items-center gap-1 border-b p-1">
|
|
977
|
+
<input
|
|
978
|
+
#linkField
|
|
979
|
+
type="url"
|
|
980
|
+
class="text-foreground placeholder:text-foreground-subtle min-w-0 flex-1 bg-transparent px-2 py-1 text-sm outline-none"
|
|
981
|
+
placeholder="https://example.com"
|
|
982
|
+
aria-label="Link URL"
|
|
983
|
+
[value]="linkDraft()"
|
|
984
|
+
(input)="linkDraft.set($any($event.target).value)"
|
|
985
|
+
(keydown.enter)="applyLink(); $event.preventDefault()"
|
|
986
|
+
(keydown.escape)="linkDraft.set(null)"
|
|
987
|
+
/>
|
|
988
|
+
<button type="button" [class]="toolClass()" class="text-xs" (click)="applyLink()">Apply</button>
|
|
989
|
+
<button type="button" [class]="toolClass()" class="text-xs" (click)="linkDraft.set(null)">Cancel</button>
|
|
990
|
+
</div>
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
@if (source()) {
|
|
994
|
+
<textarea
|
|
995
|
+
class="text-foreground placeholder:text-foreground-subtle block w-full resize-y bg-transparent p-3 font-mono text-sm outline-none"
|
|
996
|
+
[style.min-height.px]="minHeight()"
|
|
997
|
+
[attr.aria-label]="'Source (' + format() + ')'"
|
|
998
|
+
[disabled]="isDisabled()"
|
|
999
|
+
[value]="value()"
|
|
1000
|
+
(input)="onSourceInput($event)"
|
|
1001
|
+
(blur)="onTouched?.()"
|
|
1002
|
+
></textarea>
|
|
1003
|
+
} @else {
|
|
1004
|
+
<div class="relative">
|
|
1005
|
+
@if (!value().trim() && placeholder()) {
|
|
1006
|
+
<p class="text-foreground-subtle pointer-events-none absolute p-3 select-none">{{ placeholder() }}</p>
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
|
|
1010
|
+
<div
|
|
1011
|
+
#surface
|
|
1012
|
+
role="textbox"
|
|
1013
|
+
aria-multiline="true"
|
|
1014
|
+
[class]="surfaceClass()"
|
|
1015
|
+
[style.min-height.px]="minHeight()"
|
|
1016
|
+
[attr.contenteditable]="isDisabled() ? null : true"
|
|
1017
|
+
[attr.aria-label]="ariaLabel()"
|
|
1018
|
+
[attr.aria-disabled]="isDisabled() ? true : null"
|
|
1019
|
+
(input)="pull()"
|
|
1020
|
+
(blur)="onTouched?.()"
|
|
1021
|
+
(paste)="onPaste($event)"
|
|
1022
|
+
(keydown)="onKeydown($event)"
|
|
1023
|
+
(keyup)="refreshActive()"
|
|
1024
|
+
(mouseup)="refreshActive()"
|
|
1025
|
+
></div>
|
|
1026
|
+
</div>
|
|
1027
|
+
}
|
|
1028
|
+
`,
|
|
1029
|
+
host: {
|
|
1030
|
+
'[class]': 'computedClass()',
|
|
1031
|
+
'[attr.data-disabled]': 'isDisabled() ? "" : null'
|
|
1032
|
+
},
|
|
1033
|
+
providers: [XUI_RICH_TEXT_EDITOR_VALUE_ACCESSOR],
|
|
1034
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1035
|
+
encapsulation: ViewEncapsulation.None,
|
|
1036
|
+
viewProviders: [
|
|
1037
|
+
provideIcons({
|
|
1038
|
+
matCodeRound,
|
|
1039
|
+
matFormatBoldRound,
|
|
1040
|
+
matFormatItalicRound,
|
|
1041
|
+
matFormatListBulletedRound,
|
|
1042
|
+
matFormatListNumberedRound,
|
|
1043
|
+
matFormatQuoteRound,
|
|
1044
|
+
matFormatStrikethroughRound,
|
|
1045
|
+
matFormatUnderlinedRound,
|
|
1046
|
+
matHorizontalRuleRound,
|
|
1047
|
+
matLinkRound,
|
|
1048
|
+
matTerminalRound
|
|
1049
|
+
})
|
|
1050
|
+
]
|
|
1051
|
+
}]
|
|
1052
|
+
}], ctorParameters: () => [], propDecorators: { surface: [{ type: i0.ViewChild, args: ['surface', { isSignal: true }] }], linkField: [{ type: i0.ViewChild, args: ['linkField', { isSignal: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], sourceView: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceView", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }, { type: i0.Output, args: ["sourceChange"] }] } });
|
|
1053
|
+
function currentRange() {
|
|
1054
|
+
const selection = getSelection();
|
|
1055
|
+
return selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
|
|
1056
|
+
}
|
|
1057
|
+
/** The nearest ancestor with `tag`, starting at `node` itself. */
|
|
1058
|
+
function closest(node, tag) {
|
|
1059
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
1060
|
+
return element?.closest(tag) ?? null;
|
|
1061
|
+
}
|
|
1062
|
+
function escapeText(text) {
|
|
1063
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1064
|
+
}
|
|
1065
|
+
/** Runs `fn`, treating a DOM that does not implement it as "no". */
|
|
1066
|
+
function safely(fn) {
|
|
1067
|
+
try {
|
|
1068
|
+
return fn() === true;
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const XuiRichTextEditorImports = [XuiRichTextEditor];
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Generated bundle index. Do not edit.
|
|
1079
|
+
*/
|
|
1080
|
+
|
|
1081
|
+
export { XUI_RICH_TEXT_EDITOR_VALUE_ACCESSOR, XuiRichTextEditor, XuiRichTextEditorImports, bbcodeSyntax, escapeHtml, injectXuiRichTextEditorConfig, injectXuiRichTextSyntaxes, markdownSyntax, provideXuiRichTextEditorConfig, provideXuiRichTextSyntax, sanitizeUrl, serializeHtml };
|
|
1082
|
+
//# sourceMappingURL=xui-rich-text-editor.mjs.map
|