@solidev/data 1.0.1 → 1.1.1
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/AGENTS.md +46 -0
- package/README.md +40 -44
- package/fesm2022/solidev-data-mdedit.mjs +583 -0
- package/fesm2022/solidev-data-mdedit.mjs.map +1 -0
- package/fesm2022/solidev-data-richedit.mjs +1132 -157
- package/fesm2022/solidev-data-richedit.mjs.map +1 -1
- package/fesm2022/solidev-data.mjs +1092 -719
- package/fesm2022/solidev-data.mjs.map +1 -1
- package/package.json +19 -2
- package/types/solidev-data-mdedit.d.ts +226 -0
- package/types/solidev-data-richedit.d.ts +286 -69
- package/types/solidev-data.d.ts +1261 -1038
|
@@ -1,12 +1,1080 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
2
|
+
import { input, viewChild, inject, DestroyRef, afterNextRender, Component, signal, computed } from '@angular/core';
|
|
3
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
4
|
+
import * as i1 from '@angular/forms';
|
|
5
|
+
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
|
6
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
7
|
+
import { FieldEditorBase, SafeHtmlPipe } from '@solidev/data';
|
|
8
|
+
import { chainCommands, exitCode, toggleMark, baseKeymap, setBlockType, lift, wrapIn } from 'prosemirror-commands';
|
|
9
|
+
import { redo, undo, history } from 'prosemirror-history';
|
|
10
|
+
import { inputRules, wrappingInputRule, textblockTypeInputRule } from 'prosemirror-inputrules';
|
|
11
|
+
import { keymap } from 'prosemirror-keymap';
|
|
12
|
+
import { bulletList, orderedList, listItem, liftListItem, sinkListItem, splitListItem, wrapInList } from 'prosemirror-schema-list';
|
|
13
|
+
import { PluginKey, Plugin, EditorState } from 'prosemirror-state';
|
|
14
|
+
import { DecorationSet, Decoration, EditorView } from 'prosemirror-view';
|
|
15
|
+
import { Schema, DOMParser, DOMSerializer } from 'prosemirror-model';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The document schema `richedit` stores and edits.
|
|
19
|
+
*
|
|
20
|
+
* It is a deliberate copy of the schema `ngx-editor` used, because the field
|
|
21
|
+
* values already in consumers' databases were produced by that serializer:
|
|
22
|
+
* `<p style="text-align:center">`, `<span style="color:red;">`, `<u>`, `<s>`,
|
|
23
|
+
* `data-indent` attributes. Anything this schema fails to parse would be
|
|
24
|
+
* silently dropped the next time a user saves, so parse rules are kept wider
|
|
25
|
+
* than the toolbar — `sup`, `sub` and `image` have no button but survive a
|
|
26
|
+
* round trip, and so does `indent`.
|
|
27
|
+
*
|
|
28
|
+
* The one intentional difference is `rel="noopener"` on serialized links, which
|
|
29
|
+
* `ngx-editor` did not emit. It is not parsed back into an attribute, so it
|
|
30
|
+
* stays stable across round trips.
|
|
31
|
+
*/
|
|
32
|
+
/** Style declarations from a camelCased object, skipping empty values. */
|
|
33
|
+
function toStyleString(styles) {
|
|
34
|
+
const declarations = Object.entries(styles)
|
|
35
|
+
.filter(([, value]) => typeof value === 'string' && value !== '')
|
|
36
|
+
.map(([property, value]) => `${property.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}:${String(value)}`);
|
|
37
|
+
return declarations.length ? declarations.join(';') : null;
|
|
38
|
+
}
|
|
39
|
+
/** `align` and `indent`, read the way ngx-editor read them. */
|
|
40
|
+
function blockAttrs(dom) {
|
|
41
|
+
const indent = dom.getAttribute('data-indent');
|
|
42
|
+
return {
|
|
43
|
+
align: dom.getAttribute('align') ?? dom.style.textAlign ?? null,
|
|
44
|
+
indent: Number.parseInt(indent ?? '', 10) || null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** DOM attributes for a block carrying `align` / `indent`. */
|
|
48
|
+
function blockDomAttrs(attrs) {
|
|
49
|
+
const align = attrs['align'];
|
|
50
|
+
const indent = attrs['indent'];
|
|
51
|
+
return {
|
|
52
|
+
style: toStyleString({
|
|
53
|
+
// ngx-editor left `left` implicit; keeping that keeps stored values byte
|
|
54
|
+
// identical when nothing changed.
|
|
55
|
+
textAlign: align !== 'left' ? align : null,
|
|
56
|
+
marginLeft: indent !== null ? `${indent * 40}px` : null,
|
|
57
|
+
}),
|
|
58
|
+
'data-indent': indent !== null ? String(indent) : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const ALIGNABLE = {
|
|
62
|
+
align: { default: null },
|
|
63
|
+
indent: { default: null },
|
|
64
|
+
};
|
|
65
|
+
const nodes = {
|
|
66
|
+
doc: { content: 'block+' },
|
|
67
|
+
text: { group: 'inline' },
|
|
68
|
+
paragraph: {
|
|
69
|
+
content: 'inline*',
|
|
70
|
+
group: 'block',
|
|
71
|
+
attrs: ALIGNABLE,
|
|
72
|
+
parseDOM: [{ tag: 'p', getAttrs: (dom) => blockAttrs(dom) }],
|
|
73
|
+
toDOM: (node) => ['p', blockDomAttrs(node.attrs), 0],
|
|
74
|
+
},
|
|
75
|
+
blockquote: {
|
|
76
|
+
content: 'block+',
|
|
77
|
+
group: 'block',
|
|
78
|
+
defining: true,
|
|
79
|
+
attrs: { indent: { default: null } },
|
|
80
|
+
parseDOM: [
|
|
81
|
+
{
|
|
82
|
+
tag: 'blockquote',
|
|
83
|
+
getAttrs: (dom) => ({
|
|
84
|
+
indent: Number.parseInt(dom.getAttribute('data-indent') ?? '', 10) || null,
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
toDOM: (node) => {
|
|
89
|
+
const indent = node.attrs['indent'];
|
|
90
|
+
return [
|
|
91
|
+
'blockquote',
|
|
92
|
+
{
|
|
93
|
+
style: toStyleString({ marginLeft: indent !== null ? `${indent * 40}px` : null }),
|
|
94
|
+
'data-indent': indent !== null ? String(indent) : null,
|
|
95
|
+
},
|
|
96
|
+
0,
|
|
97
|
+
];
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
horizontal_rule: {
|
|
101
|
+
group: 'block',
|
|
102
|
+
parseDOM: [{ tag: 'hr' }],
|
|
103
|
+
toDOM: () => ['hr'],
|
|
104
|
+
},
|
|
105
|
+
heading: {
|
|
106
|
+
attrs: { level: { default: 1 }, ...ALIGNABLE },
|
|
107
|
+
content: 'inline*',
|
|
108
|
+
group: 'block',
|
|
109
|
+
defining: true,
|
|
110
|
+
parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({
|
|
111
|
+
tag: `h${level}`,
|
|
112
|
+
getAttrs: (dom) => ({ level, ...blockAttrs(dom) }),
|
|
113
|
+
})),
|
|
114
|
+
toDOM: (node) => [`h${String(node.attrs['level'])}`, blockDomAttrs(node.attrs), 0],
|
|
115
|
+
},
|
|
116
|
+
code_block: {
|
|
117
|
+
content: 'text*',
|
|
118
|
+
marks: '',
|
|
119
|
+
group: 'block',
|
|
120
|
+
code: true,
|
|
121
|
+
defining: true,
|
|
122
|
+
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
|
|
123
|
+
toDOM: () => ['pre', ['code', 0]],
|
|
124
|
+
},
|
|
125
|
+
hard_break: {
|
|
126
|
+
inline: true,
|
|
127
|
+
group: 'inline',
|
|
128
|
+
selectable: false,
|
|
129
|
+
parseDOM: [{ tag: 'br' }],
|
|
130
|
+
toDOM: () => ['br'],
|
|
131
|
+
},
|
|
132
|
+
// No toolbar button inserts one, but stored content may contain images and
|
|
133
|
+
// dropping them on the next save would lose user data.
|
|
134
|
+
image: {
|
|
135
|
+
inline: true,
|
|
136
|
+
group: 'inline',
|
|
137
|
+
draggable: true,
|
|
138
|
+
attrs: {
|
|
139
|
+
src: {},
|
|
140
|
+
alt: { default: null },
|
|
141
|
+
title: { default: null },
|
|
142
|
+
width: { default: null },
|
|
143
|
+
},
|
|
144
|
+
parseDOM: [
|
|
145
|
+
{
|
|
146
|
+
tag: 'img[src]',
|
|
147
|
+
getAttrs: (dom) => ({
|
|
148
|
+
src: dom.getAttribute('src'),
|
|
149
|
+
alt: dom.getAttribute('alt'),
|
|
150
|
+
title: dom.getAttribute('title'),
|
|
151
|
+
width: dom.getAttribute('width'),
|
|
152
|
+
}),
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
toDOM: (node) => [
|
|
156
|
+
'img',
|
|
157
|
+
{
|
|
158
|
+
src: node.attrs['src'],
|
|
159
|
+
alt: node.attrs['alt'],
|
|
160
|
+
title: node.attrs['title'],
|
|
161
|
+
width: node.attrs['width'],
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
list_item: { ...listItem, content: 'paragraph block*' },
|
|
166
|
+
ordered_list: { ...orderedList, content: 'list_item+', group: 'block' },
|
|
167
|
+
bullet_list: { ...bulletList, content: 'list_item+', group: 'block' },
|
|
168
|
+
};
|
|
169
|
+
const marks = {
|
|
170
|
+
link: {
|
|
171
|
+
attrs: { href: {}, title: { default: null }, target: { default: '_blank' } },
|
|
172
|
+
inclusive: false,
|
|
173
|
+
parseDOM: [
|
|
174
|
+
{
|
|
175
|
+
tag: 'a[href]',
|
|
176
|
+
getAttrs: (dom) => ({
|
|
177
|
+
href: dom.getAttribute('href'),
|
|
178
|
+
title: dom.getAttribute('title'),
|
|
179
|
+
target: dom.getAttribute('target'),
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
toDOM: (mark) => [
|
|
184
|
+
'a',
|
|
185
|
+
{
|
|
186
|
+
href: mark.attrs['href'],
|
|
187
|
+
title: mark.attrs['title'],
|
|
188
|
+
target: mark.attrs['target'],
|
|
189
|
+
// Not parsed back, so it does not accumulate; opening a link in a new
|
|
190
|
+
// tab without it hands the target window a reference to ours.
|
|
191
|
+
rel: 'noopener',
|
|
192
|
+
},
|
|
193
|
+
0,
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
em: {
|
|
197
|
+
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
|
|
198
|
+
toDOM: () => ['em', 0],
|
|
199
|
+
},
|
|
200
|
+
strong: {
|
|
201
|
+
parseDOM: [
|
|
202
|
+
{ tag: 'strong' },
|
|
203
|
+
// Google Docs wraps pasted content in <b style="font-weight:normal">.
|
|
204
|
+
{ tag: 'b', getAttrs: (dom) => dom.style.fontWeight !== 'normal' && null },
|
|
205
|
+
{ style: 'font-weight', getAttrs: (value) => /^(?:bold(?:er)?|[5-9]\d{2,})$/.test(value) && null },
|
|
206
|
+
],
|
|
207
|
+
toDOM: () => ['strong', 0],
|
|
208
|
+
},
|
|
209
|
+
code: {
|
|
210
|
+
parseDOM: [{ tag: 'code' }],
|
|
211
|
+
toDOM: () => ['code', 0],
|
|
212
|
+
},
|
|
213
|
+
u: {
|
|
214
|
+
parseDOM: [{ tag: 'u' }, { style: 'text-decoration=underline', consuming: false }],
|
|
215
|
+
toDOM: () => ['u', 0],
|
|
216
|
+
},
|
|
217
|
+
s: {
|
|
218
|
+
parseDOM: [{ tag: 's' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }],
|
|
219
|
+
toDOM: () => ['s', 0],
|
|
220
|
+
},
|
|
221
|
+
text_color: {
|
|
222
|
+
attrs: { color: { default: null } },
|
|
223
|
+
parseDOM: [{ style: 'color', getAttrs: (value) => ({ color: value }) }],
|
|
224
|
+
toDOM: (mark) => ['span', { style: `color:${String(mark.attrs['color'])};` }, 0],
|
|
225
|
+
},
|
|
226
|
+
text_background_color: {
|
|
227
|
+
attrs: { backgroundColor: { default: null } },
|
|
228
|
+
parseDOM: [{ style: 'background-color', getAttrs: (value) => ({ backgroundColor: value }) }],
|
|
229
|
+
toDOM: (mark) => [
|
|
230
|
+
'span',
|
|
231
|
+
{ style: `background-color:${String(mark.attrs['backgroundColor'])};` },
|
|
232
|
+
0,
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
// Parse-only in practice: no toolbar button toggles them, but content saved
|
|
236
|
+
// by ngx-editor's superscript/subscript commands still round-trips.
|
|
237
|
+
sup: {
|
|
238
|
+
parseDOM: [{ tag: 'sup' }, { style: 'vertical-align=super' }],
|
|
239
|
+
toDOM: () => ['sup', 0],
|
|
240
|
+
},
|
|
241
|
+
sub: {
|
|
242
|
+
parseDOM: [{ tag: 'sub' }, { style: 'vertical-align=sub' }],
|
|
243
|
+
toDOM: () => ['sub', 0],
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
/** The schema itself — one instance, shared by every editor. */
|
|
247
|
+
const schema = new Schema({ nodes, marks });
|
|
248
|
+
/**
|
|
249
|
+
* Node types, resolved once and by name.
|
|
250
|
+
*
|
|
251
|
+
* `Schema.nodes` is an index signature, and this project turns on
|
|
252
|
+
* `noPropertyAccessFromIndexSignature`, so every lookup would otherwise need
|
|
253
|
+
* brackets at the call site.
|
|
254
|
+
*/
|
|
255
|
+
const nodeTypes = {
|
|
256
|
+
doc: schema.nodes['doc'],
|
|
257
|
+
paragraph: schema.nodes['paragraph'],
|
|
258
|
+
heading: schema.nodes['heading'],
|
|
259
|
+
blockquote: schema.nodes['blockquote'],
|
|
260
|
+
codeBlock: schema.nodes['code_block'],
|
|
261
|
+
bulletList: schema.nodes['bullet_list'],
|
|
262
|
+
orderedList: schema.nodes['ordered_list'],
|
|
263
|
+
listItem: schema.nodes['list_item'],
|
|
264
|
+
hardBreak: schema.nodes['hard_break'],
|
|
265
|
+
horizontalRule: schema.nodes['horizontal_rule'],
|
|
266
|
+
image: schema.nodes['image'],
|
|
267
|
+
};
|
|
268
|
+
/** Mark types, resolved once and by name. See {@link nodeTypes}. */
|
|
269
|
+
const markTypes = {
|
|
270
|
+
strong: schema.marks['strong'],
|
|
271
|
+
em: schema.marks['em'],
|
|
272
|
+
underline: schema.marks['u'],
|
|
273
|
+
strike: schema.marks['s'],
|
|
274
|
+
code: schema.marks['code'],
|
|
275
|
+
link: schema.marks['link'],
|
|
276
|
+
textColor: schema.marks['text_color'],
|
|
277
|
+
backgroundColor: schema.marks['text_background_color'],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* HTML in, HTML out. `richedit` stores its field values as HTML strings, so
|
|
282
|
+
* every document crosses this boundary twice per edit.
|
|
283
|
+
*
|
|
284
|
+
* Both directions go through a detached element, never through the live
|
|
285
|
+
* document, which keeps them usable under SSR shims and in jsdom specs.
|
|
286
|
+
*/
|
|
287
|
+
/** A detached container to parse into or serialize out of. */
|
|
288
|
+
function container() {
|
|
289
|
+
return document.createElement('div');
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Parse an HTML string into a document node.
|
|
293
|
+
*
|
|
294
|
+
* Anything the schema has no rule for is dropped, which is why the schema keeps
|
|
295
|
+
* parse rules for constructs the toolbar cannot produce.
|
|
296
|
+
*/
|
|
297
|
+
function fromHTML(html, schema$1 = schema) {
|
|
298
|
+
const element = container();
|
|
299
|
+
element.innerHTML = html;
|
|
300
|
+
return DOMParser.fromSchema(schema$1).parse(element);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Serialize a document node back to an HTML string.
|
|
304
|
+
*
|
|
305
|
+
* An empty document serializes to `''`, not to the `<p></p>` `ngx-editor`
|
|
306
|
+
* wrote. A field the user cleared should read as empty for the consumer — a
|
|
307
|
+
* paragraph containing nothing is truthy, and every `if (model.description)`
|
|
308
|
+
* built on it was quietly wrong.
|
|
309
|
+
*/
|
|
310
|
+
function toHTML(doc, schema$1 = schema) {
|
|
311
|
+
if (isEmpty(doc))
|
|
312
|
+
return '';
|
|
313
|
+
const element = container();
|
|
314
|
+
element.appendChild(DOMSerializer.fromSchema(schema$1).serializeFragment(doc.content));
|
|
315
|
+
return element.innerHTML;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether a document holds nothing but one empty textblock.
|
|
319
|
+
*
|
|
320
|
+
* Drives both the placeholder decoration and {@link toHTML}'s empty case. Note
|
|
321
|
+
* that an empty *heading* counts: the user typed `# ` and nothing else, so
|
|
322
|
+
* there is still no content to store.
|
|
323
|
+
*/
|
|
324
|
+
function isEmpty(doc) {
|
|
325
|
+
if (doc.childCount === 0)
|
|
326
|
+
return true;
|
|
327
|
+
const first = doc.firstChild;
|
|
328
|
+
return doc.childCount === 1 && !!first && first.isTextblock && first.content.size === 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Marks a transaction as coming from {@link Editor.setContent}. */
|
|
332
|
+
const SET_CONTENT = new PluginKey('richeditSetContent');
|
|
333
|
+
/** Shows `placeholder` over an otherwise empty document. */
|
|
334
|
+
function placeholderPlugin(placeholder) {
|
|
335
|
+
return new Plugin({
|
|
336
|
+
props: {
|
|
337
|
+
decorations: (state) => {
|
|
338
|
+
const first = state.doc.firstChild;
|
|
339
|
+
if (!first || !isEmpty(state.doc))
|
|
340
|
+
return null;
|
|
341
|
+
return DecorationSet.create(state.doc, [
|
|
342
|
+
Decoration.node(0, first.nodeSize, { class: 'is-empty', 'data-placeholder': placeholder }),
|
|
343
|
+
]);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/** The typing shortcuts: `# `, `> `, `- `, `1. ` and ``` ``` ```. */
|
|
349
|
+
function editorInputRules() {
|
|
350
|
+
return inputRules({
|
|
351
|
+
rules: [
|
|
352
|
+
wrappingInputRule(/^\s*>\s$/, nodeTypes.blockquote),
|
|
353
|
+
wrappingInputRule(/^\s*([-+*])\s$/, nodeTypes.bulletList),
|
|
354
|
+
wrappingInputRule(/^(\d+)\.\s$/, nodeTypes.orderedList, (match) => ({ order: Number(match[1]) }), (match, node) => node.childCount + node.attrs['order'] === Number(match[1])),
|
|
355
|
+
textblockTypeInputRule(/^```$/, nodeTypes.codeBlock),
|
|
356
|
+
textblockTypeInputRule(/^(#{1,6})\s$/, nodeTypes.heading, (match) => ({ level: match[1].length })),
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/** Keys the editor binds on top of ProseMirror's base map. */
|
|
361
|
+
function editorKeymap() {
|
|
362
|
+
const hardBreak = chainCommands(exitCode, (state, dispatch) => {
|
|
363
|
+
if (dispatch) {
|
|
364
|
+
dispatch(state.tr.replaceSelectionWith(nodeTypes.hardBreak.create()).scrollIntoView());
|
|
365
|
+
}
|
|
366
|
+
return true;
|
|
367
|
+
});
|
|
368
|
+
return keymap({
|
|
369
|
+
'Mod-z': undo,
|
|
370
|
+
'Mod-y': redo,
|
|
371
|
+
'Mod-Shift-z': redo,
|
|
372
|
+
'Mod-b': toggleMark(markTypes.strong),
|
|
373
|
+
'Mod-i': toggleMark(markTypes.em),
|
|
374
|
+
'Mod-u': toggleMark(markTypes.underline),
|
|
375
|
+
Enter: splitListItem(nodeTypes.listItem),
|
|
376
|
+
Tab: sinkListItem(nodeTypes.listItem),
|
|
377
|
+
'Shift-Tab': liftListItem(nodeTypes.listItem),
|
|
378
|
+
'Mod-Enter': hardBreak,
|
|
379
|
+
'Shift-Enter': hardBreak,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The rich text engine behind `<data-richedit>`.
|
|
384
|
+
*
|
|
385
|
+
* Owns a ProseMirror `EditorView` and everything plugged into it, and speaks
|
|
386
|
+
* HTML at its edges — which is what the field values are. It replaces
|
|
387
|
+
* `ngx-editor`'s class of the same name and keeps the same shape of
|
|
388
|
+
* responsibility, so the Angular components around it stayed thin.
|
|
389
|
+
*
|
|
390
|
+
* The view is created detached: the menu bar and the editor component both
|
|
391
|
+
* receive the `Editor` before there is anywhere to put its DOM, and the editor
|
|
392
|
+
* component adopts {@link dom} when it renders.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* const editor = new Editor({ content: '<p>hello</p>' });
|
|
397
|
+
* editor.onChange((html) => console.log(html));
|
|
398
|
+
* document.body.appendChild(editor.dom);
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
class Editor {
|
|
402
|
+
/** The underlying ProseMirror view. */
|
|
403
|
+
view;
|
|
404
|
+
changeListeners = new Set();
|
|
405
|
+
stateListeners = new Set();
|
|
406
|
+
/** Serialization of the current document — what {@link html} reports. */
|
|
407
|
+
_html;
|
|
408
|
+
/**
|
|
409
|
+
* The last string handed in from outside.
|
|
410
|
+
*
|
|
411
|
+
* Parsing normalises (`<b>` becomes `<strong>`, `text-align:center` gains a
|
|
412
|
+
* space), so the loaded string and the serialized one often differ while
|
|
413
|
+
* meaning the same document. Keeping both is what lets {@link setContent}
|
|
414
|
+
* recognise "you are giving me back what you already gave me".
|
|
415
|
+
*/
|
|
416
|
+
_loaded;
|
|
417
|
+
_editable;
|
|
418
|
+
constructor(options = {}) {
|
|
419
|
+
this._loaded = options.content ?? '';
|
|
420
|
+
this._editable = options.editable ?? true;
|
|
421
|
+
const plugins = [history(), editorKeymap(), keymap(baseKeymap), editorInputRules()];
|
|
422
|
+
if (options.placeholder) {
|
|
423
|
+
plugins.push(placeholderPlugin(options.placeholder));
|
|
424
|
+
}
|
|
425
|
+
const doc = fromHTML(this._loaded);
|
|
426
|
+
this._html = toHTML(doc);
|
|
427
|
+
this.view = new EditorView(null, {
|
|
428
|
+
state: EditorState.create({ doc, schema, plugins }),
|
|
429
|
+
editable: () => this._editable,
|
|
430
|
+
attributes: options.labelledBy ? { 'aria-labelledby': options.labelledBy } : {},
|
|
431
|
+
dispatchTransaction: (transaction) => {
|
|
432
|
+
this.view.updateState(this.view.state.apply(transaction));
|
|
433
|
+
if (transaction.docChanged) {
|
|
434
|
+
this._html = toHTML(this.view.state.doc);
|
|
435
|
+
// Content pushed in from outside must not be reported back as a user
|
|
436
|
+
// edit; that is what would mark a pristine form dirty on load.
|
|
437
|
+
if (!transaction.getMeta(SET_CONTENT)) {
|
|
438
|
+
for (const listener of this.changeListeners)
|
|
439
|
+
listener(this._html);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const listener of this.stateListeners)
|
|
443
|
+
listener(this.view.state);
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** The editor's DOM node, ready to be placed in the document. */
|
|
448
|
+
get dom() {
|
|
449
|
+
return this.view.dom;
|
|
450
|
+
}
|
|
451
|
+
/** Current editor state, for menu bar queries. */
|
|
452
|
+
get state() {
|
|
453
|
+
return this.view.state;
|
|
454
|
+
}
|
|
455
|
+
/** Current content, as an HTML string. */
|
|
456
|
+
get html() {
|
|
457
|
+
return this._html;
|
|
458
|
+
}
|
|
459
|
+
/** Whether the document accepts edits. */
|
|
460
|
+
get editable() {
|
|
461
|
+
return this._editable;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Replace the content.
|
|
465
|
+
*
|
|
466
|
+
* A no-op when the HTML is the one already loaded, which is what keeps a
|
|
467
|
+
* form control writing its own value back from resetting the cursor on every
|
|
468
|
+
* keystroke. The replacement is kept out of the undo history — undoing back
|
|
469
|
+
* past a programmatic load is not something a user ever means.
|
|
470
|
+
*/
|
|
471
|
+
setContent(html) {
|
|
472
|
+
if (html === this._html || html === this._loaded)
|
|
473
|
+
return;
|
|
474
|
+
this._loaded = html;
|
|
475
|
+
const doc = fromHTML(html);
|
|
476
|
+
const transaction = this.view.state.tr
|
|
477
|
+
.replaceWith(0, this.view.state.doc.content.size, doc.content)
|
|
478
|
+
.setMeta(SET_CONTENT, true)
|
|
479
|
+
.setMeta('addToHistory', false);
|
|
480
|
+
this.view.dispatch(transaction);
|
|
481
|
+
}
|
|
482
|
+
/** Enable or disable editing. */
|
|
483
|
+
setEditable(editable) {
|
|
484
|
+
if (this._editable === editable)
|
|
485
|
+
return;
|
|
486
|
+
this._editable = editable;
|
|
487
|
+
// Re-runs the `editable` prop and updates contenteditable on the DOM node.
|
|
488
|
+
this.view.setProps({});
|
|
489
|
+
}
|
|
490
|
+
/** Run a command against the current state. Returns whether it applied. */
|
|
491
|
+
exec(command) {
|
|
492
|
+
const applied = command(this.view.state, this.view.dispatch.bind(this.view));
|
|
493
|
+
this.view.focus();
|
|
494
|
+
return applied;
|
|
495
|
+
}
|
|
496
|
+
/** Put the caret back in the document. */
|
|
497
|
+
focus() {
|
|
498
|
+
this.view.focus();
|
|
499
|
+
}
|
|
500
|
+
/** Listen to content changes. Returns a function that stops listening. */
|
|
501
|
+
onChange(listener) {
|
|
502
|
+
this.changeListeners.add(listener);
|
|
503
|
+
return () => this.changeListeners.delete(listener);
|
|
504
|
+
}
|
|
505
|
+
/** Listen to state changes, including selection. Returns an unsubscribe. */
|
|
506
|
+
onStateChange(listener) {
|
|
507
|
+
this.stateListeners.add(listener);
|
|
508
|
+
return () => this.stateListeners.delete(listener);
|
|
509
|
+
}
|
|
510
|
+
/** Tear the view down and drop every listener. */
|
|
511
|
+
destroy() {
|
|
512
|
+
this.changeListeners.clear();
|
|
513
|
+
this.stateListeners.clear();
|
|
514
|
+
this.view.destroy();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The editable region of `<data-richedit>`: a `ControlValueAccessor` wrapped
|
|
520
|
+
* around a vendored {@link Editor}.
|
|
521
|
+
*
|
|
522
|
+
* Internal to the entry point — it replaces `<ngx-editor>` and keeps its
|
|
523
|
+
* contract, so `richedit`'s template still binds a `[formControl]` to it and
|
|
524
|
+
* the form machinery is none the wiser.
|
|
525
|
+
*
|
|
526
|
+
* The editor's DOM is built detached by the {@link Editor} itself and adopted
|
|
527
|
+
* here once there is a host element, which keeps construction free of layout
|
|
528
|
+
* and safe under SSR.
|
|
529
|
+
*/
|
|
530
|
+
class RicheditEditorComponent {
|
|
531
|
+
/** The engine to display. Owned by the parent, which also destroys it. */
|
|
532
|
+
editor = input.required(/* @ts-ignore */
|
|
533
|
+
...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
|
|
534
|
+
/** Id put on the wrapper, so a label can point at it. */
|
|
535
|
+
inputId = input(/* @ts-ignore */
|
|
536
|
+
...(ngDevMode ? [undefined, { debugName: "inputId" }] : /* istanbul ignore next */ []));
|
|
537
|
+
host = viewChild.required('host', /* @ts-ignore */
|
|
538
|
+
...(ngDevMode ? [{ debugName: "host" }] : /* istanbul ignore next */ []));
|
|
539
|
+
destroyRef = inject(DestroyRef);
|
|
540
|
+
onChange = () => {
|
|
541
|
+
// Replaced by registerOnChange when used inside a form.
|
|
542
|
+
};
|
|
543
|
+
onTouched = () => {
|
|
544
|
+
// Replaced by registerOnTouched when used inside a form.
|
|
545
|
+
};
|
|
546
|
+
constructor() {
|
|
547
|
+
afterNextRender(() => {
|
|
548
|
+
this.host().nativeElement.appendChild(this.editor().dom);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
ngOnInit() {
|
|
552
|
+
const stop = this.editor().onChange((html) => {
|
|
553
|
+
this.onTouched();
|
|
554
|
+
this.onChange(html);
|
|
555
|
+
});
|
|
556
|
+
this.destroyRef.onDestroy(stop);
|
|
557
|
+
}
|
|
558
|
+
/** Load a value coming from the form into the editor. */
|
|
559
|
+
writeValue(html) {
|
|
560
|
+
this.editor().setContent(html ?? '');
|
|
561
|
+
}
|
|
562
|
+
/** @param onChange callback the form supplies to hear about edits */
|
|
563
|
+
registerOnChange(onChange) {
|
|
564
|
+
this.onChange = onChange;
|
|
565
|
+
}
|
|
566
|
+
/** @param onTouched callback the form supplies to hear about first contact */
|
|
567
|
+
registerOnTouched(onTouched) {
|
|
568
|
+
this.onTouched = onTouched;
|
|
569
|
+
}
|
|
570
|
+
/** Disabling the control makes the document read-only. */
|
|
571
|
+
setDisabledState(disabled) {
|
|
572
|
+
this.editor().setEditable(!disabled);
|
|
573
|
+
}
|
|
574
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
575
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.1.3", type: RicheditEditorComponent, isStandalone: true, selector: "data-richedit-editor", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: RicheditEditorComponent }], viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: '<div #host class="data-richedit__content" [attr.id]="inputId()"></div>', isInline: true, styles: [":host{display:block}:host ::ng-deep .ProseMirror{outline:none;min-height:6rem;padding:.5rem .75rem;white-space:pre-wrap;word-wrap:break-word}:host ::ng-deep .ProseMirror>*:first-child{margin-top:0}:host ::ng-deep .ProseMirror>*:last-child{margin-bottom:0}:host ::ng-deep .ProseMirror .is-empty:before{content:attr(data-placeholder);float:left;height:0;pointer-events:none;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .ProseMirror blockquote{border-left:3px solid var(--bs-border-color, #dee2e6);padding-left:.75rem;margin-left:0;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .ProseMirror pre{background:var(--bs-tertiary-bg, rgba(0, 0, 0, .05));border-radius:var(--bs-border-radius-sm, .25rem);padding:.5rem .75rem}:host ::ng-deep .ProseMirror hr{border-top:1px solid var(--bs-border-color, #dee2e6)}:host ::ng-deep .ProseMirror .ProseMirror-selectednode{outline:2px solid var(--bs-primary, #0d6efd)}\n"] });
|
|
576
|
+
}
|
|
577
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditEditorComponent, decorators: [{
|
|
578
|
+
type: Component,
|
|
579
|
+
args: [{ selector: 'data-richedit-editor', template: '<div #host class="data-richedit__content" [attr.id]="inputId()"></div>', providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: RicheditEditorComponent }], styles: [":host{display:block}:host ::ng-deep .ProseMirror{outline:none;min-height:6rem;padding:.5rem .75rem;white-space:pre-wrap;word-wrap:break-word}:host ::ng-deep .ProseMirror>*:first-child{margin-top:0}:host ::ng-deep .ProseMirror>*:last-child{margin-bottom:0}:host ::ng-deep .ProseMirror .is-empty:before{content:attr(data-placeholder);float:left;height:0;pointer-events:none;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .ProseMirror blockquote{border-left:3px solid var(--bs-border-color, #dee2e6);padding-left:.75rem;margin-left:0;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .ProseMirror pre{background:var(--bs-tertiary-bg, rgba(0, 0, 0, .05));border-radius:var(--bs-border-radius-sm, .25rem);padding:.5rem .75rem}:host ::ng-deep .ProseMirror hr{border-top:1px solid var(--bs-border-color, #dee2e6)}:host ::ng-deep .ProseMirror .ProseMirror-selectednode{outline:2px solid var(--bs-primary, #0d6efd)}\n"] }]
|
|
580
|
+
}], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }], host: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Editing commands for the `richedit` schema, plus the state queries the menu
|
|
584
|
+
* bar needs to render a button pressed or not.
|
|
585
|
+
*
|
|
586
|
+
* Everything here is a plain ProseMirror `Command` — `(state, dispatch?) =>
|
|
587
|
+
* boolean` — so it can be bound to a key, called from a button, or asserted on
|
|
588
|
+
* in a spec by dispatching into a bare `EditorState`.
|
|
589
|
+
*/
|
|
590
|
+
/** Whether every attribute in `expected` matches the node's. */
|
|
591
|
+
function attrsMatch(actual, expected) {
|
|
592
|
+
return Object.keys(expected).every((key) => actual[key] === expected[key]);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Whether the mark is on the whole selection — or, with the cursor collapsed,
|
|
596
|
+
* whether the next character typed would carry it.
|
|
597
|
+
*/
|
|
598
|
+
function isMarkActive(state, type) {
|
|
599
|
+
const { from, to, empty, $from } = state.selection;
|
|
600
|
+
if (empty) {
|
|
601
|
+
return !!type.isInSet(state.storedMarks ?? $from.marks());
|
|
602
|
+
}
|
|
603
|
+
return state.doc.rangeHasMark(from, to, type);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Whether a node of that type (with those attributes, if given) contains or
|
|
607
|
+
* intersects the selection.
|
|
608
|
+
*
|
|
609
|
+
* `nodesBetween` walks down from the document, so this reports ancestors too —
|
|
610
|
+
* which is what makes it work for blockquotes and lists, where the cursor sits
|
|
611
|
+
* in a paragraph nested inside the node being asked about.
|
|
612
|
+
*/
|
|
613
|
+
function isNodeActive(state, type, attrs = {}) {
|
|
614
|
+
const { from, to } = state.selection;
|
|
615
|
+
let active = false;
|
|
616
|
+
state.doc.nodesBetween(from, to, (node) => {
|
|
617
|
+
if (node.type === type && attrsMatch(node.attrs, attrs)) {
|
|
618
|
+
active = true;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
return active;
|
|
622
|
+
}
|
|
623
|
+
/** Toggle a mark over the selection. */
|
|
624
|
+
function toggleMarkCommand(type) {
|
|
625
|
+
return toggleMark(type);
|
|
626
|
+
}
|
|
627
|
+
/** Toggle `<strong>` over the selection. */
|
|
628
|
+
const toggleBold = toggleMark(markTypes.strong);
|
|
629
|
+
/** Toggle `<em>` over the selection. */
|
|
630
|
+
const toggleItalic = toggleMark(markTypes.em);
|
|
631
|
+
/** Toggle `<u>` over the selection. */
|
|
632
|
+
const toggleUnderline = toggleMark(markTypes.underline);
|
|
633
|
+
/** Toggle `<s>` over the selection. */
|
|
634
|
+
const toggleStrike = toggleMark(markTypes.strike);
|
|
635
|
+
/** Toggle `<code>` over the selection. */
|
|
636
|
+
const toggleCode = toggleMark(markTypes.code);
|
|
637
|
+
/**
|
|
638
|
+
* Make the selected blocks headings of that level.
|
|
639
|
+
*
|
|
640
|
+
* This is what the menu bar's dropdown uses: picking a level in a list should
|
|
641
|
+
* put you at that level, not toggle you out of it.
|
|
642
|
+
*/
|
|
643
|
+
function setHeading(level) {
|
|
644
|
+
return setBlockType(nodeTypes.heading, { level });
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Switch the selected blocks between a heading of that level and a paragraph.
|
|
648
|
+
*
|
|
649
|
+
* Unlike {@link setHeading}, picking the level already in force turns it back
|
|
650
|
+
* into a paragraph — the behaviour a toggle button wants.
|
|
651
|
+
*/
|
|
652
|
+
function toggleHeading(level) {
|
|
653
|
+
return (state, dispatch) => isNodeActive(state, nodeTypes.heading, { level })
|
|
654
|
+
? setBlockType(nodeTypes.paragraph)(state, dispatch)
|
|
655
|
+
: setHeading(level)(state, dispatch);
|
|
656
|
+
}
|
|
657
|
+
/** Turn the selected blocks into paragraphs. */
|
|
658
|
+
const setParagraph = setBlockType(nodeTypes.paragraph);
|
|
659
|
+
/** Toggle a code block over the selected blocks. */
|
|
660
|
+
const toggleCodeBlock = (state, dispatch) => isNodeActive(state, nodeTypes.codeBlock)
|
|
661
|
+
? setBlockType(nodeTypes.paragraph)(state, dispatch)
|
|
662
|
+
: setBlockType(nodeTypes.codeBlock)(state, dispatch);
|
|
663
|
+
/** Wrap the selection in a blockquote, or lift it back out. */
|
|
664
|
+
const toggleBlockquote = (state, dispatch) => isNodeActive(state, nodeTypes.blockquote) ? lift(state, dispatch) : wrapIn(nodeTypes.blockquote)(state, dispatch);
|
|
665
|
+
/** Wrap the selection in a list of that type, or lift it back out. */
|
|
666
|
+
function toggleList(type) {
|
|
667
|
+
return (state, dispatch) => isNodeActive(state, type) ? liftListItem(nodeTypes.listItem)(state, dispatch) : wrapInList(type)(state, dispatch);
|
|
668
|
+
}
|
|
669
|
+
/** Toggle a bullet list around the selection. */
|
|
670
|
+
const toggleBulletList = toggleList(nodeTypes.bulletList);
|
|
671
|
+
/** Toggle an ordered list around the selection. */
|
|
672
|
+
const toggleOrderedList = toggleList(nodeTypes.orderedList);
|
|
673
|
+
/**
|
|
674
|
+
* Set the text alignment of every paragraph and heading in the selection.
|
|
675
|
+
*
|
|
676
|
+
* Alignment lives on the block as an attribute and serializes to
|
|
677
|
+
* `style="text-align:…"`, the way `ngx-editor` stored it. `null` clears it.
|
|
678
|
+
*/
|
|
679
|
+
function setAlign(align) {
|
|
680
|
+
return (state, dispatch) => {
|
|
681
|
+
const { from, to } = state.selection;
|
|
682
|
+
const transaction = state.tr;
|
|
683
|
+
let applicable = false;
|
|
684
|
+
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
685
|
+
if (node.type !== nodeTypes.paragraph && node.type !== nodeTypes.heading)
|
|
686
|
+
return;
|
|
687
|
+
applicable = true;
|
|
688
|
+
// setNodeMarkup keeps the node's size, so positions collected during the
|
|
689
|
+
// walk stay valid as the transaction grows.
|
|
690
|
+
transaction.setNodeMarkup(pos, undefined, { ...node.attrs, align });
|
|
691
|
+
});
|
|
692
|
+
if (!applicable)
|
|
693
|
+
return false;
|
|
694
|
+
if (dispatch)
|
|
695
|
+
dispatch(transaction.scrollIntoView());
|
|
696
|
+
return true;
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/** The alignment in force on the block holding the cursor, if any. */
|
|
700
|
+
function activeAlignment(state) {
|
|
701
|
+
const parent = state.selection.$from.parent;
|
|
702
|
+
if (parent.type !== nodeTypes.paragraph && parent.type !== nodeTypes.heading)
|
|
703
|
+
return null;
|
|
704
|
+
return parent.attrs['align'] ?? null;
|
|
705
|
+
}
|
|
706
|
+
/** The heading level in force, or 0 outside a heading. */
|
|
707
|
+
function activeHeading(state) {
|
|
708
|
+
for (let level = 1; level <= 6; level++) {
|
|
709
|
+
if (isNodeActive(state, nodeTypes.heading, { level }))
|
|
710
|
+
return level;
|
|
711
|
+
}
|
|
712
|
+
return 0;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Apply a colour mark to the selection, replacing any colour already there.
|
|
716
|
+
*
|
|
717
|
+
* With the cursor collapsed the mark is stored instead, so it applies to what
|
|
718
|
+
* the user types next — the same behaviour as bold on an empty selection.
|
|
719
|
+
*/
|
|
720
|
+
function setColor(type, attrs) {
|
|
721
|
+
return (state, dispatch) => {
|
|
722
|
+
const { from, to, empty } = state.selection;
|
|
723
|
+
if (empty) {
|
|
724
|
+
if (dispatch)
|
|
725
|
+
dispatch(state.tr.addStoredMark(type.create(attrs)));
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
if (dispatch) {
|
|
729
|
+
dispatch(state.tr.removeMark(from, to, type).addMark(from, to, type.create(attrs)).scrollIntoView());
|
|
730
|
+
}
|
|
731
|
+
return true;
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/** Drop a colour mark from the selection. */
|
|
735
|
+
function removeColor(type) {
|
|
736
|
+
return (state, dispatch) => {
|
|
737
|
+
const { from, to, empty } = state.selection;
|
|
738
|
+
if (empty) {
|
|
739
|
+
if (dispatch)
|
|
740
|
+
dispatch(state.tr.removeStoredMark(type));
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
if (dispatch)
|
|
744
|
+
dispatch(state.tr.removeMark(from, to, type).scrollIntoView());
|
|
745
|
+
return true;
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** The colour currently carried by the selection, for the colour pickers. */
|
|
749
|
+
function activeColor(state, type, attr) {
|
|
750
|
+
const { $from, empty } = state.selection;
|
|
751
|
+
const marks = empty ? (state.storedMarks ?? $from.marks()) : ($from.nodeAfter?.marks ?? $from.marks());
|
|
752
|
+
const mark = marks.find((candidate) => candidate.type === type);
|
|
753
|
+
return mark ? (mark.attrs[attr] ?? null) : null;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The span of the link under the cursor, or the selection when it is not
|
|
757
|
+
* collapsed.
|
|
758
|
+
*
|
|
759
|
+
* Walks outwards from the cursor over the sibling text nodes that carry the
|
|
760
|
+
* mark, so "remove link" works with the caret merely inside the link rather
|
|
761
|
+
* than with the whole thing selected.
|
|
762
|
+
*/
|
|
763
|
+
function linkRange(state) {
|
|
764
|
+
const { $from, from, to, empty } = state.selection;
|
|
765
|
+
if (!empty)
|
|
766
|
+
return { from, to };
|
|
767
|
+
const type = markTypes.link;
|
|
768
|
+
if (!type.isInSet($from.marks()))
|
|
769
|
+
return null;
|
|
770
|
+
const parent = $from.parent;
|
|
771
|
+
const start = parent.childAfter($from.parentOffset);
|
|
772
|
+
if (!start.node)
|
|
773
|
+
return null;
|
|
774
|
+
let index = start.index;
|
|
775
|
+
let startPos = $from.start() + start.offset;
|
|
776
|
+
while (index > 0 && type.isInSet(parent.child(index - 1).marks)) {
|
|
777
|
+
index--;
|
|
778
|
+
startPos -= parent.child(index).nodeSize;
|
|
779
|
+
}
|
|
780
|
+
index = start.index;
|
|
781
|
+
let endPos = $from.start() + start.offset + start.node.nodeSize;
|
|
782
|
+
while (index + 1 < parent.childCount && type.isInSet(parent.child(index + 1).marks)) {
|
|
783
|
+
index++;
|
|
784
|
+
endPos += parent.child(index).nodeSize;
|
|
785
|
+
}
|
|
786
|
+
return { from: startPos, to: endPos };
|
|
787
|
+
}
|
|
788
|
+
/** The href of the link under the cursor, for prefilling the link form. */
|
|
789
|
+
function activeLink(state) {
|
|
790
|
+
const mark = state.selection.$from.marks().find((candidate) => candidate.type === markTypes.link);
|
|
791
|
+
return mark ? (mark.attrs['href'] ?? null) : null;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Link the selection.
|
|
795
|
+
*
|
|
796
|
+
* Takes the href as an argument rather than prompting, so the command stays
|
|
797
|
+
* free of DOM and the menu bar owns the form. With the caret inside an existing
|
|
798
|
+
* link and nothing selected, that link's whole span is re-linked.
|
|
799
|
+
*/
|
|
800
|
+
function addLink(href, title = null) {
|
|
801
|
+
return (state, dispatch) => {
|
|
802
|
+
const range = linkRange(state);
|
|
803
|
+
if (!range || range.from === range.to)
|
|
804
|
+
return false;
|
|
805
|
+
if (dispatch) {
|
|
806
|
+
const mark = markTypes.link.create({ href, title, target: '_blank' });
|
|
807
|
+
dispatch(state.tr.removeMark(range.from, range.to, markTypes.link).addMark(range.from, range.to, mark).scrollIntoView());
|
|
808
|
+
}
|
|
809
|
+
return true;
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/** Unlink the link under the cursor, or the selection. */
|
|
813
|
+
const removeLink = (state, dispatch) => {
|
|
814
|
+
const range = linkRange(state);
|
|
815
|
+
if (!range || range.from === range.to)
|
|
816
|
+
return false;
|
|
817
|
+
if (dispatch)
|
|
818
|
+
dispatch(state.tr.removeMark(range.from, range.to, markTypes.link).scrollIntoView());
|
|
819
|
+
return true;
|
|
820
|
+
};
|
|
821
|
+
/** Insert a horizontal rule at the selection. */
|
|
822
|
+
const insertHorizontalRule = (state, dispatch) => {
|
|
823
|
+
if (dispatch)
|
|
824
|
+
dispatch(state.tr.replaceSelectionWith(nodeTypes.horizontalRule.create()).scrollIntoView());
|
|
825
|
+
return true;
|
|
826
|
+
};
|
|
827
|
+
/** Drop every mark from the selection and put its blocks back to paragraphs. */
|
|
828
|
+
const clearFormat = (state, dispatch) => {
|
|
829
|
+
const { from, to, empty } = state.selection;
|
|
830
|
+
if (empty)
|
|
831
|
+
return false;
|
|
832
|
+
if (dispatch) {
|
|
833
|
+
const transaction = state.tr;
|
|
834
|
+
for (const type of Object.values(schema.marks)) {
|
|
835
|
+
transaction.removeMark(from, to, type);
|
|
836
|
+
}
|
|
837
|
+
dispatch(transaction.scrollIntoView());
|
|
838
|
+
}
|
|
839
|
+
return true;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/** Button definitions, by toolbar item name. */
|
|
843
|
+
const BUTTONS = {
|
|
844
|
+
bold: { icon: 'bi-type-bold', label: 'Gras', command: toggleBold, active: (s) => isMarkActive(s, markTypes.strong) },
|
|
845
|
+
italic: {
|
|
846
|
+
icon: 'bi-type-italic',
|
|
847
|
+
label: 'Italique',
|
|
848
|
+
command: toggleItalic,
|
|
849
|
+
active: (s) => isMarkActive(s, markTypes.em),
|
|
850
|
+
},
|
|
851
|
+
underline: {
|
|
852
|
+
icon: 'bi-type-underline',
|
|
853
|
+
label: 'Souligné',
|
|
854
|
+
command: toggleUnderline,
|
|
855
|
+
active: (s) => isMarkActive(s, markTypes.underline),
|
|
856
|
+
},
|
|
857
|
+
strike: {
|
|
858
|
+
icon: 'bi-type-strikethrough',
|
|
859
|
+
label: 'Barré',
|
|
860
|
+
command: toggleStrike,
|
|
861
|
+
active: (s) => isMarkActive(s, markTypes.strike),
|
|
862
|
+
},
|
|
863
|
+
code: { icon: 'bi-code', label: 'Code', command: toggleCode, active: (s) => isMarkActive(s, markTypes.code) },
|
|
864
|
+
blockquote: {
|
|
865
|
+
icon: 'bi-blockquote-left',
|
|
866
|
+
label: 'Citation',
|
|
867
|
+
command: toggleBlockquote,
|
|
868
|
+
active: (s) => isNodeActive(s, nodeTypes.blockquote),
|
|
869
|
+
},
|
|
870
|
+
bullet_list: {
|
|
871
|
+
icon: 'bi-list-ul',
|
|
872
|
+
label: 'Liste à puces',
|
|
873
|
+
command: toggleBulletList,
|
|
874
|
+
active: (s) => isNodeActive(s, nodeTypes.bulletList),
|
|
875
|
+
},
|
|
876
|
+
ordered_list: {
|
|
877
|
+
icon: 'bi-list-ol',
|
|
878
|
+
label: 'Liste numérotée',
|
|
879
|
+
command: toggleOrderedList,
|
|
880
|
+
active: (s) => isNodeActive(s, nodeTypes.orderedList),
|
|
881
|
+
},
|
|
882
|
+
align_left: { icon: 'bi-text-left', label: 'Aligner à gauche', ...alignEntry('left') },
|
|
883
|
+
align_center: { icon: 'bi-text-center', label: 'Centrer', ...alignEntry('center') },
|
|
884
|
+
align_right: { icon: 'bi-text-right', label: 'Aligner à droite', ...alignEntry('right') },
|
|
885
|
+
align_justify: { icon: 'bi-justify', label: 'Justifier', ...alignEntry('justify') },
|
|
886
|
+
horizontal_rule: {
|
|
887
|
+
icon: 'bi-hr',
|
|
888
|
+
label: 'Ligne horizontale',
|
|
889
|
+
command: insertHorizontalRule,
|
|
890
|
+
active: () => false,
|
|
891
|
+
},
|
|
892
|
+
format_clear: { icon: 'bi-eraser', label: 'Effacer la mise en forme', command: clearFormat, active: () => false },
|
|
893
|
+
undo: { icon: 'bi-arrow-counterclockwise', label: 'Annuler', command: undo, active: () => false },
|
|
894
|
+
redo: { icon: 'bi-arrow-clockwise', label: 'Rétablir', command: redo, active: () => false },
|
|
895
|
+
};
|
|
896
|
+
/** The command and active check for an alignment button. */
|
|
897
|
+
function alignEntry(align) {
|
|
898
|
+
return {
|
|
899
|
+
command: setAlign(align),
|
|
900
|
+
active: (state) => activeAlignment(state) === align,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Colour pickers, by toolbar item name.
|
|
905
|
+
*
|
|
906
|
+
* Each renders as an icon over a swatch rather than as a bare `<input
|
|
907
|
+
* type="color">`: on its own that input is an opaque coloured square, and two
|
|
908
|
+
* of them side by side say nothing about which is text and which is
|
|
909
|
+
* background.
|
|
910
|
+
*/
|
|
911
|
+
const COLORS = {
|
|
912
|
+
text_color: { icon: 'bi-fonts', label: 'Couleur du texte', mark: markTypes.textColor, attr: 'color' },
|
|
913
|
+
background_color: {
|
|
914
|
+
icon: 'bi-highlighter',
|
|
915
|
+
label: 'Couleur de fond',
|
|
916
|
+
mark: markTypes.backgroundColor,
|
|
917
|
+
attr: 'backgroundColor',
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
/** Items accepted by the configuration but not implemented here. */
|
|
921
|
+
const UNSUPPORTED = new Set(['image', 'indent', 'outdent', 'superscript', 'subscript']);
|
|
922
|
+
/** `rgb(255, 0, 0)` or `#f00` as the `#rrggbb` an `<input type="color">` wants. */
|
|
923
|
+
function toHexColor(value) {
|
|
924
|
+
if (!value)
|
|
925
|
+
return '#000000';
|
|
926
|
+
const rgb = /^rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(value);
|
|
927
|
+
if (rgb) {
|
|
928
|
+
return `#${[1, 2, 3].map((i) => Number(rgb[i]).toString(16).padStart(2, '0')).join('')}`;
|
|
929
|
+
}
|
|
930
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(value);
|
|
931
|
+
if (short) {
|
|
932
|
+
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`.toLowerCase();
|
|
933
|
+
}
|
|
934
|
+
return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : '#000000';
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The menu bar of `<data-richedit>`.
|
|
938
|
+
*
|
|
939
|
+
* Internal to the entry point — it replaces `<ngx-editor-menu>` and reads the
|
|
940
|
+
* same `Toolbar` configuration, so presets and hand-written toolbars carry
|
|
941
|
+
* over. Rendering is Bootstrap button groups with `bootstrap-icons`; colours
|
|
942
|
+
* use a native `<input type="color">` and links a small inline form, which is
|
|
943
|
+
* what lets this drop the floating-ui dependency the old menu needed.
|
|
944
|
+
*/
|
|
945
|
+
class RicheditMenubarComponent {
|
|
946
|
+
/** The engine the buttons act on. */
|
|
947
|
+
editor = input.required(/* @ts-ignore */
|
|
948
|
+
...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
|
|
949
|
+
/** Toolbar configuration; unknown items are skipped with a warning. */
|
|
950
|
+
toolbar = input([], /* @ts-ignore */
|
|
951
|
+
...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
|
|
952
|
+
/** Latest editor state, so buttons can render themselves pressed. */
|
|
953
|
+
editorState = signal(undefined, /* @ts-ignore */
|
|
954
|
+
...(ngDevMode ? [{ debugName: "editorState" }] : /* istanbul ignore next */ []));
|
|
955
|
+
/** Whether the inline link form is open. */
|
|
956
|
+
linkOpen = signal(false, /* @ts-ignore */
|
|
957
|
+
...(ngDevMode ? [{ debugName: "linkOpen" }] : /* istanbul ignore next */ []));
|
|
958
|
+
destroyRef = inject(DestroyRef);
|
|
959
|
+
/** The toolbar resolved into entries the template can render. */
|
|
960
|
+
groups = computed(() => {
|
|
961
|
+
const skipped = [];
|
|
962
|
+
const groups = this.toolbar().map((group) => group.map((item) => this.resolve(item, skipped)).filter((entry) => entry !== null));
|
|
963
|
+
if (skipped.length) {
|
|
964
|
+
console.warn(`richedit: unsupported toolbar item(s) ignored: ${skipped.join(', ')}`);
|
|
965
|
+
}
|
|
966
|
+
return groups.filter((group) => group.length > 0);
|
|
967
|
+
}, /* @ts-ignore */
|
|
968
|
+
...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
|
|
969
|
+
/** Href of the link under the cursor, prefilled into the link form. */
|
|
970
|
+
linkHref = computed(() => {
|
|
971
|
+
const state = this.editorState();
|
|
972
|
+
return state ? (activeLink(state) ?? '') : '';
|
|
973
|
+
}, /* @ts-ignore */
|
|
974
|
+
...(ngDevMode ? [{ debugName: "linkHref" }] : /* istanbul ignore next */ []));
|
|
975
|
+
ngOnInit() {
|
|
976
|
+
this.editorState.set(this.editor().state);
|
|
977
|
+
const stop = this.editor().onStateChange((state) => this.editorState.set(state));
|
|
978
|
+
this.destroyRef.onDestroy(stop);
|
|
979
|
+
}
|
|
980
|
+
/** Whether a toggle button should render pressed. */
|
|
981
|
+
isActive(entry) {
|
|
982
|
+
const state = this.editorState();
|
|
983
|
+
return state ? entry.active(state) : false;
|
|
984
|
+
}
|
|
985
|
+
/** Run a button's command. */
|
|
986
|
+
run(entry) {
|
|
987
|
+
this.editor().exec(entry.command);
|
|
988
|
+
}
|
|
989
|
+
/** Heading level in force, as the string the dropdown binds to. */
|
|
990
|
+
currentHeading() {
|
|
991
|
+
const state = this.editorState();
|
|
992
|
+
return state ? activeHeading(state) : 0;
|
|
993
|
+
}
|
|
994
|
+
/** Apply the level picked in the heading dropdown. */
|
|
995
|
+
applyHeading(value) {
|
|
996
|
+
const level = Number(value);
|
|
997
|
+
this.editor().exec(level === 0 ? setParagraph : setHeading(level));
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* The colour actually in force, or null when the selection carries none.
|
|
1001
|
+
*
|
|
1002
|
+
* Distinct from {@link currentColor} on purpose: the swatch has to be able to
|
|
1003
|
+
* show "no colour", while the native picker insists on a real value.
|
|
1004
|
+
*/
|
|
1005
|
+
swatchColor(entry) {
|
|
1006
|
+
const state = this.editorState();
|
|
1007
|
+
return state ? activeColor(state, entry.mark, entry.attr) : null;
|
|
1008
|
+
}
|
|
1009
|
+
/** Current value of a colour picker, as the `#rrggbb` the input requires. */
|
|
1010
|
+
currentColor(entry) {
|
|
1011
|
+
return toHexColor(this.swatchColor(entry));
|
|
1012
|
+
}
|
|
1013
|
+
/** Apply a colour picked in one of the pickers. */
|
|
1014
|
+
applyColor(entry, value) {
|
|
1015
|
+
this.editor().exec(setColor(entry.mark, { [entry.attr]: value }));
|
|
1016
|
+
}
|
|
1017
|
+
/** Drop the colour a picker controls. */
|
|
1018
|
+
clearColor(entry) {
|
|
1019
|
+
this.editor().exec(removeColor(entry.mark));
|
|
1020
|
+
}
|
|
1021
|
+
/** Open or close the inline link form. */
|
|
1022
|
+
toggleLinkForm() {
|
|
1023
|
+
this.linkOpen.update((open) => !open);
|
|
1024
|
+
}
|
|
1025
|
+
/** Link the selection to `href`, then close the form. */
|
|
1026
|
+
applyLink(href) {
|
|
1027
|
+
if (href) {
|
|
1028
|
+
this.editor().exec(addLink(href));
|
|
1029
|
+
}
|
|
1030
|
+
this.linkOpen.set(false);
|
|
1031
|
+
}
|
|
1032
|
+
/** Unlink, then close the form. */
|
|
1033
|
+
clearLink() {
|
|
1034
|
+
this.editor().exec(removeLink);
|
|
1035
|
+
this.linkOpen.set(false);
|
|
1036
|
+
}
|
|
1037
|
+
/** Whether the cursor sits in a link, for the button's pressed state. */
|
|
1038
|
+
linkActive() {
|
|
1039
|
+
const state = this.editorState();
|
|
1040
|
+
return state ? isMarkActive(state, markTypes.link) : false;
|
|
1041
|
+
}
|
|
1042
|
+
/** Turn one configuration item into a renderable entry, or skip it. */
|
|
1043
|
+
resolve(item, skipped) {
|
|
1044
|
+
if (typeof item !== 'string') {
|
|
1045
|
+
const levels = item.heading;
|
|
1046
|
+
if (!levels?.length)
|
|
1047
|
+
return null;
|
|
1048
|
+
return {
|
|
1049
|
+
kind: 'heading',
|
|
1050
|
+
key: 'heading',
|
|
1051
|
+
label: 'Niveau de titre',
|
|
1052
|
+
levels: levels.map((level) => Number(level.slice(1))),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
if (item === 'link') {
|
|
1056
|
+
return { kind: 'link', key: 'link', icon: 'bi-link-45deg', label: 'Lien' };
|
|
1057
|
+
}
|
|
1058
|
+
const color = COLORS[item];
|
|
1059
|
+
if (color) {
|
|
1060
|
+
return { kind: 'color', key: item, ...color };
|
|
1061
|
+
}
|
|
1062
|
+
const button = BUTTONS[item];
|
|
1063
|
+
if (button) {
|
|
1064
|
+
return { kind: 'button', key: item, ...button };
|
|
1065
|
+
}
|
|
1066
|
+
if (UNSUPPORTED.has(item)) {
|
|
1067
|
+
skipped.push(item);
|
|
1068
|
+
}
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditMenubarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1072
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: RicheditMenubarComponent, isStandalone: true, selector: "data-richedit-menubar", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"btn-toolbar data-richedit__menubar\" role=\"toolbar\" aria-label=\"Mise en forme du texte\">\n @for (group of groups(); track $index) {\n <div class=\"btn-group btn-group-sm me-1\">\n @for (entry of group; track entry.key) {\n @if (entry.kind === \"button\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"isActive(entry)\"\n [attr.aria-pressed]=\"isActive(entry)\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"run(entry)\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else if (entry.kind === \"link\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"linkActive()\"\n [attr.aria-pressed]=\"linkOpen()\"\n [attr.aria-expanded]=\"linkOpen()\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"toggleLinkForm()\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else if (entry.kind === \"heading\") {\n <select\n #headingSelect\n class=\"form-select form-select-sm data-richedit__headings\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n [value]=\"currentHeading()\"\n (change)=\"applyHeading(headingSelect.value)\"\n >\n <option [value]=\"0\">Paragraphe</option>\n @for (level of entry.levels; track level) {\n <option [value]=\"level\">Titre {{ level }}</option>\n }\n </select>\n } @else {\n <!--\n The native picker is the real control -- it keeps the keyboard and\n the OS colour dialog -- but it sits invisible over the label, which\n carries the icon, the swatch and (through :focus-within) the focus\n ring.\n -->\n <label class=\"btn btn-sm btn-outline-secondary data-richedit__color\" [title]=\"entry.label\">\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n <span\n class=\"data-richedit__swatch\"\n [style.background-color]=\"swatchColor(entry) ?? 'transparent'\"\n aria-hidden=\"true\"\n ></span>\n <input\n #colorInput\n type=\"color\"\n [attr.aria-label]=\"entry.label\"\n [value]=\"currentColor(entry)\"\n (change)=\"applyColor(entry, colorInput.value)\"\n />\n </label>\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [attr.aria-label]=\"entry.label + ' : effacer'\"\n [title]=\"entry.label + ' : effacer'\"\n (click)=\"clearColor(entry)\"\n >\n <i class=\"bi bi-x-lg\" aria-hidden=\"true\"></i>\n </button>\n }\n }\n </div>\n }\n</div>\n@if (linkOpen()) {\n <div class=\"input-group input-group-sm mt-1 data-richedit__linkform\">\n <input\n #linkInput\n type=\"url\"\n class=\"form-control\"\n placeholder=\"https://\u2026\"\n aria-label=\"Adresse du lien\"\n [value]=\"linkHref()\"\n (keydown.enter)=\"applyLink(linkInput.value)\"\n />\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"applyLink(linkInput.value)\">Appliquer</button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"clearLink()\">Supprimer</button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"toggleLinkForm()\">Annuler</button>\n </div>\n}\n", styles: [":host{display:block}.data-richedit__menubar{flex-wrap:wrap;gap:.25rem 0;padding:.25rem;border-bottom:1px solid var(--bs-border-color, #dee2e6)}.data-richedit__headings{width:auto}.data-richedit__color{position:relative;display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;margin-bottom:0;cursor:pointer}.data-richedit__color input[type=color]{position:absolute;inset:0;width:100%;height:100%;padding:0;border:none;opacity:0;cursor:pointer}.data-richedit__color:focus-within{border-color:var(--bs-primary-border-subtle, #86b7fe);box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb, 13, 110, 253),.25)}.data-richedit__swatch{display:block;width:1em;height:3px;border-radius:1px;box-shadow:inset 0 0 0 1px var(--bs-border-color, #dee2e6)}\n"] });
|
|
1073
|
+
}
|
|
1074
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditMenubarComponent, decorators: [{
|
|
1075
|
+
type: Component,
|
|
1076
|
+
args: [{ selector: 'data-richedit-menubar', template: "<div class=\"btn-toolbar data-richedit__menubar\" role=\"toolbar\" aria-label=\"Mise en forme du texte\">\n @for (group of groups(); track $index) {\n <div class=\"btn-group btn-group-sm me-1\">\n @for (entry of group; track entry.key) {\n @if (entry.kind === \"button\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"isActive(entry)\"\n [attr.aria-pressed]=\"isActive(entry)\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"run(entry)\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else if (entry.kind === \"link\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"linkActive()\"\n [attr.aria-pressed]=\"linkOpen()\"\n [attr.aria-expanded]=\"linkOpen()\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"toggleLinkForm()\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else if (entry.kind === \"heading\") {\n <select\n #headingSelect\n class=\"form-select form-select-sm data-richedit__headings\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n [value]=\"currentHeading()\"\n (change)=\"applyHeading(headingSelect.value)\"\n >\n <option [value]=\"0\">Paragraphe</option>\n @for (level of entry.levels; track level) {\n <option [value]=\"level\">Titre {{ level }}</option>\n }\n </select>\n } @else {\n <!--\n The native picker is the real control -- it keeps the keyboard and\n the OS colour dialog -- but it sits invisible over the label, which\n carries the icon, the swatch and (through :focus-within) the focus\n ring.\n -->\n <label class=\"btn btn-sm btn-outline-secondary data-richedit__color\" [title]=\"entry.label\">\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n <span\n class=\"data-richedit__swatch\"\n [style.background-color]=\"swatchColor(entry) ?? 'transparent'\"\n aria-hidden=\"true\"\n ></span>\n <input\n #colorInput\n type=\"color\"\n [attr.aria-label]=\"entry.label\"\n [value]=\"currentColor(entry)\"\n (change)=\"applyColor(entry, colorInput.value)\"\n />\n </label>\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [attr.aria-label]=\"entry.label + ' : effacer'\"\n [title]=\"entry.label + ' : effacer'\"\n (click)=\"clearColor(entry)\"\n >\n <i class=\"bi bi-x-lg\" aria-hidden=\"true\"></i>\n </button>\n }\n }\n </div>\n }\n</div>\n@if (linkOpen()) {\n <div class=\"input-group input-group-sm mt-1 data-richedit__linkform\">\n <input\n #linkInput\n type=\"url\"\n class=\"form-control\"\n placeholder=\"https://\u2026\"\n aria-label=\"Adresse du lien\"\n [value]=\"linkHref()\"\n (keydown.enter)=\"applyLink(linkInput.value)\"\n />\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"applyLink(linkInput.value)\">Appliquer</button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"clearLink()\">Supprimer</button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"toggleLinkForm()\">Annuler</button>\n </div>\n}\n", styles: [":host{display:block}.data-richedit__menubar{flex-wrap:wrap;gap:.25rem 0;padding:.25rem;border-bottom:1px solid var(--bs-border-color, #dee2e6)}.data-richedit__headings{width:auto}.data-richedit__color{position:relative;display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;margin-bottom:0;cursor:pointer}.data-richedit__color input[type=color]{position:absolute;inset:0;width:100%;height:100%;padding:0;border:none;opacity:0;cursor:pointer}.data-richedit__color:focus-within{border-color:var(--bs-primary-border-subtle, #86b7fe);box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb, 13, 110, 253),.25)}.data-richedit__swatch{display:block;width:1em;height:3px;border-radius:1px;box-shadow:inset 0 0 0 1px var(--bs-border-color, #dee2e6)}\n"] }]
|
|
1077
|
+
}], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }] } });
|
|
10
1078
|
|
|
11
1079
|
/**
|
|
12
1080
|
* Named toolbar presets for {@link RicheditComponent}, selectable through its
|
|
@@ -22,30 +1090,35 @@ import { firstValueFrom } from 'rxjs';
|
|
|
22
1090
|
*/
|
|
23
1091
|
const RichEditToolbars = {
|
|
24
1092
|
default: [
|
|
25
|
-
[
|
|
26
|
-
[
|
|
27
|
-
[
|
|
28
|
-
[
|
|
29
|
-
[{ heading: [
|
|
30
|
-
[
|
|
31
|
-
[
|
|
32
|
-
[
|
|
1093
|
+
['bold', 'italic'],
|
|
1094
|
+
['underline', 'strike'],
|
|
1095
|
+
['code', 'blockquote'],
|
|
1096
|
+
['ordered_list', 'bullet_list'],
|
|
1097
|
+
[{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }],
|
|
1098
|
+
['link'],
|
|
1099
|
+
['text_color', 'background_color'],
|
|
1100
|
+
['align_left', 'align_center', 'align_right', 'align_justify'],
|
|
33
1101
|
],
|
|
34
1102
|
light: [
|
|
35
|
-
[
|
|
36
|
-
[
|
|
37
|
-
[
|
|
38
|
-
[
|
|
1103
|
+
['bold', 'italic', 'underline'],
|
|
1104
|
+
['ordered_list', 'bullet_list'],
|
|
1105
|
+
['text_color'],
|
|
1106
|
+
['align_left', 'align_center', 'align_right', 'align_justify'],
|
|
39
1107
|
],
|
|
40
1108
|
none: [],
|
|
41
1109
|
};
|
|
42
1110
|
/**
|
|
43
|
-
* Rich text editor for a model field, built on
|
|
1111
|
+
* Rich text editor for a model field, built on a vendored ProseMirror engine.
|
|
44
1112
|
*
|
|
45
1113
|
* Shipped as a separate entry point (`@solidev/data/richedit`) so that the
|
|
46
|
-
* `
|
|
47
|
-
* follows the same `dd` / `inline` / `form` layout convention as
|
|
48
|
-
* `<data-dispedit>`,
|
|
1114
|
+
* `prosemirror-*` dependencies stay optional for consumers who do not need
|
|
1115
|
+
* them. It follows the same `dd` / `inline` / `form` layout convention as
|
|
1116
|
+
* `<data-dispedit>`, but is used directly rather than through it: `dispedit`
|
|
1117
|
+
* lives in the primary entry point and cannot import a secondary one.
|
|
1118
|
+
*
|
|
1119
|
+
* The engine used to be `ngx-editor`, which stopped at Angular 19. The
|
|
1120
|
+
* replacement lives in `./prose` and speaks the same schema, so stored values
|
|
1121
|
+
* are unaffected — and so is this component's API.
|
|
49
1122
|
*
|
|
50
1123
|
* Unlike `<data-dispedit>`, saving is explicit: the value is written back and
|
|
51
1124
|
* persisted only when {@link save} runs, from the built-in save button — and
|
|
@@ -58,158 +1131,60 @@ const RichEditToolbars = {
|
|
|
58
1131
|
* <data-richedit [model]="thing" field="description">Description</data-richedit>
|
|
59
1132
|
* ```
|
|
60
1133
|
*/
|
|
61
|
-
class RicheditComponent {
|
|
62
|
-
/** Model instance holding the field. */
|
|
63
|
-
model;
|
|
64
|
-
/** Name of the rich text field to edit. */
|
|
65
|
-
field;
|
|
66
|
-
/** Whether {@link toggleEdit} is allowed to enable editing. */
|
|
67
|
-
editable = true;
|
|
68
|
-
/** Whether the editor is currently enabled. Defaults to true. */
|
|
69
|
-
edit = true;
|
|
70
|
-
/**
|
|
71
|
-
* Layout, and whether {@link save} persists.
|
|
72
|
-
*
|
|
73
|
-
* `dd` renders a `<dt>`/`<dd>` block and is the only mode that saves to the
|
|
74
|
-
* API; `inline` and `form` render a label plus the editor and leave saving to
|
|
75
|
-
* the caller.
|
|
76
|
-
*/
|
|
77
|
-
mode = "dd";
|
|
78
|
-
/** Hide label (for inline forms) */
|
|
79
|
-
hideLabel = false;
|
|
80
|
-
/**
|
|
81
|
-
* Hide the built-in save button, for callers driving persistence themselves
|
|
82
|
-
* from the {@link changed} output.
|
|
83
|
-
*/
|
|
84
|
-
hideButton = false;
|
|
85
|
-
/**
|
|
86
|
-
* Form control backing the editor.
|
|
87
|
-
*
|
|
88
|
-
* When supplied, the component uses it as-is and skips its own setup — no
|
|
89
|
-
* field manager lookup, no seeding from the model, and no {@link changed}
|
|
90
|
-
* emissions. When absent, one is created and wired up from `[model]` and
|
|
91
|
-
* `[field]`.
|
|
92
|
-
*/
|
|
93
|
-
fc;
|
|
1134
|
+
class RicheditComponent extends FieldEditorBase {
|
|
94
1135
|
/** Toolbar preset name from {@link RichEditToolbars}, or an explicit toolbar. */
|
|
95
|
-
toolbar =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/** Field manager for {@link field}; only resolved when `fc` is created here. */
|
|
99
|
-
manager;
|
|
100
|
-
/** Whether the manager declares the field required. */
|
|
101
|
-
required;
|
|
102
|
-
/** Underlying ngx-editor instance; destroyed with the component. */
|
|
1136
|
+
toolbar = input('default', /* @ts-ignore */
|
|
1137
|
+
...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
|
|
1138
|
+
/** Underlying editor instance; destroyed with the component. */
|
|
103
1139
|
editor;
|
|
104
1140
|
/** Unused; kept for backwards compatibility. */
|
|
105
|
-
html =
|
|
1141
|
+
html = '';
|
|
106
1142
|
/** Toolbar actually rendered, resolved from {@link toolbar}. */
|
|
107
|
-
realToolbar;
|
|
1143
|
+
realToolbar = [];
|
|
1144
|
+
destroyRef = inject(DestroyRef);
|
|
108
1145
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* relaying changes to {@link changed}.
|
|
1146
|
+
* Build the engine and resolve the toolbar, then let the base resolve the
|
|
1147
|
+
* control and relay its changes.
|
|
112
1148
|
*/
|
|
113
1149
|
ngOnInit() {
|
|
114
1150
|
this.editor = new Editor();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
1151
|
+
this.destroyRef.onDestroy(() => this.editor.destroy());
|
|
1152
|
+
const toolbar = this.toolbar();
|
|
1153
|
+
if (toolbar === 'none') {
|
|
1154
|
+
this.realToolbar = [];
|
|
119
1155
|
}
|
|
120
|
-
else {
|
|
121
|
-
this.realToolbar =
|
|
122
|
-
}
|
|
123
|
-
if (!this.fc) {
|
|
124
|
-
this.fc = new FormControl("");
|
|
125
|
-
if (this.model && this.field) {
|
|
126
|
-
this.manager = this.model.FM(this.field);
|
|
127
|
-
this.required = this.manager?.required || false;
|
|
128
|
-
this.fc.setValue(this.model[this.field] || "", {
|
|
129
|
-
emitEvent: false,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
if (this.edit) {
|
|
133
|
-
this.fc.enable();
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
this.fc.disable();
|
|
137
|
-
}
|
|
138
|
-
this.fc.valueChanges.subscribe((v) => this.changed.emit(v));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/** Destroy the ngx-editor instance to release its resources. */
|
|
142
|
-
// make sure to destory the editor
|
|
143
|
-
ngOnDestroy() {
|
|
144
|
-
this.editor.destroy();
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Switch between read-only and editing by enabling or disabling the control.
|
|
148
|
-
* Forces read-only when `[editable]` is false.
|
|
149
|
-
*/
|
|
150
|
-
toggleEdit() {
|
|
151
|
-
if (this.editable) {
|
|
152
|
-
if (this.edit) {
|
|
153
|
-
this.fc.disable();
|
|
154
|
-
this.edit = false;
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
this.fc.enable();
|
|
158
|
-
this.edit = true;
|
|
159
|
-
}
|
|
1156
|
+
else if (typeof toolbar === 'string') {
|
|
1157
|
+
this.realToolbar = RichEditToolbars[toolbar] ?? [];
|
|
160
1158
|
}
|
|
161
1159
|
else {
|
|
162
|
-
this.
|
|
163
|
-
this.edit = false;
|
|
1160
|
+
this.realToolbar = toolbar;
|
|
164
1161
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
* In `inline` / `form` modes the model is updated in memory but no request is
|
|
171
|
-
* sent, leaving the save to the surrounding form. Does nothing without both a
|
|
172
|
-
* `[model]` and a `[field]`.
|
|
173
|
-
*/
|
|
174
|
-
async save() {
|
|
175
|
-
if (this.model && this.field) {
|
|
176
|
-
this.model.setFV(this.field, this.fc.value);
|
|
177
|
-
if (this.mode === "dd") {
|
|
178
|
-
await firstValueFrom(this.model.update([this.field], { updateModel: true }));
|
|
179
|
-
}
|
|
1162
|
+
super.ngOnInit();
|
|
1163
|
+
if (!this.fc()) {
|
|
1164
|
+
this.control.valueChanges
|
|
1165
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
1166
|
+
.subscribe((value) => this.changed.emit(value));
|
|
180
1167
|
}
|
|
181
1168
|
}
|
|
182
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.
|
|
183
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.
|
|
1169
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
1170
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: RicheditComponent, isStandalone: true, selector: "data-richedit", inputs: { toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0, template: "<ng-template #editorTemplate>\n @if (!showEditor()) {\n <!--\n Read-only: the stored HTML, injected as trusted markup. See\n SafeHtmlPipe for why it is not sanitized.\n -->\n <div\n class=\"editable data-richedit__view\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"toggleEdit()\"\n (keydown.enter)=\"toggleEdit()\"\n (keydown.space)=\"$event.preventDefault(); toggleEdit()\"\n [innerHTML]=\"displayValue() | safeHtml\"\n ></div>\n } @else {\n <div class=\"data-richedit__wrapper\">\n @if (realToolbar.length) {\n <data-richedit-menubar [editor]=\"editor\" [toolbar]=\"realToolbar\"></data-richedit-menubar>\n }\n <data-richedit-editor [editor]=\"editor\" [inputId]=\"inputId\" [formControl]=\"control\"></data-richedit-editor>\n </div>\n @if (!hideButton()) {\n <button class=\"btn btn-primary btn-sm w-100 mt-1\" (click)=\"save()\">\n <i class=\"bi bi-save me-2\"></i>\n Enregistrer\n </button>\n }\n }\n</ng-template>\n<ng-template #titleTpl>\n <ng-content></ng-content>\n</ng-template>\n<!-- Dd display-->\n@if (mode() === \"dd\") {\n @if (!hideLabel()) {\n <dt [class.required]=\"required()\">\n <span\n class=\"editable\"\n [attr.id]=\"labelId\"\n (click)=\"toggleEdit()\"\n role=\"button\"\n tabindex=\"0\"\n (keydown.enter)=\"toggleEdit()\"\n (keydown.space)=\"$event.preventDefault(); toggleEdit()\"\n >\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </span>\n </dt>\n }\n <dd [class.mb-0]=\"hideLabel()\">\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n </dd>\n}\n<!-- Inline display-->\n@if (mode() === \"inline\") {\n @if (!hideLabel()) {\n <label [class.required]=\"required()\" [attr.id]=\"labelId\" [attr.for]=\"inputId\">\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </label>\n }\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n}\n<!-- Form display-->\n@if (mode() === \"form\") {\n @if (!hideLabel()) {\n <label [class.required]=\"required()\" [attr.id]=\"labelId\" [attr.for]=\"inputId\">\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </label>\n }\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n}\n", styles: [".data-richedit__wrapper{border:var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6);border-radius:var(--bs-border-radius, .375rem);background:var(--bs-body-bg, #fff);overflow:hidden}.data-richedit__wrapper:focus-within{border-color:var(--bs-primary-border-subtle, #86b7fe);box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb, 13, 110, 253),.25)}.data-richedit__view>*:first-child{margin-top:0}.data-richedit__view>*:last-child{margin-bottom:0}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: RicheditEditorComponent, selector: "data-richedit-editor", inputs: ["editor", "inputId"] }, { kind: "component", type: RicheditMenubarComponent, selector: "data-richedit-menubar", inputs: ["editor", "toolbar"] }, { kind: "pipe", type: SafeHtmlPipe, name: "safeHtml" }] });
|
|
184
1171
|
}
|
|
185
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.
|
|
1172
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditComponent, decorators: [{
|
|
186
1173
|
type: Component,
|
|
187
|
-
args: [{ selector:
|
|
188
|
-
}], propDecorators: {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
type: Input
|
|
198
|
-
}], hideLabel: [{
|
|
199
|
-
type: Input
|
|
200
|
-
}], hideButton: [{
|
|
201
|
-
type: Input
|
|
202
|
-
}], fc: [{
|
|
203
|
-
type: Input
|
|
204
|
-
}], toolbar: [{
|
|
205
|
-
type: Input
|
|
206
|
-
}], changed: [{
|
|
207
|
-
type: Output
|
|
208
|
-
}] } });
|
|
1174
|
+
args: [{ selector: 'data-richedit', imports: [NgTemplateOutlet, ReactiveFormsModule, RicheditEditorComponent, RicheditMenubarComponent, SafeHtmlPipe], template: "<ng-template #editorTemplate>\n @if (!showEditor()) {\n <!--\n Read-only: the stored HTML, injected as trusted markup. See\n SafeHtmlPipe for why it is not sanitized.\n -->\n <div\n class=\"editable data-richedit__view\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"toggleEdit()\"\n (keydown.enter)=\"toggleEdit()\"\n (keydown.space)=\"$event.preventDefault(); toggleEdit()\"\n [innerHTML]=\"displayValue() | safeHtml\"\n ></div>\n } @else {\n <div class=\"data-richedit__wrapper\">\n @if (realToolbar.length) {\n <data-richedit-menubar [editor]=\"editor\" [toolbar]=\"realToolbar\"></data-richedit-menubar>\n }\n <data-richedit-editor [editor]=\"editor\" [inputId]=\"inputId\" [formControl]=\"control\"></data-richedit-editor>\n </div>\n @if (!hideButton()) {\n <button class=\"btn btn-primary btn-sm w-100 mt-1\" (click)=\"save()\">\n <i class=\"bi bi-save me-2\"></i>\n Enregistrer\n </button>\n }\n }\n</ng-template>\n<ng-template #titleTpl>\n <ng-content></ng-content>\n</ng-template>\n<!-- Dd display-->\n@if (mode() === \"dd\") {\n @if (!hideLabel()) {\n <dt [class.required]=\"required()\">\n <span\n class=\"editable\"\n [attr.id]=\"labelId\"\n (click)=\"toggleEdit()\"\n role=\"button\"\n tabindex=\"0\"\n (keydown.enter)=\"toggleEdit()\"\n (keydown.space)=\"$event.preventDefault(); toggleEdit()\"\n >\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </span>\n </dt>\n }\n <dd [class.mb-0]=\"hideLabel()\">\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n </dd>\n}\n<!-- Inline display-->\n@if (mode() === \"inline\") {\n @if (!hideLabel()) {\n <label [class.required]=\"required()\" [attr.id]=\"labelId\" [attr.for]=\"inputId\">\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </label>\n }\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n}\n<!-- Form display-->\n@if (mode() === \"form\") {\n @if (!hideLabel()) {\n <label [class.required]=\"required()\" [attr.id]=\"labelId\" [attr.for]=\"inputId\">\n <ng-container [ngTemplateOutlet]=\"titleTpl\"></ng-container>\n </label>\n }\n <ng-container [ngTemplateOutlet]=\"editorTemplate\"></ng-container>\n}\n", styles: [".data-richedit__wrapper{border:var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6);border-radius:var(--bs-border-radius, .375rem);background:var(--bs-body-bg, #fff);overflow:hidden}.data-richedit__wrapper:focus-within{border-color:var(--bs-primary-border-subtle, #86b7fe);box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb, 13, 110, 253),.25)}.data-richedit__view>*:first-child{margin-top:0}.data-richedit__view>*:last-child{margin-bottom:0}\n"] }]
|
|
1175
|
+
}], propDecorators: { toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }] } });
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Toolbar configuration for `<data-richedit>`.
|
|
1179
|
+
*
|
|
1180
|
+
* The shape is the one `ngx-editor` used — an array of groups, each group an
|
|
1181
|
+
* array of items, an item either a name or a `{heading: [...]}` dropdown — so
|
|
1182
|
+
* toolbars written against the old editor keep compiling and keep working.
|
|
1183
|
+
*/
|
|
209
1184
|
|
|
210
1185
|
/**
|
|
211
1186
|
* Generated bundle index. Do not edit.
|
|
212
1187
|
*/
|
|
213
1188
|
|
|
214
|
-
export { RichEditToolbars, RicheditComponent };
|
|
1189
|
+
export { Editor, RichEditToolbars, RicheditComponent, activeAlignment, activeColor, activeHeading, activeLink, addLink, clearFormat, fromHTML, insertHorizontalRule, isEmpty, isMarkActive, isNodeActive, linkRange, markTypes, nodeTypes, removeColor, removeLink, schema, setAlign, setColor, setHeading, setParagraph, toHTML, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleCodeBlock, toggleHeading, toggleItalic, toggleList, toggleMarkCommand, toggleOrderedList, toggleStrike, toggleUnderline };
|
|
215
1190
|
//# sourceMappingURL=solidev-data-richedit.mjs.map
|