@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.
@@ -1,12 +1,1080 @@
1
1
  import * as i0 from '@angular/core';
2
- import { EventEmitter, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
3
- import * as i1 from '@angular/common';
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';
9
- import { firstValueFrom } from 'rxjs';
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
- ["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"],
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
- ["bold", "italic", "underline"],
36
- ["ordered_list", "bullet_list"],
37
- ["text_color"],
38
- ["align_left", "align_center", "align_right", "align_justify"],
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 `ngx-editor`.
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
- * `ngx-editor` dependency stays optional for consumers who do not need it. It
47
- * follows the same `dd` / `inline` / `form` layout convention as
48
- * `<data-dispedit>`, and can also stand in as the `quill` editor slot.
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 = "default";
96
- /** Emits the editor's HTML on every change. Only wired for an internal `fc`. */
97
- changed = new EventEmitter();
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
- * Create the editor, resolve the toolbar, and unless a `[fc]` was given —
110
- * build a control seeded from the model field, enabled per `[edit]`, and
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
- if (this.toolbar === "light" || this.toolbar === "default") {
116
- this.realToolbar = RichEditToolbars[this.toolbar];
117
- }
118
- else if (this.toolbar === "none") {
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 = this.toolbar;
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.fc.disable();
163
- this.edit = false;
1160
+ this.realToolbar = toolbar;
164
1161
  }
165
- }
166
- /**
167
- * Write the editor content back to the model field, and persist it in `dd`
168
- * mode only.
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.2", ngImport: i0, type: RicheditComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
183
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", 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 });
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.2", ngImport: i0, type: RicheditComponent, decorators: [{
1172
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RicheditComponent, decorators: [{
186
1173
  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 }" }]
188
- }], propDecorators: { model: [{
189
- type: Input
190
- }], field: [{
191
- type: Input
192
- }], editable: [{
193
- type: Input
194
- }], edit: [{
195
- type: Input
196
- }], mode: [{
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