@solidev/data 1.0.0 → 1.1.0

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