@solidev/data 1.0.1 → 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.
@@ -0,0 +1,668 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, model, output, signal, computed, viewChild, inject, DestroyRef, afterNextRender, Component } from '@angular/core';
3
+ import { NgTemplateOutlet } from '@angular/common';
4
+ import { FormControl } from '@angular/forms';
5
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
+ import { uniqueId, asText, fieldValues } from '@solidev/data';
7
+ import { EditorState, EditorSelection, Compartment } from '@codemirror/state';
8
+ import { EditorView, keymap, placeholder } from '@codemirror/view';
9
+ import { firstValueFrom } from 'rxjs';
10
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
11
+ import { history, historyKeymap, defaultKeymap } from '@codemirror/commands';
12
+ import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language';
13
+ import { tags } from '@lezer/highlight';
14
+
15
+ /**
16
+ * Syntax highlighting for the markdown *source*.
17
+ *
18
+ * The point of `mdedit` is that the markup stays on screen: `**bold**` keeps
19
+ * its asterisks, a heading keeps its `#`. What this style does is make the
20
+ * source read like the document it describes — headings get bigger, `**bold**`
21
+ * renders bold, code turns monospace — while the markers themselves
22
+ * ({@link tags.processingInstruction}, {@link tags.meta}) are dimmed so they
23
+ * stay visible but recede.
24
+ *
25
+ * Colours come from Bootstrap CSS variables so the editor follows the host
26
+ * theme, including its dark mode, without shipping a palette of its own.
27
+ */
28
+ const markdownHighlightStyle = HighlightStyle.define([
29
+ { tag: tags.heading1, fontSize: '1.6em', fontWeight: 'bold', lineHeight: '1.4' },
30
+ { tag: tags.heading2, fontSize: '1.4em', fontWeight: 'bold', lineHeight: '1.4' },
31
+ { tag: tags.heading3, fontSize: '1.25em', fontWeight: 'bold' },
32
+ { tag: tags.heading4, fontSize: '1.15em', fontWeight: 'bold' },
33
+ { tag: tags.heading5, fontSize: '1.05em', fontWeight: 'bold' },
34
+ { tag: tags.heading6, fontSize: '1em', fontWeight: 'bold' },
35
+ { tag: tags.strong, fontWeight: 'bold' },
36
+ { tag: tags.emphasis, fontStyle: 'italic' },
37
+ { tag: tags.strikethrough, textDecoration: 'line-through' },
38
+ { tag: tags.link, textDecoration: 'underline' },
39
+ { tag: tags.url, color: 'var(--bs-link-color, #0d6efd)', textDecoration: 'underline' },
40
+ {
41
+ tag: tags.monospace,
42
+ fontFamily: 'var(--bs-font-monospace, monospace)',
43
+ backgroundColor: 'var(--bs-tertiary-bg, rgba(0, 0, 0, 0.05))',
44
+ },
45
+ { tag: tags.quote, color: 'var(--bs-secondary-color, #6c757d)', fontStyle: 'italic' },
46
+ { tag: tags.contentSeparator, fontWeight: 'bold', color: 'var(--bs-secondary-color, #6c757d)' },
47
+ { tag: tags.list, color: 'var(--bs-emphasis-color, #000)' },
48
+ // The markup itself: `#`, `*`, `>`, backticks, list bullets. Dimmed rather
49
+ // than hidden -- this is a source editor, not a WYSIWYG.
50
+ { tag: tags.processingInstruction, color: 'var(--bs-secondary-color, #6c757d)' },
51
+ { tag: tags.meta, color: 'var(--bs-secondary-color, #6c757d)' },
52
+ ]);
53
+ /**
54
+ * Editor chrome: a box that matches Bootstrap's `.form-control`, including its
55
+ * focus ring, so an `mdedit` sits in a form without looking foreign.
56
+ */
57
+ const markdownTheme = EditorView.theme({
58
+ '&': {
59
+ fontSize: 'inherit',
60
+ color: 'var(--bs-body-color, #212529)',
61
+ backgroundColor: 'var(--bs-body-bg, #fff)',
62
+ border: 'var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6)',
63
+ borderRadius: 'var(--bs-border-radius, 0.375rem)',
64
+ },
65
+ '&.cm-focused': {
66
+ outline: 'none',
67
+ borderColor: 'var(--bs-primary-border-subtle, #86b7fe)',
68
+ boxShadow: '0 0 0 0.25rem rgba(var(--bs-primary-rgb, 13, 110, 253), 0.25)',
69
+ },
70
+ '.cm-content': {
71
+ fontFamily: 'var(--bs-body-font-family, system-ui, sans-serif)',
72
+ padding: '0.5rem 0.75rem',
73
+ minHeight: '8rem',
74
+ caretColor: 'var(--bs-body-color, #212529)',
75
+ },
76
+ '.cm-line': {
77
+ padding: '0',
78
+ },
79
+ '.cm-placeholder': {
80
+ color: 'var(--bs-secondary-color, #6c757d)',
81
+ },
82
+ '&.cm-editor.cm-readonly .cm-content, &.cm-editor:not(.cm-focused) .cm-content': {
83
+ caretColor: 'transparent',
84
+ },
85
+ });
86
+ /**
87
+ * The whole visual layer — chrome plus source highlighting — as one extension,
88
+ * which is what {@link markdownSetup} installs.
89
+ */
90
+ function markdownStyling() {
91
+ return [markdownTheme, syntaxHighlighting(markdownHighlightStyle)];
92
+ }
93
+
94
+ /**
95
+ * The extensions that make an `EditorView` behave as a markdown source editor.
96
+ *
97
+ * Deliberately not CodeMirror's `basicSetup`: this edits prose, so there are no
98
+ * line numbers, no fold gutter and no bracket matching — just markdown parsing,
99
+ * history, wrapping, and the styling from {@link markdownStyling}.
100
+ *
101
+ * The markdown dialect is `markdownLanguage`, i.e. GFM: tables, strikethrough
102
+ * and task lists parse. Fenced code blocks are recognised but their content is
103
+ * not highlighted per-language; that would need `@codemirror/language-data` and
104
+ * its lazily loaded grammars, which is out of scope here.
105
+ */
106
+ function markdownSetup(options = {}) {
107
+ const extensions = [
108
+ history(),
109
+ // Our bindings come first so undo/redo and the markdown list-continuation
110
+ // keys win over the language's own defaults.
111
+ keymap.of([...historyKeymap, ...defaultKeymap]),
112
+ markdown({ base: markdownLanguage, addKeymap: true }),
113
+ EditorView.lineWrapping,
114
+ markdownStyling(),
115
+ ];
116
+ if (options.placeholder) {
117
+ extensions.push(placeholder(options.placeholder));
118
+ }
119
+ if (options.labelledBy) {
120
+ extensions.push(EditorView.contentAttributes.of({ 'aria-labelledby': options.labelledBy }));
121
+ }
122
+ const { onChange, onStateChange } = options;
123
+ if (onChange || onStateChange) {
124
+ extensions.push(EditorView.updateListener.of((update) => {
125
+ if (onChange && update.docChanged) {
126
+ onChange(update.state.doc.toString());
127
+ }
128
+ if (onStateChange && (update.docChanged || update.selectionSet || update.focusChanged)) {
129
+ onStateChange(update.state);
130
+ }
131
+ }));
132
+ }
133
+ if (options.readOnly) {
134
+ extensions.push(options.readOnly.of(readOnlyExtension(options.readOnlyInitially ?? false)));
135
+ }
136
+ else if (options.readOnlyInitially) {
137
+ extensions.push(readOnlyExtension(true));
138
+ }
139
+ return extensions;
140
+ }
141
+ /**
142
+ * Read-only state for a given editability, meant to be reconfigured into the
143
+ * compartment passed to {@link markdownSetup}.
144
+ *
145
+ * Both facets are needed: `EditorState.readOnly` stops commands from changing
146
+ * the document, `EditorView.editable` takes `contenteditable` off the DOM node
147
+ * so the caret and the browser's own editing affordances go away too.
148
+ */
149
+ function readOnlyExtension(readOnly) {
150
+ return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)];
151
+ }
152
+
153
+ /**
154
+ * Markdown editing commands, written as CodeMirror `StateCommand`s.
155
+ *
156
+ * A `StateCommand` only needs `{state, dispatch}`, which an `EditorView`
157
+ * satisfies — so these drive the toolbar buttons in the browser, and can be
158
+ * exercised in a spec against a bare `EditorState`, with no DOM at all. They
159
+ * rewrite the markdown source, since that is what the user sees and edits.
160
+ */
161
+ /**
162
+ * Lines touched by the selection, in document order and without duplicates.
163
+ *
164
+ * Selection ranges are sorted and non-overlapping, so tracking the last line
165
+ * number emitted is enough to keep a line from being rewritten twice when two
166
+ * cursors sit on it.
167
+ */
168
+ function selectedLines(state) {
169
+ const lines = [];
170
+ let last = -1;
171
+ for (const range of state.selection.ranges) {
172
+ for (let pos = range.from; pos <= range.to;) {
173
+ const line = state.doc.lineAt(pos);
174
+ if (line.number > last) {
175
+ lines.push(line);
176
+ last = line.number;
177
+ }
178
+ pos = line.to + 1;
179
+ }
180
+ }
181
+ return lines;
182
+ }
183
+ /**
184
+ * Toggle a symmetric inline delimiter — `**` for bold, `*` for italic, `~~` for
185
+ * strikethrough, `` ` `` for code.
186
+ *
187
+ * Wraps the selection, or unwraps it when the delimiters are already there,
188
+ * whether they sit just outside the selection (the usual case after a previous
189
+ * toggle) or inside it (the user selected them along with the text). On an
190
+ * empty selection it inserts the pair and leaves the cursor between the two
191
+ * halves, so typing continues inside the mark.
192
+ */
193
+ function toggleInlineMark(delim) {
194
+ const len = delim.length;
195
+ return ({ state, dispatch }) => {
196
+ const transaction = state.changeByRange((range) => {
197
+ if (!range.empty &&
198
+ state.sliceDoc(range.from - len, range.from) === delim &&
199
+ state.sliceDoc(range.to, range.to + len) === delim) {
200
+ return {
201
+ changes: [
202
+ { from: range.from - len, to: range.from },
203
+ { from: range.to, to: range.to + len },
204
+ ],
205
+ range: EditorSelection.range(range.from - len, range.to - len),
206
+ };
207
+ }
208
+ const text = state.sliceDoc(range.from, range.to);
209
+ if (text.length >= 2 * len && text.startsWith(delim) && text.endsWith(delim)) {
210
+ return {
211
+ changes: [
212
+ { from: range.from, to: range.from + len },
213
+ { from: range.to - len, to: range.to },
214
+ ],
215
+ range: EditorSelection.range(range.from, range.to - 2 * len),
216
+ };
217
+ }
218
+ return {
219
+ changes: [
220
+ { from: range.from, insert: delim },
221
+ { from: range.to, insert: delim },
222
+ ],
223
+ range: range.empty
224
+ ? EditorSelection.cursor(range.from + len)
225
+ : EditorSelection.range(range.from + len, range.to + len),
226
+ };
227
+ });
228
+ dispatch(state.update(transaction, { scrollIntoView: true, userEvent: 'input' }));
229
+ return true;
230
+ };
231
+ }
232
+ /** Toggle `**bold**` around the selection. */
233
+ const toggleBold = toggleInlineMark('**');
234
+ /** Toggle `*italic*` around the selection. */
235
+ const toggleItalic = toggleInlineMark('*');
236
+ /** Toggle `~~strikethrough~~` around the selection (GFM). */
237
+ const toggleStrikethrough = toggleInlineMark('~~');
238
+ /** Toggle `` `code` `` around the selection. */
239
+ const toggleInlineCode = toggleInlineMark('`');
240
+ /** Leading ATX heading markup, e.g. `### `. */
241
+ const HEADING = /^#{1,6} */;
242
+ /**
243
+ * Set the heading level of every selected line, `0` meaning plain paragraph.
244
+ *
245
+ * This sets rather than toggles: picking "Heading 2" twice leaves an `h2`, and
246
+ * the way back to a paragraph is `setHeading(0)` — which is what the toolbar's
247
+ * "Paragraphe" entry does. Returns false when nothing would change, as a
248
+ * CodeMirror command should.
249
+ */
250
+ function setHeading(level) {
251
+ const prefix = level > 0 ? `${'#'.repeat(level)} ` : '';
252
+ return ({ state, dispatch }) => {
253
+ const changes = [];
254
+ for (const line of selectedLines(state)) {
255
+ const current = HEADING.exec(line.text)?.[0] ?? '';
256
+ if (current === prefix)
257
+ continue;
258
+ changes.push({ from: line.from, to: line.from + current.length, insert: prefix });
259
+ }
260
+ if (!changes.length)
261
+ return false;
262
+ dispatch(state.update({ changes, userEvent: 'input' }));
263
+ return true;
264
+ };
265
+ }
266
+ /**
267
+ * Add a line prefix to every selected line, or strip it when all of them
268
+ * already have it.
269
+ *
270
+ * `prefixFor` receives the index of the line within the selection, which is
271
+ * what lets ordered lists number themselves.
272
+ */
273
+ function toggleLinePrefix(match, prefixFor) {
274
+ return ({ state, dispatch }) => {
275
+ const lines = selectedLines(state);
276
+ if (!lines.length)
277
+ return false;
278
+ const strip = lines.every((line) => match.test(line.text));
279
+ const changes = [];
280
+ lines.forEach((line, index) => {
281
+ const found = match.exec(line.text);
282
+ if (strip) {
283
+ if (found)
284
+ changes.push({ from: line.from, to: line.from + found[0].length });
285
+ }
286
+ else if (!found) {
287
+ changes.push({ from: line.from, insert: prefixFor(index) });
288
+ }
289
+ });
290
+ if (!changes.length)
291
+ return false;
292
+ dispatch(state.update({ changes, userEvent: 'input' }));
293
+ return true;
294
+ };
295
+ }
296
+ /** Toggle `> ` blockquote markers on the selected lines. */
297
+ const toggleQuote = toggleLinePrefix(/^> ?/, () => '> ');
298
+ /** Toggle `- ` bullets on the selected lines; `*` and `+` bullets count as set. */
299
+ const toggleBulletList = toggleLinePrefix(/^[-*+] /, () => '- ');
300
+ /** Toggle `1. ` numbering on the selected lines, renumbering from one. */
301
+ const toggleOrderedList = toggleLinePrefix(/^\d+\. /, (index) => `${index + 1}. `);
302
+ /**
303
+ * Turn the selection into a link.
304
+ *
305
+ * With text selected it becomes `[text](url)` and the `url` placeholder is left
306
+ * selected, ready to be typed over. With no selection the whole
307
+ * `[text](url)` skeleton is inserted and `text` is selected instead.
308
+ */
309
+ const insertLink = ({ state, dispatch }) => {
310
+ const transaction = state.changeByRange((range) => {
311
+ if (range.empty) {
312
+ return {
313
+ changes: { from: range.from, insert: '[text](url)' },
314
+ range: EditorSelection.range(range.from + 1, range.from + 5),
315
+ };
316
+ }
317
+ const text = state.sliceDoc(range.from, range.to);
318
+ // '[' + text + '](' puts the url placeholder here.
319
+ const urlFrom = range.from + text.length + 3;
320
+ return {
321
+ changes: { from: range.from, to: range.to, insert: `[${text}](url)` },
322
+ range: EditorSelection.range(urlFrom, urlFrom + 3),
323
+ };
324
+ });
325
+ dispatch(state.update(transaction, { scrollIntoView: true, userEvent: 'input' }));
326
+ return true;
327
+ };
328
+ /** Lezer node names, per markdown construct, as produced by `@lezer/markdown`. */
329
+ const ACTIVE_NODES = {
330
+ bold: 'StrongEmphasis',
331
+ italic: 'Emphasis',
332
+ strike: 'Strikethrough',
333
+ code: 'InlineCode',
334
+ quote: 'Blockquote',
335
+ bulletList: 'BulletList',
336
+ orderedList: 'OrderedList',
337
+ link: 'Link',
338
+ };
339
+ /**
340
+ * Which markdown constructs the cursor currently sits in.
341
+ *
342
+ * Read from the syntax tree rather than by matching the raw text, so nesting
343
+ * and escapes are handled by the parser. Drives `aria-pressed` on the toolbar.
344
+ */
345
+ function markdownActive(state) {
346
+ const active = {
347
+ bold: false,
348
+ italic: false,
349
+ strike: false,
350
+ code: false,
351
+ quote: false,
352
+ bulletList: false,
353
+ orderedList: false,
354
+ link: false,
355
+ heading: 0,
356
+ };
357
+ // -1 biases the lookup towards the node ending at the cursor, so a mark stays
358
+ // reported as active with the caret just after its closing delimiter.
359
+ let node = syntaxTree(state).resolveInner(state.selection.main.head, -1);
360
+ const names = Object.entries(ACTIVE_NODES);
361
+ for (;;) {
362
+ const heading = /^ATXHeading([1-6])$/.exec(node.name);
363
+ if (heading && !active.heading) {
364
+ active.heading = Number(heading[1]);
365
+ }
366
+ for (const [key, nodeName] of names) {
367
+ if (node.name === nodeName)
368
+ active[key] = true;
369
+ }
370
+ const parent = node.parent;
371
+ if (!parent)
372
+ return active;
373
+ node = parent;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Named toolbar presets for {@link MdeditComponent}, selectable through its
379
+ * `toolbar` input.
380
+ *
381
+ * - `default`: marks, a heading dropdown, quote and code, lists, links.
382
+ * - `light`: a reduced set — bold, italic, bullets, links.
383
+ * - `none`: empty; the component hides the toolbar entirely for this value.
384
+ *
385
+ * An `MdToolbar` can also be passed directly when neither preset fits.
386
+ */
387
+ const MdEditToolbars = {
388
+ default: [['bold', 'italic', 'strike'], ['heading'], ['quote', 'code'], ['bullet_list', 'ordered_list'], ['link']],
389
+ light: [['bold', 'italic'], ['bullet_list'], ['link']],
390
+ none: [],
391
+ };
392
+ const BUTTONS = {
393
+ bold: { icon: 'bi-type-bold', label: 'Gras', command: toggleBold, active: 'bold' },
394
+ italic: { icon: 'bi-type-italic', label: 'Italique', command: toggleItalic, active: 'italic' },
395
+ strike: { icon: 'bi-type-strikethrough', label: 'Barré', command: toggleStrikethrough, active: 'strike' },
396
+ code: { icon: 'bi-code', label: 'Code', command: toggleInlineCode, active: 'code' },
397
+ quote: { icon: 'bi-blockquote-left', label: 'Citation', command: toggleQuote, active: 'quote' },
398
+ bullet_list: { icon: 'bi-list-ul', label: 'Liste à puces', command: toggleBulletList, active: 'bulletList' },
399
+ ordered_list: { icon: 'bi-list-ol', label: 'Liste numérotée', command: toggleOrderedList, active: 'orderedList' },
400
+ link: { icon: 'bi-link-45deg', label: 'Lien', command: insertLink, active: 'link' },
401
+ };
402
+ /** Levels offered by the heading dropdown; 0 puts the line back to a paragraph. */
403
+ const HEADING_LEVELS = [
404
+ { level: 0, label: 'Paragraphe' },
405
+ { level: 1, label: 'Titre 1' },
406
+ { level: 2, label: 'Titre 2' },
407
+ { level: 3, label: 'Titre 3' },
408
+ { level: 4, label: 'Titre 4' },
409
+ ];
410
+ /** Everything off, the state of an editor that has not reported yet. */
411
+ const NOTHING_ACTIVE = {
412
+ bold: false,
413
+ italic: false,
414
+ strike: false,
415
+ code: false,
416
+ quote: false,
417
+ bulletList: false,
418
+ orderedList: false,
419
+ link: false,
420
+ heading: 0,
421
+ };
422
+ /**
423
+ * Markdown source editor for a model field, built on CodeMirror 6.
424
+ *
425
+ * Shipped as a separate entry point (`@solidev/data/mdedit`) so consumers who
426
+ * do not edit markdown never pull CodeMirror in. The markup stays on screen and
427
+ * stays editable — this is a *source* editor that styles itself as it goes
428
+ * (headings bigger, `**bold**` actually bold), not a WYSIWYG hiding the syntax
429
+ * and not a split preview. The flavour is GFM.
430
+ *
431
+ * The external contract matches {@link RicheditComponent} so the two are
432
+ * interchangeable in a form: same `dd` / `inline` / `form` modes, same explicit
433
+ * save (persisting only in `dd` mode), same `[fc]` escape hatch.
434
+ *
435
+ * @example
436
+ * ```html
437
+ * <data-mdedit [model]="thing" field="description">Description</data-mdedit>
438
+ * ```
439
+ */
440
+ class MdeditComponent {
441
+ /** Id tying the label to the editor. */
442
+ inputId = uniqueId('data-mdedit');
443
+ /** Id of the label element, wired to the editor through `aria-labelledby`. */
444
+ labelId = `${this.inputId}-label`;
445
+ /** Model instance holding the field. */
446
+ model = input(/* @ts-ignore */
447
+ ...(ngDevMode ? [undefined, { debugName: "model" }] : /* istanbul ignore next */ []));
448
+ /** Name of the markdown field to edit. */
449
+ field = input(/* @ts-ignore */
450
+ ...(ngDevMode ? [undefined, { debugName: "field" }] : /* istanbul ignore next */ []));
451
+ /** Whether {@link toggleEdit} is allowed to enable editing. */
452
+ editable = input(true, /* @ts-ignore */
453
+ ...(ngDevMode ? [{ debugName: "editable" }] : /* istanbul ignore next */ []));
454
+ /** Whether the editor is currently enabled. Defaults to true. */
455
+ edit = model(true, /* @ts-ignore */
456
+ ...(ngDevMode ? [{ debugName: "edit" }] : /* istanbul ignore next */ []));
457
+ /**
458
+ * Layout, and whether {@link save} persists.
459
+ *
460
+ * `dd` renders a `<dt>`/`<dd>` block and is the only mode that saves to the
461
+ * API; `inline` and `form` render a label plus the editor and leave saving to
462
+ * the caller.
463
+ */
464
+ mode = input('dd', /* @ts-ignore */
465
+ ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
466
+ /** Hide label (for inline forms). */
467
+ hideLabel = input(false, /* @ts-ignore */
468
+ ...(ngDevMode ? [{ debugName: "hideLabel" }] : /* istanbul ignore next */ []));
469
+ /**
470
+ * Hide the built-in save button, for callers driving persistence themselves
471
+ * from the {@link changed} output.
472
+ */
473
+ hideButton = input(false, /* @ts-ignore */
474
+ ...(ngDevMode ? [{ debugName: "hideButton" }] : /* istanbul ignore next */ []));
475
+ /**
476
+ * Form control backing the editor.
477
+ *
478
+ * When supplied, the component uses it as-is and skips its own setup — no
479
+ * field manager lookup, no seeding from the model, and no {@link changed}
480
+ * emissions. When absent, one is created and wired up from `[model]` and
481
+ * `[field]`.
482
+ */
483
+ fc = input(/* @ts-ignore */
484
+ ...(ngDevMode ? [undefined, { debugName: "fc" }] : /* istanbul ignore next */ []));
485
+ /** Toolbar preset name from {@link MdEditToolbars}, or an explicit toolbar. */
486
+ toolbar = input('default', /* @ts-ignore */
487
+ ...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
488
+ /** Text shown while the document is empty. */
489
+ placeholder = input('', /* @ts-ignore */
490
+ ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
491
+ /** Emits the markdown source on every change. Only wired for an internal `fc`. */
492
+ changed = output();
493
+ /** Field manager for {@link field}; only resolved when `fc` is created here. */
494
+ manager;
495
+ /** Whether the manager declares the field required. */
496
+ required = signal(false, /* @ts-ignore */
497
+ ...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
498
+ /** Markdown constructs under the cursor, driving `aria-pressed`. */
499
+ active = signal(NOTHING_ACTIVE, /* @ts-ignore */
500
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
501
+ /** Heading levels offered by the dropdown. */
502
+ headingLevels = HEADING_LEVELS;
503
+ /** The control actually in use: the supplied `[fc]`, or the internal one. */
504
+ control;
505
+ /** Toolbar resolved from {@link toolbar}, as entries the template can render. */
506
+ groups = computed(() => {
507
+ const requested = this.toolbar();
508
+ const layout = typeof requested === 'string' ? (MdEditToolbars[requested] ?? []) : requested;
509
+ return layout.map((group) => group.map((item) => item === 'heading'
510
+ ? { kind: 'heading', label: 'Niveau de titre' }
511
+ : { kind: 'button', ...BUTTONS[item] }));
512
+ }, /* @ts-ignore */
513
+ ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
514
+ host = viewChild('editorHost', /* @ts-ignore */
515
+ ...(ngDevMode ? [{ debugName: "host" }] : /* istanbul ignore next */ []));
516
+ destroyRef = inject(DestroyRef);
517
+ /** Holds the read-only extensions, so editability can be reconfigured live. */
518
+ readOnly = new Compartment();
519
+ view;
520
+ constructor() {
521
+ // The view touches the DOM, so it is only built once there is one --
522
+ // nothing happens under SSR, where afterNextRender does not run.
523
+ afterNextRender(() => this.mount());
524
+ this.destroyRef.onDestroy(() => this.view?.destroy());
525
+ }
526
+ /**
527
+ * Resolve the control — unless a `[fc]` was given, build one seeded from the
528
+ * model field, enabled per `[edit]`, and relaying changes to {@link changed} —
529
+ * then keep the editor and the control in step.
530
+ */
531
+ ngOnInit() {
532
+ const supplied = this.fc();
533
+ if (supplied) {
534
+ this.control = supplied;
535
+ }
536
+ else {
537
+ this.control = new FormControl('');
538
+ const model = this.model();
539
+ const field = this.field();
540
+ if (model && field) {
541
+ this.manager = model.FM(field);
542
+ this.required.set(this.manager?.required ?? false);
543
+ this.control.setValue(asText(fieldValues(model)[field]), { emitEvent: false });
544
+ }
545
+ if (this.edit()) {
546
+ this.control.enable();
547
+ }
548
+ else {
549
+ this.control.disable();
550
+ }
551
+ this.control.valueChanges
552
+ .pipe(takeUntilDestroyed(this.destroyRef))
553
+ .subscribe((value) => this.changed.emit(value));
554
+ }
555
+ // A value set from outside is pushed into the document, and enable() /
556
+ // disable() flips the read-only compartment. Both are no-ops when they
557
+ // already match, which is what keeps the editor -> control -> editor round
558
+ // trip from looping.
559
+ this.control.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => this.applyValue(value));
560
+ this.control.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.applyEditable());
561
+ }
562
+ /**
563
+ * Switch between read-only and editing by enabling or disabling the control.
564
+ * Forces read-only when `[editable]` is false.
565
+ */
566
+ toggleEdit() {
567
+ if (this.editable() && !this.edit()) {
568
+ this.control.enable();
569
+ this.edit.set(true);
570
+ }
571
+ else {
572
+ this.control.disable();
573
+ this.edit.set(false);
574
+ }
575
+ }
576
+ /**
577
+ * Run a toolbar command against the editor and hand focus back, so a click
578
+ * on the toolbar does not take the caret out of the document.
579
+ */
580
+ run(command) {
581
+ const view = this.view;
582
+ if (!view)
583
+ return;
584
+ command(view);
585
+ view.focus();
586
+ }
587
+ /** Apply the heading level picked in the dropdown to the current line(s). */
588
+ setHeadingLevel(value) {
589
+ this.run(setHeading(Number(value)));
590
+ }
591
+ /**
592
+ * Write the editor content back to the model field, and persist it in `dd`
593
+ * mode only.
594
+ *
595
+ * In `inline` / `form` modes the model is updated in memory but no request is
596
+ * sent, leaving the save to the surrounding form. Does nothing without both a
597
+ * `[model]` and a `[field]`.
598
+ */
599
+ async save() {
600
+ const model = this.model();
601
+ const field = this.field();
602
+ if (!model || !field)
603
+ return;
604
+ model.setFV(field, this.control.value);
605
+ if (this.mode() === 'dd') {
606
+ await firstValueFrom(model.update([field], { updateModel: true }));
607
+ }
608
+ }
609
+ /** Build the CodeMirror view inside the host element. */
610
+ mount() {
611
+ const host = this.host()?.nativeElement;
612
+ if (!host || this.view)
613
+ return;
614
+ this.view = new EditorView({
615
+ parent: host,
616
+ state: EditorState.create({
617
+ doc: this.control.value ?? '',
618
+ extensions: markdownSetup({
619
+ readOnly: this.readOnly,
620
+ readOnlyInitially: this.control.disabled,
621
+ placeholder: this.placeholder(),
622
+ labelledBy: this.labelId,
623
+ onChange: (doc) => this.pushToControl(doc),
624
+ onStateChange: (state) => this.active.set(markdownActive(state)),
625
+ }),
626
+ }),
627
+ });
628
+ }
629
+ /**
630
+ * Push a document into the control, unless it is already there.
631
+ *
632
+ * `setValue` emits even when the value is unchanged, so without this guard an
633
+ * external `setValue` would come back through the editor as a second
634
+ * `valueChanges` — and a second {@link changed} emission for one edit.
635
+ */
636
+ pushToControl(doc) {
637
+ if (doc === (this.control.value ?? ''))
638
+ return;
639
+ this.control.setValue(doc);
640
+ }
641
+ /** Push a control value into the document, unless it is already there. */
642
+ applyValue(value) {
643
+ const view = this.view;
644
+ if (!view)
645
+ return;
646
+ const next = value ?? '';
647
+ if (next === view.state.doc.toString())
648
+ return;
649
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next } });
650
+ }
651
+ /** Mirror the control's enabled/disabled status onto the editor. */
652
+ applyEditable() {
653
+ this.view?.dispatch({ effects: this.readOnly.reconfigure(readOnlyExtension(this.control.disabled)) });
654
+ }
655
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: MdeditComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
656
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: MdeditComponent, isStandalone: true, selector: "data-mdedit", inputs: { model: { classPropertyName: "model", publicName: "model", isSignal: true, isRequired: false, transformFunction: null }, field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, edit: { classPropertyName: "edit", publicName: "edit", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, hideLabel: { classPropertyName: "hideLabel", publicName: "hideLabel", isSignal: true, isRequired: false, transformFunction: null }, hideButton: { classPropertyName: "hideButton", publicName: "hideButton", isSignal: true, isRequired: false, transformFunction: null }, fc: { classPropertyName: "fc", publicName: "fc", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { edit: "editChange", changed: "changed" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["editorHost"], descendants: true, isSignal: true }], ngImport: i0, template: "<ng-template #editorTemplate>\n @if (edit() && groups().length) {\n <div class=\"btn-toolbar data-mdedit__toolbar\" role=\"toolbar\" aria-label=\"Mise en forme du markdown\">\n @for (group of groups(); track $index) {\n <div class=\"btn-group btn-group-sm me-1\">\n @for (entry of group; track entry.label) {\n @if (entry.kind === \"button\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"active()[entry.active]\"\n [attr.aria-pressed]=\"active()[entry.active]\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"run(entry.command)\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else {\n <select\n #headingSelect\n class=\"form-select form-select-sm data-mdedit__headings\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n [value]=\"active().heading\"\n (change)=\"setHeadingLevel(headingSelect.value)\"\n >\n @for (level of headingLevels; track level.level) {\n <option [value]=\"level.level\">{{ level.label }}</option>\n }\n </select>\n }\n }\n </div>\n }\n </div>\n }\n <div #editorHost class=\"data-mdedit__host\" [attr.id]=\"inputId\"></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 [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-mdedit__toolbar{flex-wrap:wrap;gap:.25rem 0;margin-bottom:.25rem}.data-mdedit__headings{width:auto}.data-mdedit__host .cm-editor{height:100%}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
657
+ }
658
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: MdeditComponent, decorators: [{
659
+ type: Component,
660
+ args: [{ selector: 'data-mdedit', imports: [NgTemplateOutlet], template: "<ng-template #editorTemplate>\n @if (edit() && groups().length) {\n <div class=\"btn-toolbar data-mdedit__toolbar\" role=\"toolbar\" aria-label=\"Mise en forme du markdown\">\n @for (group of groups(); track $index) {\n <div class=\"btn-group btn-group-sm me-1\">\n @for (entry of group; track entry.label) {\n @if (entry.kind === \"button\") {\n <button\n type=\"button\"\n class=\"btn btn-sm btn-outline-secondary\"\n [class.active]=\"active()[entry.active]\"\n [attr.aria-pressed]=\"active()[entry.active]\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n (click)=\"run(entry.command)\"\n >\n <i class=\"bi {{ entry.icon }}\" aria-hidden=\"true\"></i>\n </button>\n } @else {\n <select\n #headingSelect\n class=\"form-select form-select-sm data-mdedit__headings\"\n [attr.aria-label]=\"entry.label\"\n [title]=\"entry.label\"\n [value]=\"active().heading\"\n (change)=\"setHeadingLevel(headingSelect.value)\"\n >\n @for (level of headingLevels; track level.level) {\n <option [value]=\"level.level\">{{ level.label }}</option>\n }\n </select>\n }\n }\n </div>\n }\n </div>\n }\n <div #editorHost class=\"data-mdedit__host\" [attr.id]=\"inputId\"></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 [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-mdedit__toolbar{flex-wrap:wrap;gap:.25rem 0;margin-bottom:.25rem}.data-mdedit__headings{width:auto}.data-mdedit__host .cm-editor{height:100%}\n"] }]
661
+ }], ctorParameters: () => [], propDecorators: { model: [{ type: i0.Input, args: [{ isSignal: true, alias: "model", required: false }] }], field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: false }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }], edit: [{ type: i0.Input, args: [{ isSignal: true, alias: "edit", required: false }] }, { type: i0.Output, args: ["editChange"] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], hideLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideLabel", required: false }] }], hideButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideButton", required: false }] }], fc: [{ type: i0.Input, args: [{ isSignal: true, alias: "fc", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], changed: [{ type: i0.Output, args: ["changed"] }], host: [{ type: i0.ViewChild, args: ['editorHost', { isSignal: true }] }] } });
662
+
663
+ /**
664
+ * Generated bundle index. Do not edit.
665
+ */
666
+
667
+ export { MdEditToolbars, MdeditComponent, insertLink, markdownActive, markdownHighlightStyle, markdownSetup, markdownStyling, markdownTheme, readOnlyExtension, setHeading, toggleBold, toggleBulletList, toggleInlineCode, toggleInlineMark, toggleItalic, toggleOrderedList, toggleQuote, toggleStrikethrough };
668
+ //# sourceMappingURL=solidev-data-mdedit.mjs.map