@besaitech/ng-design-system 0.0.71 → 0.0.73

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,1559 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, Directive, input, booleanAttribute, numberAttribute, inject, ChangeDetectorRef, ElementRef, viewChild, viewChildren, contentChild, signal, computed, afterNextRender, effect, untracked, DestroyRef, forwardRef, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import * as i1 from '@angular/forms';
4
+ import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
5
+ import { SdsIcon, SdsButton } from '@besaitech/ng-design-system';
6
+ import { Editor } from '@tiptap/core';
7
+ import { Placeholder, CharacterCount } from '@tiptap/extensions';
8
+ import { TableKit } from '@tiptap/extension-table';
9
+ import StarterKit from '@tiptap/starter-kit';
10
+
11
+ /** The toolbar shown when `[tools]` is not set. */
12
+ const SDS_RICH_TEXT_DEFAULT_TOOLS = [
13
+ 'heading2',
14
+ 'heading3',
15
+ 'bold',
16
+ 'italic',
17
+ 'underline',
18
+ 'strike',
19
+ 'bulletList',
20
+ 'orderedList',
21
+ 'blockquote',
22
+ 'link',
23
+ 'table',
24
+ 'clearFormatting',
25
+ 'undo',
26
+ 'redo',
27
+ ];
28
+ const SDS_RICH_TEXT_LABELS_EN = {
29
+ toolbar: 'Formatting',
30
+ paragraph: 'Normal text',
31
+ heading2: 'Heading',
32
+ heading3: 'Subheading',
33
+ bold: 'Bold',
34
+ italic: 'Italic',
35
+ underline: 'Underline',
36
+ strike: 'Strikethrough',
37
+ bulletList: 'Bulleted list',
38
+ orderedList: 'Numbered list',
39
+ blockquote: 'Quote',
40
+ link: 'Link',
41
+ table: 'Table',
42
+ horizontalRule: 'Divider',
43
+ clearFormatting: 'Clear formatting',
44
+ undo: 'Undo',
45
+ redo: 'Redo',
46
+ linkUrl: 'Link address',
47
+ linkPlaceholder: 'https://… or e-mail address',
48
+ linkApply: 'Apply',
49
+ linkRemove: 'Remove link',
50
+ linkCancel: 'Cancel',
51
+ linkInvalid: 'Enter a web address (https://…), an e-mail address or a path starting with /.',
52
+ tableTools: 'Table',
53
+ tableAddRow: 'Row below',
54
+ tableAddColumn: 'Column right',
55
+ tableDeleteRow: 'Delete row',
56
+ tableDeleteColumn: 'Delete column',
57
+ tableHeaderRow: 'Header row',
58
+ tableDelete: 'Delete table',
59
+ unsupportedContent: 'This text contains elements this editor cannot keep: {elements}. Changing it will remove them.',
60
+ countLimit: '{count} of {max} characters used',
61
+ count: '{count} characters',
62
+ };
63
+ /**
64
+ * App-wide label overrides for every {@link SdsRichTextEditor} — provide a (partial) translation once, e.g.
65
+ * `{ provide: SDS_RICH_TEXT_LABELS, useValue: { bold: 'Gras', … } }`. A component's own `[labels]` win over it.
66
+ * For an app that switches language at runtime, bind `[labels]` instead.
67
+ */
68
+ const SDS_RICH_TEXT_LABELS = new InjectionToken('SDS_RICH_TEXT_LABELS', { factory: () => ({}) });
69
+ const SCHEME = /^([a-z][a-z0-9+-]*):/i;
70
+ const ALLOWED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
71
+ const BARE_EMAIL = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/;
72
+ const BARE_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?([/?#].*)?$/i;
73
+ /**
74
+ * The link target the editor accepts for `raw`, normalised — or `null` when it is not safe to link to.
75
+ * Accepted: `http(s):`, `mailto:` and `tel:` URLs, site-relative paths (`/formations`) and anchors (`#faq`);
76
+ * a bare domain (`example.ch/page`) gets `https://`, a bare e-mail address gets `mailto:`. Everything else —
77
+ * `javascript:`, `data:`, protocol-relative `//host`, whitespace — is refused. The editor applies the same
78
+ * rule to pasted and loaded HTML, so an unsafe link never survives in its output.
79
+ */
80
+ function sdsSafeHref(raw) {
81
+ const url = (raw ?? '').trim();
82
+ if (!url || /[\s\u0000-\u001f\u007f]/.test(url)) {
83
+ return null;
84
+ }
85
+ if (url.startsWith('#')) {
86
+ return url;
87
+ }
88
+ if (url.startsWith('/')) {
89
+ return url.startsWith('//') ? null : url;
90
+ }
91
+ const scheme = SCHEME.exec(url);
92
+ if (scheme) {
93
+ return ALLOWED_SCHEMES.has(scheme[1].toLowerCase()) ? url : null;
94
+ }
95
+ if (BARE_EMAIL.test(url)) {
96
+ return `mailto:${url}`;
97
+ }
98
+ if (BARE_DOMAIN.test(url)) {
99
+ return `https://${url}`;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ let nextId$1 = 0;
105
+ const TOOL_DEFS = [
106
+ { tool: 'paragraph', icon: 'pilcrow', group: 0, toggle: true, shortcut: 'Mod-Alt-0' },
107
+ { tool: 'heading2', icon: 'heading-2', group: 0, toggle: true, shortcut: 'Mod-Alt-2' },
108
+ { tool: 'heading3', icon: 'heading-3', group: 0, toggle: true, shortcut: 'Mod-Alt-3' },
109
+ { tool: 'bold', icon: 'bold', group: 1, toggle: true, shortcut: 'Mod-B' },
110
+ { tool: 'italic', icon: 'italic', group: 1, toggle: true, shortcut: 'Mod-I' },
111
+ { tool: 'underline', icon: 'underline', group: 1, toggle: true, shortcut: 'Mod-U' },
112
+ { tool: 'strike', icon: 'strikethrough', group: 1, toggle: true, shortcut: 'Mod-Shift-S' },
113
+ { tool: 'bulletList', icon: 'list', group: 2, toggle: true, shortcut: 'Mod-Shift-8' },
114
+ { tool: 'orderedList', icon: 'list-ordered', group: 2, toggle: true, shortcut: 'Mod-Shift-7' },
115
+ { tool: 'blockquote', icon: 'text-quote', group: 2, toggle: true, shortcut: 'Mod-Shift-B' },
116
+ { tool: 'link', icon: 'link', group: 3, toggle: true, shortcut: 'Mod-K' },
117
+ { tool: 'table', icon: 'table', group: 3, toggle: false },
118
+ { tool: 'horizontalRule', icon: 'minus', group: 3, toggle: false },
119
+ { tool: 'clearFormatting', icon: 'remove-formatting', group: 4, toggle: false },
120
+ { tool: 'undo', icon: 'undo-2', group: 5, toggle: false, shortcut: 'Mod-Z' },
121
+ { tool: 'redo', icon: 'redo-2', group: 5, toggle: false, shortcut: 'Mod-Shift-Z' },
122
+ ];
123
+ const TABLE_ACTIONS = [
124
+ { action: 'addRow', label: 'tableAddRow', icon: 'between-horizontal-end' },
125
+ { action: 'addColumn', label: 'tableAddColumn', icon: 'between-vertical-end' },
126
+ { action: 'deleteRow', label: 'tableDeleteRow', icon: 'minus' },
127
+ { action: 'deleteColumn', label: 'tableDeleteColumn', icon: 'minus' },
128
+ { action: 'headerRow', label: 'tableHeaderRow', icon: null },
129
+ { action: 'delete', label: 'tableDelete', icon: 'grid-2x2-x' },
130
+ ];
131
+ /**
132
+ * Elements whose CONTENT (not just formatting) is lost when the schema cannot hold them — pictures, embeds,
133
+ * tables, form controls… Loaded HTML holding one of them that the editor drops triggers the warning.
134
+ */
135
+ const LOSSY_TAGS = new Set([
136
+ 'img',
137
+ 'picture',
138
+ 'figure',
139
+ 'svg',
140
+ 'canvas',
141
+ 'video',
142
+ 'audio',
143
+ 'iframe',
144
+ 'embed',
145
+ 'object',
146
+ 'table',
147
+ 'hr',
148
+ 'form',
149
+ 'input',
150
+ 'select',
151
+ 'textarea',
152
+ 'button',
153
+ 'math',
154
+ ]);
155
+ /** One line of the editable area, for `[rows]` → min-height. */
156
+ const LINE_EM = 1.6;
157
+ const FRAME_BASE = 'overflow-hidden rounded-control border text-ink transition-colors';
158
+ const TOOL_BUTTON = 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-ink-soft transition-colors ' +
159
+ 'hover:bg-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ' +
160
+ 'disabled:pointer-events-none disabled:opacity-40';
161
+ /**
162
+ * Pressed (active) state: the brand tint, like a tertiary button. In dark mode it takes the preset's
163
+ * `--sds-dark-tint` / `--sds-dark-on-tint` and falls back to a neutral white tint (see theme/dark-fills.ts —
164
+ * internal to the root entry, hence the literals here; Tailwind finds them by scanning the bundle).
165
+ */
166
+ const TOOL_BUTTON_ON = 'bg-brand-50 text-brand-700 hover:bg-brand-100 hover:text-brand-700 ' +
167
+ 'dark:bg-[color:var(--sds-dark-tint,rgb(255_255_255/0.12))] ' +
168
+ 'dark:text-[color:var(--sds-dark-on-tint,var(--color-ink))] ' +
169
+ 'dark:hover:bg-[color:var(--sds-dark-tint-hover,rgb(255_255_255/0.16))] ' +
170
+ 'dark:hover:text-[color:var(--sds-dark-on-tint,var(--color-ink))]';
171
+ /**
172
+ * Marks content projected at the END of an {@link SdsRichTextEditor}'s toolbar — a language switcher (as in
173
+ * {@link SdsLocalizedRichTextEditor}), an "insert variable" menu… It stays visible in `readonly` mode, where
174
+ * the formatting buttons are hidden: `<div sdsRichTextToolbarEnd>…</div>`.
175
+ */
176
+ class SdsRichTextToolbarEnd {
177
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsRichTextToolbarEnd, deps: [], target: i0.ɵɵFactoryTarget.Directive });
178
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.25", type: SdsRichTextToolbarEnd, isStandalone: true, selector: "[sdsRichTextToolbarEnd]", ngImport: i0 });
179
+ }
180
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsRichTextToolbarEnd, decorators: [{
181
+ type: Directive,
182
+ args: [{ selector: '[sdsRichTextToolbarEnd]' }]
183
+ }] });
184
+ /**
185
+ * A rich-text field — headings, bold / italic / underline / strike, lists, quotes, links, dividers, undo/redo —
186
+ * for content that is stored and shown as HTML (course pages, instructions, articles). Built on Tiptap
187
+ * (ProseMirror), so the HTML it emits is always clean and structured: no inline styles, no `<font>`/`<span>`
188
+ * soup from Word or web pages, nothing outside the enabled tools.
189
+ *
190
+ * **The tools are the schema.** `[tools]` chooses the toolbar AND what the document may contain: drop
191
+ * `heading2`/`heading3` and pasted headings become paragraphs; drop `link` and links are unwrapped. Links
192
+ * only accept http(s), mailto, tel, site-relative paths and anchors ({@link sdsSafeHref}) — `javascript:`
193
+ * and friends are stripped from typed, pasted and loaded HTML alike, and links open in a new tab with
194
+ * `rel="noopener noreferrer nofollow"`. Still sanitise on display, as for any stored HTML.
195
+ *
196
+ * Implements ControlValueAccessor over an HTML string — use with reactive forms or `[(ngModel)]`. An empty
197
+ * document is `''` (not `<p></p>`), so `Validators.required` works. Writing a value never marks the control
198
+ * dirty. Keyboard: the usual shortcuts (⌘/Ctrl+B, I, U, K for a link, ⌘/Ctrl+Alt+2/3 for headings, Markdown
199
+ * shortcuts like `## ` or `- `); the toolbar is one Tab stop, arrows move inside it.
200
+ *
201
+ * Strings are English by default: pass `[labels]` (or provide {@link SDS_RICH_TEXT_LABELS}) in another
202
+ * language. Import from the secondary entry point `@besaitech/ng-design-system/rich-text`.
203
+ */
204
+ class SdsRichTextEditor {
205
+ fieldId = `sds-rte-${nextId$1++}`;
206
+ label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
207
+ /** Accessible name used when no visible [label] is provided. */
208
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
209
+ /** Shown in the empty document. */
210
+ placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
211
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
212
+ error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
213
+ /** Mark the field invalid (danger frame + `aria-invalid`) without showing an [error] message. */
214
+ invalid = input(false, ...(ngDevMode ? [{ debugName: "invalid", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
215
+ /**
216
+ * Language of the content (`lang` on the editable document), so the browser spell-checks it in the right
217
+ * language — e.g. `'de'` for the German version of a translated text.
218
+ */
219
+ contentLanguage = input('', ...(ngDevMode ? [{ debugName: "contentLanguage" }] : []));
220
+ /** Asterisk after the [label] (hidden from screen readers) + `aria-required`. It does not validate. */
221
+ required = input(false, ...(ngDevMode ? [{ debugName: "required", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
222
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
223
+ /** Shows the content without the toolbar; it can be selected and copied but not changed. */
224
+ readonly = input(false, ...(ngDevMode ? [{ debugName: "readonly", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
225
+ /**
226
+ * The toolbar, in display order (groups are fixed: block, text, lists, insert, clear, history) — and the
227
+ * content the document may hold. Defaults to {@link SDS_RICH_TEXT_DEFAULT_TOOLS}. Set once: changing it
228
+ * rebuilds the editor (the content is kept, minus what the new tools no longer allow).
229
+ */
230
+ tools = input(SDS_RICH_TEXT_DEFAULT_TOOLS, ...(ngDevMode ? [{ debugName: "tools" }] : []));
231
+ /** Minimum visible lines of the editing area. */
232
+ rows = input(6, ...(ngDevMode ? [{ debugName: "rows", transform: numberAttribute }] : [{ transform: numberAttribute }]));
233
+ /** Lines after which the area scrolls instead of growing; 0 (default) grows with the content. */
234
+ maxRows = input(0, ...(ngDevMode ? [{ debugName: "maxRows", transform: numberAttribute }] : [{ transform: numberAttribute }]));
235
+ /** Cap on the plain-text length (markup is not counted); 0 means no limit. Typing stops at the cap. */
236
+ maxLength = input(0, ...(ngDevMode ? [{ debugName: "maxLength", transform: numberAttribute }] : [{ transform: numberAttribute }]));
237
+ /** Show the character counter (always shown with a [maxLength]). */
238
+ showCount = input(false, ...(ngDevMode ? [{ debugName: "showCount", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
239
+ /** `filled`: a borderless field on a muted ground, like `sds-textarea`'s. */
240
+ appearance = input('default', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
241
+ /** Per-instance strings (merged over {@link SDS_RICH_TEXT_LABELS} and the English defaults). */
242
+ labels = input({}, ...(ngDevMode ? [{ debugName: "labels" }] : []));
243
+ cdr = inject(ChangeDetectorRef);
244
+ host = inject(ElementRef);
245
+ appLabels = inject(SDS_RICH_TEXT_LABELS);
246
+ contentHost = viewChild.required('content');
247
+ linkInput = viewChild('linkInput', ...(ngDevMode ? [{ debugName: "linkInput" }] : []));
248
+ toolButtons = viewChildren('toolButton', ...(ngDevMode ? [{ debugName: "toolButtons" }] : []));
249
+ toolbarEnd = contentChild(SdsRichTextToolbarEnd, ...(ngDevMode ? [{ debugName: "toolbarEnd" }] : []));
250
+ editor = null;
251
+ /** The HTML last written to or emitted by the control — '' for an empty document. */
252
+ html = '';
253
+ builtTools = '';
254
+ ready = signal(false, ...(ngDevMode ? [{ debugName: "ready" }] : []));
255
+ cvaDisabled = signal(false, ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : []));
256
+ /** Formatting active at the selection, per tool (refreshed on every transaction). */
257
+ activeTools = signal(new Set(), ...(ngDevMode ? [{ debugName: "activeTools" }] : []));
258
+ history = signal({ undo: false, redo: false }, ...(ngDevMode ? [{ debugName: "history" }] : []));
259
+ /** The first row of the table holding the caret is a header row. */
260
+ headerRow = signal(false, ...(ngDevMode ? [{ debugName: "headerRow" }] : []));
261
+ /** Elements of the loaded HTML the enabled tools could not keep (see {@link LOSSY_TAGS}). */
262
+ lostElements = signal([], ...(ngDevMode ? [{ debugName: "lostElements" }] : []));
263
+ tableActions = TABLE_ACTIONS;
264
+ characters = signal(0, ...(ngDevMode ? [{ debugName: "characters" }] : []));
265
+ rovingTool = signal(null, ...(ngDevMode ? [{ debugName: "rovingTool" }] : []));
266
+ linkOpen = signal(false, ...(ngDevMode ? [{ debugName: "linkOpen" }] : []));
267
+ linkDraft = signal('', ...(ngDevMode ? [{ debugName: "linkDraft" }] : []));
268
+ linkError = signal(false, ...(ngDevMode ? [{ debugName: "linkError" }] : []));
269
+ l = computed(() => ({
270
+ ...SDS_RICH_TEXT_LABELS_EN,
271
+ ...this.appLabels,
272
+ ...this.labels(),
273
+ }), ...(ngDevMode ? [{ debugName: "l" }] : []));
274
+ isDisabled = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
275
+ /** The document can be changed (and the toolbar used). */
276
+ interactive = computed(() => !this.isDisabled() && !this.readonly(), ...(ngDevMode ? [{ debugName: "interactive" }] : []));
277
+ showToolbar = computed(() => !this.readonly() && this.groups().length > 0, ...(ngDevMode ? [{ debugName: "showToolbar" }] : []));
278
+ toolSet = computed(() => new Set(this.tools()), ...(ngDevMode ? [{ debugName: "toolSet" }] : []));
279
+ groups = computed(() => {
280
+ const enabled = this.toolSet();
281
+ const byGroup = new Map();
282
+ for (const def of TOOL_DEFS) {
283
+ if (enabled.has(def.tool)) {
284
+ byGroup.set(def.group, [...(byGroup.get(def.group) ?? []), def]);
285
+ }
286
+ }
287
+ return [...byGroup.entries()].sort(([a], [b]) => a - b).map(([, defs]) => defs);
288
+ }, ...(ngDevMode ? [{ debugName: "groups" }] : []));
289
+ hasCounter = computed(() => this.showCount() || this.maxLength() > 0, ...(ngDevMode ? [{ debugName: "hasCounter" }] : []));
290
+ lostLabel = computed(() => this.l().unsupportedContent.replace('{elements}', this.lostElements()
291
+ .map((tag) => `<${tag}>`)
292
+ .join(', ')), ...(ngDevMode ? [{ debugName: "lostLabel" }] : []));
293
+ atLimit = computed(() => this.maxLength() > 0 && this.characters() >= this.maxLength(), ...(ngDevMode ? [{ debugName: "atLimit" }] : []));
294
+ countLabel = computed(() => (this.maxLength() ? this.l().countLimit : this.l().count)
295
+ .replace('{count}', String(this.characters()))
296
+ .replace('{max}', String(this.maxLength())), ...(ngDevMode ? [{ debugName: "countLabel" }] : []));
297
+ maxHeight = computed(() => this.maxRows() > 0 ? `calc(${this.maxRows() * LINE_EM}em + 1.25rem)` : null, ...(ngDevMode ? [{ debugName: "maxHeight" }] : []));
298
+ danger = computed(() => !!this.error() || this.invalid(), ...(ngDevMode ? [{ debugName: "danger" }] : []));
299
+ frameClasses = computed(() => {
300
+ const parts = [FRAME_BASE];
301
+ if (this.appearance() === 'filled') {
302
+ parts.push('bg-muted');
303
+ parts.push(this.danger()
304
+ ? 'border-danger focus-within:ring-2 focus-within:ring-danger/30'
305
+ : 'border-transparent focus-within:ring-2 focus-within:ring-brand-500/25');
306
+ }
307
+ else {
308
+ parts.push('bg-surface');
309
+ parts.push(this.danger()
310
+ ? 'border-danger focus-within:border-danger focus-within:ring-2 focus-within:ring-danger/25'
311
+ : 'border-border focus-within:border-brand-500 focus-within:ring-2 focus-within:ring-brand-500/25');
312
+ }
313
+ if (this.isDisabled()) {
314
+ parts.push('opacity-60 cursor-not-allowed');
315
+ }
316
+ return parts.join(' ');
317
+ }, ...(ngDevMode ? [{ debugName: "frameClasses" }] : []));
318
+ describedBy = computed(() => {
319
+ const ids = [];
320
+ if (this.error())
321
+ ids.push(`${this.fieldId}-err`);
322
+ else if (this.hint())
323
+ ids.push(`${this.fieldId}-hint`);
324
+ if (this.hasCounter())
325
+ ids.push(`${this.fieldId}-count`);
326
+ if (this.lostElements().length)
327
+ ids.push(`${this.fieldId}-lost`);
328
+ return ids.join(' ');
329
+ }, ...(ngDevMode ? [{ debugName: "describedBy" }] : []));
330
+ /** Attributes of the contenteditable document (ProseMirror's own element). */
331
+ documentAttributes = computed(() => {
332
+ const attrs = {
333
+ id: this.fieldId,
334
+ class: 'sds-rte-content',
335
+ role: 'textbox',
336
+ 'aria-multiline': 'true',
337
+ style: `min-height: calc(${Math.max(1, this.rows()) * LINE_EM}em + 1.25rem)`,
338
+ };
339
+ if (this.label())
340
+ attrs['aria-labelledby'] = `${this.fieldId}-label`;
341
+ else if (this.ariaLabel())
342
+ attrs['aria-label'] = this.ariaLabel();
343
+ if (this.describedBy())
344
+ attrs['aria-describedby'] = this.describedBy();
345
+ if (this.danger())
346
+ attrs['aria-invalid'] = 'true';
347
+ if (this.contentLanguage())
348
+ attrs['lang'] = this.contentLanguage();
349
+ if (this.required())
350
+ attrs['aria-required'] = 'true';
351
+ if (this.readonly())
352
+ attrs['aria-readonly'] = 'true';
353
+ if (this.isDisabled())
354
+ attrs['aria-disabled'] = 'true';
355
+ return attrs;
356
+ }, ...(ngDevMode ? [{ debugName: "documentAttributes" }] : []));
357
+ isApple = typeof navigator !== 'undefined' &&
358
+ /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
359
+ constructor() {
360
+ // The editor needs the DOM: create it after the first render, in the browser only (SSR-safe).
361
+ afterNextRender(() => this.build());
362
+ effect(() => {
363
+ const props = this.editorProps(this.documentAttributes());
364
+ if (this.ready()) {
365
+ untracked(() => this.editor?.setOptions({ editorProps: props }));
366
+ }
367
+ });
368
+ effect(() => {
369
+ const editable = this.interactive();
370
+ if (this.ready()) {
371
+ untracked(() => {
372
+ this.editor?.setEditable(editable, false);
373
+ if (!editable)
374
+ this.linkOpen.set(false);
375
+ });
376
+ }
377
+ });
378
+ // A new tool set (or a new length cap) changes the schema: rebuild, keeping the content.
379
+ effect(() => {
380
+ const key = JSON.stringify([this.tools(), this.maxLength()]);
381
+ if (this.ready() && key !== this.builtTools) {
382
+ untracked(() => this.build());
383
+ }
384
+ });
385
+ effect(() => {
386
+ // Keep one toolbar button tabbable: the first enabled one unless the user moved.
387
+ const first = this.groups()[0]?.[0]?.tool ?? null;
388
+ const current = untracked(this.rovingTool);
389
+ if (!current || !this.toolSet().has(current))
390
+ this.rovingTool.set(first);
391
+ });
392
+ inject(DestroyRef).onDestroy(() => {
393
+ this.editor?.destroy();
394
+ this.editor = null;
395
+ });
396
+ }
397
+ /** Move the caret into the document. */
398
+ focus() {
399
+ this.editor?.commands.focus();
400
+ }
401
+ /**
402
+ * Insert plain text at the caret (replacing the selection) — for an "insert variable" button in the
403
+ * {@link SdsRichTextToolbarEnd} slot, say. The text is inserted as text, never parsed as HTML. No-op while
404
+ * disabled or read-only.
405
+ */
406
+ insertText(text) {
407
+ if (!this.editor || !this.interactive() || !text)
408
+ return;
409
+ this.editor.chain().focus().insertContent({ type: 'text', text }).run();
410
+ }
411
+ // --- ControlValueAccessor -------------------------------------------------
412
+ onChange = () => { };
413
+ onTouched = () => { };
414
+ writeValue(value) {
415
+ this.html = value ?? '';
416
+ const editor = this.editor;
417
+ if (editor && this.currentHtml(editor) !== this.html) {
418
+ // A value written by the form is the starting point, not an edit: keep it out of the undo history,
419
+ // or ⌘Z right after loading would empty the document.
420
+ editor
421
+ .chain()
422
+ .setMeta('addToHistory', false)
423
+ .setContent(this.html, { emitUpdate: false })
424
+ .run();
425
+ this.refresh(editor);
426
+ }
427
+ if (editor)
428
+ this.detectLoss(this.html, editor);
429
+ }
430
+ registerOnChange(fn) {
431
+ this.onChange = fn;
432
+ }
433
+ registerOnTouched(fn) {
434
+ this.onTouched = fn;
435
+ }
436
+ setDisabledState(isDisabled) {
437
+ this.cvaDisabled.set(isDisabled);
438
+ }
439
+ // --- toolbar ----------------------------------------------------------------
440
+ isOn(tool) {
441
+ return this.activeTools().has(tool);
442
+ }
443
+ canRun(tool) {
444
+ if (tool === 'undo')
445
+ return this.history().undo;
446
+ if (tool === 'redo')
447
+ return this.history().redo;
448
+ // no table inside a table
449
+ if (tool === 'table')
450
+ return !this.isOn('table');
451
+ return true;
452
+ }
453
+ buttonClass(def) {
454
+ return def.toggle && this.isOn(def.tool) ? `${TOOL_BUTTON} ${TOOL_BUTTON_ON}` : TOOL_BUTTON;
455
+ }
456
+ tooltip(def) {
457
+ const name = this.l()[def.tool];
458
+ if (!def.shortcut)
459
+ return name;
460
+ const keys = def.shortcut
461
+ .split('-')
462
+ .map((k) => k === 'Mod'
463
+ ? this.isApple
464
+ ? '⌘'
465
+ : 'Ctrl'
466
+ : k === 'Alt'
467
+ ? this.isApple
468
+ ? '⌥'
469
+ : 'Alt'
470
+ : k === 'Shift'
471
+ ? this.isApple
472
+ ? '⇧'
473
+ : 'Shift'
474
+ : k);
475
+ return `${name} (${keys.join(this.isApple ? '' : '+')})`;
476
+ }
477
+ run(tool) {
478
+ const editor = this.editor;
479
+ if (!editor || !this.interactive())
480
+ return;
481
+ const chain = editor.chain().focus();
482
+ switch (tool) {
483
+ case 'paragraph':
484
+ chain.setParagraph().run();
485
+ break;
486
+ case 'heading2':
487
+ chain.toggleHeading({ level: 2 }).run();
488
+ break;
489
+ case 'heading3':
490
+ chain.toggleHeading({ level: 3 }).run();
491
+ break;
492
+ case 'bold':
493
+ chain.toggleBold().run();
494
+ break;
495
+ case 'italic':
496
+ chain.toggleItalic().run();
497
+ break;
498
+ case 'underline':
499
+ chain.toggleUnderline().run();
500
+ break;
501
+ case 'strike':
502
+ chain.toggleStrike().run();
503
+ break;
504
+ case 'bulletList':
505
+ chain.toggleBulletList().run();
506
+ break;
507
+ case 'orderedList':
508
+ chain.toggleOrderedList().run();
509
+ break;
510
+ case 'blockquote':
511
+ chain.toggleBlockquote().run();
512
+ break;
513
+ case 'horizontalRule':
514
+ chain.setHorizontalRule().run();
515
+ break;
516
+ case 'table':
517
+ chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
518
+ break;
519
+ case 'clearFormatting':
520
+ chain.unsetAllMarks().clearNodes().run();
521
+ break;
522
+ case 'undo':
523
+ chain.undo().run();
524
+ break;
525
+ case 'redo':
526
+ chain.redo().run();
527
+ break;
528
+ case 'link':
529
+ this.openLink();
530
+ break;
531
+ }
532
+ }
533
+ /** Roving focus inside the toolbar: one Tab stop, arrows / Home / End move between the buttons. */
534
+ onToolbarKeydown(event) {
535
+ const buttons = this.toolButtons()
536
+ .map((b) => b.nativeElement)
537
+ .filter((b) => !b.disabled);
538
+ const index = buttons.indexOf(document.activeElement);
539
+ if (index < 0)
540
+ return;
541
+ let next = -1;
542
+ if (event.key === 'ArrowRight')
543
+ next = (index + 1) % buttons.length;
544
+ else if (event.key === 'ArrowLeft')
545
+ next = (index - 1 + buttons.length) % buttons.length;
546
+ else if (event.key === 'Home')
547
+ next = 0;
548
+ else if (event.key === 'End')
549
+ next = buttons.length - 1;
550
+ if (next >= 0) {
551
+ event.preventDefault();
552
+ buttons[next].focus();
553
+ }
554
+ }
555
+ /** A click in the blank area under the last line still places the caret (at the end). */
556
+ onAreaClick(event) {
557
+ const editor = this.editor;
558
+ if (editor && this.interactive() && event.target === this.contentHost().nativeElement) {
559
+ editor.commands.focus('end');
560
+ }
561
+ }
562
+ tableButtonClass(action) {
563
+ const base = 'inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium transition-colors ' +
564
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500';
565
+ if (action === 'headerRow' && this.headerRow())
566
+ return `${base} ${TOOL_BUTTON_ON}`;
567
+ if (action === 'delete')
568
+ return `${base} text-danger hover:bg-danger/10`;
569
+ return `${base} text-ink-soft hover:bg-muted hover:text-ink`;
570
+ }
571
+ runTable(action) {
572
+ const editor = this.editor;
573
+ if (!editor || !this.interactive())
574
+ return;
575
+ const chain = editor.chain().focus();
576
+ switch (action) {
577
+ case 'addRow':
578
+ chain.addRowAfter().run();
579
+ break;
580
+ case 'addColumn':
581
+ chain.addColumnAfter().run();
582
+ break;
583
+ case 'deleteRow':
584
+ chain.deleteRow().run();
585
+ break;
586
+ case 'deleteColumn':
587
+ chain.deleteColumn().run();
588
+ break;
589
+ case 'headerRow':
590
+ chain.toggleHeaderRow().run();
591
+ break;
592
+ case 'delete':
593
+ chain.deleteTable().run();
594
+ break;
595
+ }
596
+ }
597
+ // --- link -------------------------------------------------------------------
598
+ openLink() {
599
+ const editor = this.editor;
600
+ if (!editor || !this.interactive() || !this.toolSet().has('link'))
601
+ return;
602
+ this.linkDraft.set(editor.getAttributes('link')['href'] ?? '');
603
+ this.linkError.set(false);
604
+ this.linkOpen.set(true);
605
+ // Render the field and move focus into it NOW: deferring to the next render lets keystrokes typed
606
+ // right after ⌘K land in the document and replace the selection.
607
+ this.cdr.detectChanges();
608
+ this.linkInput()?.nativeElement.focus();
609
+ }
610
+ onLinkInput(event) {
611
+ this.linkDraft.set(event.target.value);
612
+ this.linkError.set(false);
613
+ }
614
+ onLinkKeydown(event) {
615
+ if (event.key === 'Enter') {
616
+ // Never let Enter submit a surrounding form.
617
+ event.preventDefault();
618
+ this.applyLink();
619
+ }
620
+ else if (event.key === 'Escape') {
621
+ event.preventDefault();
622
+ event.stopPropagation();
623
+ this.closeLink(true);
624
+ }
625
+ }
626
+ applyLink() {
627
+ const editor = this.editor;
628
+ if (!editor)
629
+ return;
630
+ const href = sdsSafeHref(this.linkDraft());
631
+ if (!href) {
632
+ this.linkError.set(true);
633
+ this.linkInput()?.nativeElement.focus();
634
+ return;
635
+ }
636
+ if (editor.state.selection.empty && !editor.isActive('link')) {
637
+ // Nothing selected: insert the address itself as the link text.
638
+ const text = href.replace(/^mailto:/i, '').replace(/^tel:/i, '');
639
+ editor
640
+ .chain()
641
+ .focus()
642
+ .insertContent({ type: 'text', text, marks: [{ type: 'link', attrs: { href } }] })
643
+ .run();
644
+ }
645
+ else {
646
+ editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
647
+ }
648
+ this.linkOpen.set(false);
649
+ }
650
+ removeLink() {
651
+ this.editor?.chain().focus().extendMarkRange('link').unsetLink().run();
652
+ this.linkOpen.set(false);
653
+ }
654
+ closeLink(refocus) {
655
+ this.linkOpen.set(false);
656
+ if (refocus)
657
+ this.editor?.commands.focus();
658
+ }
659
+ // --- editor -----------------------------------------------------------------
660
+ build() {
661
+ const element = this.contentHost().nativeElement;
662
+ const content = this.editor ? this.currentHtml(this.editor) : this.html;
663
+ this.editor?.destroy();
664
+ element.replaceChildren();
665
+ this.builtTools = JSON.stringify([this.tools(), this.maxLength()]);
666
+ const editor = new Editor({
667
+ element,
668
+ extensions: this.extensions(),
669
+ content,
670
+ editable: this.interactive(),
671
+ editorProps: this.editorProps(this.documentAttributes()),
672
+ onUpdate: ({ editor }) => this.emit(editor),
673
+ onTransaction: ({ editor }) => this.refresh(editor),
674
+ onBlur: ({ event }) => {
675
+ const next = event.relatedTarget;
676
+ if (!next || !this.host.nativeElement.contains(next))
677
+ this.onTouched();
678
+ },
679
+ });
680
+ this.editor = editor;
681
+ this.refresh(editor);
682
+ this.detectLoss(content, editor);
683
+ this.ready.set(true);
684
+ }
685
+ extensions() {
686
+ const tools = this.toolSet();
687
+ const on = (tool) => (tools.has(tool) ? {} : false);
688
+ const levels = [2, 3].filter((level) => tools.has(`heading${level}`));
689
+ const lists = tools.has('bulletList') || tools.has('orderedList');
690
+ return [
691
+ StarterKit.configure({
692
+ heading: levels.length ? { levels: [...levels] } : false,
693
+ bold: on('bold'),
694
+ italic: on('italic'),
695
+ underline: on('underline'),
696
+ strike: on('strike'),
697
+ bulletList: on('bulletList'),
698
+ orderedList: on('orderedList'),
699
+ listItem: lists ? {} : false,
700
+ listKeymap: lists ? {} : false,
701
+ blockquote: on('blockquote'),
702
+ horizontalRule: on('horizontalRule'),
703
+ code: false,
704
+ codeBlock: false,
705
+ link: tools.has('link')
706
+ ? {
707
+ openOnClick: false,
708
+ autolink: true,
709
+ linkOnPaste: true,
710
+ defaultProtocol: 'https',
711
+ isAllowedUri: (url) => sdsSafeHref(url) === url.trim(),
712
+ HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow' },
713
+ }
714
+ : false,
715
+ }),
716
+ ...(tools.has('table') ? [TableKit.configure({ table: { resizable: false } })] : []),
717
+ Placeholder.configure({ placeholder: () => this.placeholder() }),
718
+ CharacterCount.configure({ limit: this.maxLength() > 0 ? this.maxLength() : null }),
719
+ ];
720
+ }
721
+ editorProps(attributes) {
722
+ return {
723
+ attributes,
724
+ handleKeyDown: (_view, event) => this.onDocumentKeydown(event),
725
+ };
726
+ }
727
+ /**
728
+ * ⌘/Ctrl+K opens the link field. The editor owns the shortcut while it has focus: the event is stopped
729
+ * so an app-wide ⌘K (a command palette such as `sds-search-command`) does not open on top of it.
730
+ */
731
+ onDocumentKeydown(event) {
732
+ const modK = (event.metaKey || event.ctrlKey) &&
733
+ !event.altKey &&
734
+ !event.shiftKey &&
735
+ event.key.toLowerCase() === 'k';
736
+ if (!modK || !this.toolSet().has('link'))
737
+ return false;
738
+ event.preventDefault();
739
+ event.stopPropagation();
740
+ this.openLink();
741
+ return true;
742
+ }
743
+ /**
744
+ * The control's value for the editor's document: '' when it is empty, and without the editor's own
745
+ * scaffolding — the empty paragraphs kept after a final list / quote / table so the caret can leave it
746
+ * (Tiptap's trailing node), and the table layout hints (`<colgroup>`, `style="min-width…"`, the default
747
+ * `colspan="1" rowspan="1"`) that columns no one resized do not need.
748
+ */
749
+ currentHtml(editor) {
750
+ if (isBlank(editor))
751
+ return '';
752
+ return editor
753
+ .getHTML()
754
+ .replace(/<colgroup>[\s\S]*?<\/colgroup>/g, '')
755
+ .replace(/<table style="[^"]*">/g, '<table>')
756
+ .replace(/ (?:colspan|rowspan)="1"/g, '')
757
+ .replace(/(?:<p><\/p>)+$/, '');
758
+ }
759
+ /** Flags the elements of `source` whose content the schema could not keep (shown under the field). */
760
+ detectLoss(source, editor) {
761
+ if (!source || typeof DOMParser === 'undefined') {
762
+ this.lostElements.set([]);
763
+ return;
764
+ }
765
+ const kept = tagsOf(editor.getHTML());
766
+ this.lostElements.set([...tagsOf(source)].filter((tag) => LOSSY_TAGS.has(tag) && !kept.has(tag)));
767
+ }
768
+ emit(editor) {
769
+ const html = this.currentHtml(editor);
770
+ if (html !== this.html) {
771
+ this.html = html;
772
+ this.onChange(html);
773
+ }
774
+ }
775
+ refresh(editor) {
776
+ const active = new Set();
777
+ const check = (tool, name, attrs) => {
778
+ if (this.toolSet().has(tool) && editor.isActive(name, attrs))
779
+ active.add(tool);
780
+ };
781
+ check('heading2', 'heading', { level: 2 });
782
+ check('heading3', 'heading', { level: 3 });
783
+ check('paragraph', 'paragraph');
784
+ check('bold', 'bold');
785
+ check('italic', 'italic');
786
+ check('underline', 'underline');
787
+ check('strike', 'strike');
788
+ check('bulletList', 'bulletList');
789
+ check('orderedList', 'orderedList');
790
+ check('blockquote', 'blockquote');
791
+ check('link', 'link');
792
+ check('table', 'table');
793
+ this.activeTools.set(active);
794
+ this.headerRow.set(active.has('table') && firstRowIsHeader(editor));
795
+ this.history.set({ undo: editor.can().undo(), redo: editor.can().redo() });
796
+ this.characters.set(editor.storage.characterCount.characters());
797
+ }
798
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsRichTextEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
799
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsRichTextEditor, isStandalone: true, selector: "sds-rich-text-editor", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, contentLanguage: { classPropertyName: "contentLanguage", publicName: "contentLanguage", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, tools: { classPropertyName: "tools", publicName: "tools", isSignal: true, isRequired: false, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: false, transformFunction: null }, maxRows: { classPropertyName: "maxRows", publicName: "maxRows", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, showCount: { classPropertyName: "showCount", publicName: "showCount", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block" }, providers: [
800
+ {
801
+ provide: NG_VALUE_ACCESSOR,
802
+ useExisting: forwardRef(() => SdsRichTextEditor),
803
+ multi: true,
804
+ },
805
+ ], queries: [{ propertyName: "toolbarEnd", first: true, predicate: SdsRichTextToolbarEnd, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "contentHost", first: true, predicate: ["content"], descendants: true, isSignal: true }, { propertyName: "linkInput", first: true, predicate: ["linkInput"], descendants: true, isSignal: true }, { propertyName: "toolButtons", predicate: ["toolButton"], descendants: true, isSignal: true }], ngImport: i0, template: `
806
+ @if (label()) {
807
+ <label
808
+ [id]="fieldId + '-label'"
809
+ class="mb-1.5 block text-sm font-medium text-ink"
810
+ (click)="focus()"
811
+ >{{ label() }}
812
+ @if (required()) {
813
+ <span class="ml-0.5 text-danger" aria-hidden="true">*</span>
814
+ }
815
+ </label>
816
+ }
817
+
818
+ <div [class]="frameClasses()">
819
+ @if (showToolbar() || toolbarEnd()) {
820
+ <div
821
+ [attr.role]="showToolbar() ? 'toolbar' : null"
822
+ [attr.aria-label]="showToolbar() ? l().toolbar : null"
823
+ [attr.aria-controls]="showToolbar() ? fieldId : null"
824
+ [attr.aria-disabled]="showToolbar() && !interactive() ? 'true' : null"
825
+ class="flex flex-wrap items-center gap-0.5 border-b border-border px-1.5 py-1"
826
+ [class.bg-muted]="appearance() === 'default'"
827
+ (keydown)="onToolbarKeydown($event)"
828
+ >
829
+ @for (group of showToolbar() ? groups() : []; track $index; let gi = $index) {
830
+ @if (gi > 0) {
831
+ <span class="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden="true"></span>
832
+ }
833
+ @for (def of group; track def.tool) {
834
+ <button
835
+ #toolButton
836
+ type="button"
837
+ [attr.data-tool]="def.tool"
838
+ [attr.tabindex]="def.tool === rovingTool() ? 0 : -1"
839
+ [attr.aria-pressed]="def.toggle ? (isOn(def.tool) ? 'true' : 'false') : null"
840
+ [attr.aria-label]="l()[def.tool]"
841
+ [attr.title]="tooltip(def)"
842
+ [disabled]="!interactive() || !canRun(def.tool)"
843
+ [class]="buttonClass(def)"
844
+ (mousedown)="$event.preventDefault()"
845
+ (click)="run(def.tool)"
846
+ (focus)="rovingTool.set(def.tool)"
847
+ >
848
+ <sds-icon [name]="def.icon" [size]="16" />
849
+ </button>
850
+ }
851
+ }
852
+ <div class="ml-auto flex shrink-0 items-center pl-1">
853
+ <ng-content select="[sdsRichTextToolbarEnd]" />
854
+ </div>
855
+ </div>
856
+ }
857
+
858
+ @if (linkOpen()) {
859
+ <div
860
+ role="group"
861
+ [attr.aria-label]="l().link"
862
+ class="flex flex-wrap items-center gap-2 border-b border-border bg-surface px-2 py-2"
863
+ >
864
+ <label class="sr-only" [for]="fieldId + '-url'">{{ l().linkUrl }}</label>
865
+ <input
866
+ #linkInput
867
+ [id]="fieldId + '-url'"
868
+ type="text"
869
+ inputmode="url"
870
+ autocomplete="url"
871
+ spellcheck="false"
872
+ [value]="linkDraft()"
873
+ [placeholder]="l().linkPlaceholder"
874
+ [attr.aria-invalid]="linkError() ? 'true' : null"
875
+ [attr.aria-describedby]="linkError() ? fieldId + '-url-err' : null"
876
+ class="h-8 min-w-0 flex-1 basis-48 rounded-control border bg-surface px-2.5 text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-2"
877
+ [class]="
878
+ linkError()
879
+ ? 'border-danger focus:ring-danger/25'
880
+ : 'border-border focus:border-brand-500 focus:ring-brand-500/25'
881
+ "
882
+ (input)="onLinkInput($event)"
883
+ (keydown)="onLinkKeydown($event)"
884
+ />
885
+ <sds-button size="sm" (click)="applyLink()">{{ l().linkApply }}</sds-button>
886
+ @if (isOn('link')) {
887
+ <sds-button size="sm" variant="ghost" (click)="removeLink()">
888
+ <sds-icon name="unlink" [size]="15" />
889
+ {{ l().linkRemove }}
890
+ </sds-button>
891
+ }
892
+ <sds-button size="sm" variant="ghost" (click)="closeLink(true)">{{
893
+ l().linkCancel
894
+ }}</sds-button>
895
+ @if (linkError()) {
896
+ <p [id]="fieldId + '-url-err'" class="basis-full text-xs font-medium text-danger">
897
+ {{ l().linkInvalid }}
898
+ </p>
899
+ }
900
+ </div>
901
+ }
902
+
903
+ @if (isOn('table') && interactive()) {
904
+ <div
905
+ role="toolbar"
906
+ [attr.aria-label]="l().tableTools"
907
+ class="flex flex-wrap items-center gap-0.5 border-b border-border bg-surface px-1.5 py-1"
908
+ >
909
+ @for (t of tableActions; track t.action) {
910
+ <button
911
+ type="button"
912
+ [attr.data-table-action]="t.action"
913
+ [attr.aria-pressed]="
914
+ t.action === 'headerRow' ? (headerRow() ? 'true' : 'false') : null
915
+ "
916
+ [class]="tableButtonClass(t.action)"
917
+ (mousedown)="$event.preventDefault()"
918
+ (click)="runTable(t.action)"
919
+ >
920
+ @if (t.icon) {
921
+ <sds-icon [name]="t.icon" [size]="14" />
922
+ }
923
+ {{ l()[t.label] }}
924
+ </button>
925
+ }
926
+ </div>
927
+ }
928
+
929
+ <div
930
+ #content
931
+ class="sds-rte"
932
+ [class.overflow-y-auto]="maxRows() > 0"
933
+ [style.max-height]="maxHeight()"
934
+ (click)="onAreaClick($event)"
935
+ ></div>
936
+ </div>
937
+
938
+ @if (lostElements().length) {
939
+ <p
940
+ [id]="fieldId + '-lost'"
941
+ class="mt-1.5 flex items-start gap-1.5 rounded-control border border-warning/40 bg-warning/10 px-2.5 py-1.5 text-xs text-ink"
942
+ >
943
+ <sds-icon name="triangle-alert" [size]="14" class="mt-px shrink-0 text-warning" />
944
+ <span>{{ lostLabel() }}</span>
945
+ </p>
946
+ }
947
+
948
+ @if (hasCounter()) {
949
+ <div class="mt-1.5 flex items-start gap-2 text-xs">
950
+ @if (error()) {
951
+ <p [id]="fieldId + '-err'" class="flex-1 font-medium text-danger">{{ error() }}</p>
952
+ } @else if (hint()) {
953
+ <p [id]="fieldId + '-hint'" class="flex-1 text-ink-faint">{{ hint() }}</p>
954
+ } @else {
955
+ <span class="flex-1"></span>
956
+ }
957
+ <span
958
+ [id]="fieldId + '-count'"
959
+ aria-live="polite"
960
+ [attr.aria-label]="countLabel()"
961
+ class="shrink-0 tabular-nums"
962
+ [class.text-ink-faint]="!atLimit()"
963
+ [class.text-danger]="atLimit()"
964
+ >{{ characters() }}{{ maxLength() ? ' / ' + maxLength() : '' }}</span
965
+ >
966
+ </div>
967
+ } @else if (error()) {
968
+ <p [id]="fieldId + '-err'" class="mt-1.5 text-xs font-medium text-danger">{{ error() }}</p>
969
+ } @else if (hint()) {
970
+ <p [id]="fieldId + '-hint'" class="mt-1.5 text-xs text-ink-faint">{{ hint() }}</p>
971
+ }
972
+ `, isInline: true, styles: [".sds-rte-content{outline:none;padding:.625rem .875rem;color:var(--color-ink);font-size:.875rem;line-height:1.6;overflow-wrap:anywhere;white-space:pre-wrap}.sds-rte-content>:first-child{margin-top:0}.sds-rte-content>:last-child{margin-bottom:0}.sds-rte-content p,.sds-rte-content ul,.sds-rte-content ol,.sds-rte-content blockquote{margin:0 0 .75em}.sds-rte-content h2{font-size:1.25em;font-weight:600;line-height:1.3;margin:1.1em 0 .5em}.sds-rte-content h3{font-size:1.1em;font-weight:600;line-height:1.35;margin:1em 0 .4em}.sds-rte-content ul{list-style:disc;padding-left:1.5em}.sds-rte-content ol{list-style:decimal;padding-left:1.5em}.sds-rte-content li>p{margin:0}.sds-rte-content li+li{margin-top:.25em}.sds-rte-content li>ul,.sds-rte-content li>ol{margin:.25em 0 0}.sds-rte-content blockquote{border-left:3px solid var(--color-border);padding-left:.875em;color:var(--color-ink-soft)}.sds-rte-content a{color:var(--color-brand-700, var(--color-accent-blue));text-decoration:underline;text-underline-offset:2px}.dark .sds-rte-content a{color:var(--sds-dark-on-tint, var(--color-ink))}.sds-rte{overflow-x:auto}.sds-rte-content table{border-collapse:collapse;width:100%;margin:0 0 .75em}.sds-rte-content th,.sds-rte-content td{border:1px solid var(--color-border);padding:.375em .5em;vertical-align:top;text-align:left;min-width:4em}.sds-rte-content th{background:var(--color-muted);font-weight:600}.sds-rte-content th>p,.sds-rte-content td>p{margin:0}.sds-rte-content .selectedCell{background:color-mix(in oklab,var(--color-brand-500, var(--color-accent-blue)) 14%,transparent)}.sds-rte-content hr{border:0;border-top:1px solid var(--color-border);margin:1em 0}.sds-rte-content hr.ProseMirror-selectednode{outline:2px solid var(--color-brand-500, var(--color-accent-blue));outline-offset:2px}.sds-rte-content p.is-editor-empty:first-child:before{content:attr(data-placeholder);color:var(--color-ink-faint);float:left;height:0;pointer-events:none}.sds-rte-content[contenteditable=false]{cursor:default}\n"], dependencies: [{ kind: "component", type: SdsIcon, selector: "sds-icon", inputs: ["name", "size", "strokeWidth", "filled"] }, { kind: "component", type: SdsButton, selector: "sds-button", inputs: ["variant", "size", "shape", "type", "disabled", "loading", "block", "iconOnly", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
973
+ }
974
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsRichTextEditor, decorators: [{
975
+ type: Component,
976
+ args: [{ selector: 'sds-rich-text-editor', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [SdsIcon, SdsButton], providers: [
977
+ {
978
+ provide: NG_VALUE_ACCESSOR,
979
+ useExisting: forwardRef(() => SdsRichTextEditor),
980
+ multi: true,
981
+ },
982
+ ], host: { class: 'block' }, template: `
983
+ @if (label()) {
984
+ <label
985
+ [id]="fieldId + '-label'"
986
+ class="mb-1.5 block text-sm font-medium text-ink"
987
+ (click)="focus()"
988
+ >{{ label() }}
989
+ @if (required()) {
990
+ <span class="ml-0.5 text-danger" aria-hidden="true">*</span>
991
+ }
992
+ </label>
993
+ }
994
+
995
+ <div [class]="frameClasses()">
996
+ @if (showToolbar() || toolbarEnd()) {
997
+ <div
998
+ [attr.role]="showToolbar() ? 'toolbar' : null"
999
+ [attr.aria-label]="showToolbar() ? l().toolbar : null"
1000
+ [attr.aria-controls]="showToolbar() ? fieldId : null"
1001
+ [attr.aria-disabled]="showToolbar() && !interactive() ? 'true' : null"
1002
+ class="flex flex-wrap items-center gap-0.5 border-b border-border px-1.5 py-1"
1003
+ [class.bg-muted]="appearance() === 'default'"
1004
+ (keydown)="onToolbarKeydown($event)"
1005
+ >
1006
+ @for (group of showToolbar() ? groups() : []; track $index; let gi = $index) {
1007
+ @if (gi > 0) {
1008
+ <span class="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden="true"></span>
1009
+ }
1010
+ @for (def of group; track def.tool) {
1011
+ <button
1012
+ #toolButton
1013
+ type="button"
1014
+ [attr.data-tool]="def.tool"
1015
+ [attr.tabindex]="def.tool === rovingTool() ? 0 : -1"
1016
+ [attr.aria-pressed]="def.toggle ? (isOn(def.tool) ? 'true' : 'false') : null"
1017
+ [attr.aria-label]="l()[def.tool]"
1018
+ [attr.title]="tooltip(def)"
1019
+ [disabled]="!interactive() || !canRun(def.tool)"
1020
+ [class]="buttonClass(def)"
1021
+ (mousedown)="$event.preventDefault()"
1022
+ (click)="run(def.tool)"
1023
+ (focus)="rovingTool.set(def.tool)"
1024
+ >
1025
+ <sds-icon [name]="def.icon" [size]="16" />
1026
+ </button>
1027
+ }
1028
+ }
1029
+ <div class="ml-auto flex shrink-0 items-center pl-1">
1030
+ <ng-content select="[sdsRichTextToolbarEnd]" />
1031
+ </div>
1032
+ </div>
1033
+ }
1034
+
1035
+ @if (linkOpen()) {
1036
+ <div
1037
+ role="group"
1038
+ [attr.aria-label]="l().link"
1039
+ class="flex flex-wrap items-center gap-2 border-b border-border bg-surface px-2 py-2"
1040
+ >
1041
+ <label class="sr-only" [for]="fieldId + '-url'">{{ l().linkUrl }}</label>
1042
+ <input
1043
+ #linkInput
1044
+ [id]="fieldId + '-url'"
1045
+ type="text"
1046
+ inputmode="url"
1047
+ autocomplete="url"
1048
+ spellcheck="false"
1049
+ [value]="linkDraft()"
1050
+ [placeholder]="l().linkPlaceholder"
1051
+ [attr.aria-invalid]="linkError() ? 'true' : null"
1052
+ [attr.aria-describedby]="linkError() ? fieldId + '-url-err' : null"
1053
+ class="h-8 min-w-0 flex-1 basis-48 rounded-control border bg-surface px-2.5 text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-2"
1054
+ [class]="
1055
+ linkError()
1056
+ ? 'border-danger focus:ring-danger/25'
1057
+ : 'border-border focus:border-brand-500 focus:ring-brand-500/25'
1058
+ "
1059
+ (input)="onLinkInput($event)"
1060
+ (keydown)="onLinkKeydown($event)"
1061
+ />
1062
+ <sds-button size="sm" (click)="applyLink()">{{ l().linkApply }}</sds-button>
1063
+ @if (isOn('link')) {
1064
+ <sds-button size="sm" variant="ghost" (click)="removeLink()">
1065
+ <sds-icon name="unlink" [size]="15" />
1066
+ {{ l().linkRemove }}
1067
+ </sds-button>
1068
+ }
1069
+ <sds-button size="sm" variant="ghost" (click)="closeLink(true)">{{
1070
+ l().linkCancel
1071
+ }}</sds-button>
1072
+ @if (linkError()) {
1073
+ <p [id]="fieldId + '-url-err'" class="basis-full text-xs font-medium text-danger">
1074
+ {{ l().linkInvalid }}
1075
+ </p>
1076
+ }
1077
+ </div>
1078
+ }
1079
+
1080
+ @if (isOn('table') && interactive()) {
1081
+ <div
1082
+ role="toolbar"
1083
+ [attr.aria-label]="l().tableTools"
1084
+ class="flex flex-wrap items-center gap-0.5 border-b border-border bg-surface px-1.5 py-1"
1085
+ >
1086
+ @for (t of tableActions; track t.action) {
1087
+ <button
1088
+ type="button"
1089
+ [attr.data-table-action]="t.action"
1090
+ [attr.aria-pressed]="
1091
+ t.action === 'headerRow' ? (headerRow() ? 'true' : 'false') : null
1092
+ "
1093
+ [class]="tableButtonClass(t.action)"
1094
+ (mousedown)="$event.preventDefault()"
1095
+ (click)="runTable(t.action)"
1096
+ >
1097
+ @if (t.icon) {
1098
+ <sds-icon [name]="t.icon" [size]="14" />
1099
+ }
1100
+ {{ l()[t.label] }}
1101
+ </button>
1102
+ }
1103
+ </div>
1104
+ }
1105
+
1106
+ <div
1107
+ #content
1108
+ class="sds-rte"
1109
+ [class.overflow-y-auto]="maxRows() > 0"
1110
+ [style.max-height]="maxHeight()"
1111
+ (click)="onAreaClick($event)"
1112
+ ></div>
1113
+ </div>
1114
+
1115
+ @if (lostElements().length) {
1116
+ <p
1117
+ [id]="fieldId + '-lost'"
1118
+ class="mt-1.5 flex items-start gap-1.5 rounded-control border border-warning/40 bg-warning/10 px-2.5 py-1.5 text-xs text-ink"
1119
+ >
1120
+ <sds-icon name="triangle-alert" [size]="14" class="mt-px shrink-0 text-warning" />
1121
+ <span>{{ lostLabel() }}</span>
1122
+ </p>
1123
+ }
1124
+
1125
+ @if (hasCounter()) {
1126
+ <div class="mt-1.5 flex items-start gap-2 text-xs">
1127
+ @if (error()) {
1128
+ <p [id]="fieldId + '-err'" class="flex-1 font-medium text-danger">{{ error() }}</p>
1129
+ } @else if (hint()) {
1130
+ <p [id]="fieldId + '-hint'" class="flex-1 text-ink-faint">{{ hint() }}</p>
1131
+ } @else {
1132
+ <span class="flex-1"></span>
1133
+ }
1134
+ <span
1135
+ [id]="fieldId + '-count'"
1136
+ aria-live="polite"
1137
+ [attr.aria-label]="countLabel()"
1138
+ class="shrink-0 tabular-nums"
1139
+ [class.text-ink-faint]="!atLimit()"
1140
+ [class.text-danger]="atLimit()"
1141
+ >{{ characters() }}{{ maxLength() ? ' / ' + maxLength() : '' }}</span
1142
+ >
1143
+ </div>
1144
+ } @else if (error()) {
1145
+ <p [id]="fieldId + '-err'" class="mt-1.5 text-xs font-medium text-danger">{{ error() }}</p>
1146
+ } @else if (hint()) {
1147
+ <p [id]="fieldId + '-hint'" class="mt-1.5 text-xs text-ink-faint">{{ hint() }}</p>
1148
+ }
1149
+ `, styles: [".sds-rte-content{outline:none;padding:.625rem .875rem;color:var(--color-ink);font-size:.875rem;line-height:1.6;overflow-wrap:anywhere;white-space:pre-wrap}.sds-rte-content>:first-child{margin-top:0}.sds-rte-content>:last-child{margin-bottom:0}.sds-rte-content p,.sds-rte-content ul,.sds-rte-content ol,.sds-rte-content blockquote{margin:0 0 .75em}.sds-rte-content h2{font-size:1.25em;font-weight:600;line-height:1.3;margin:1.1em 0 .5em}.sds-rte-content h3{font-size:1.1em;font-weight:600;line-height:1.35;margin:1em 0 .4em}.sds-rte-content ul{list-style:disc;padding-left:1.5em}.sds-rte-content ol{list-style:decimal;padding-left:1.5em}.sds-rte-content li>p{margin:0}.sds-rte-content li+li{margin-top:.25em}.sds-rte-content li>ul,.sds-rte-content li>ol{margin:.25em 0 0}.sds-rte-content blockquote{border-left:3px solid var(--color-border);padding-left:.875em;color:var(--color-ink-soft)}.sds-rte-content a{color:var(--color-brand-700, var(--color-accent-blue));text-decoration:underline;text-underline-offset:2px}.dark .sds-rte-content a{color:var(--sds-dark-on-tint, var(--color-ink))}.sds-rte{overflow-x:auto}.sds-rte-content table{border-collapse:collapse;width:100%;margin:0 0 .75em}.sds-rte-content th,.sds-rte-content td{border:1px solid var(--color-border);padding:.375em .5em;vertical-align:top;text-align:left;min-width:4em}.sds-rte-content th{background:var(--color-muted);font-weight:600}.sds-rte-content th>p,.sds-rte-content td>p{margin:0}.sds-rte-content .selectedCell{background:color-mix(in oklab,var(--color-brand-500, var(--color-accent-blue)) 14%,transparent)}.sds-rte-content hr{border:0;border-top:1px solid var(--color-border);margin:1em 0}.sds-rte-content hr.ProseMirror-selectednode{outline:2px solid var(--color-brand-500, var(--color-accent-blue));outline-offset:2px}.sds-rte-content p.is-editor-empty:first-child:before{content:attr(data-placeholder);color:var(--color-ink-faint);float:left;height:0;pointer-events:none}.sds-rte-content[contenteditable=false]{cursor:default}\n"] }]
1150
+ }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], contentLanguage: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentLanguage", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], tools: [{ type: i0.Input, args: [{ isSignal: true, alias: "tools", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], maxRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxRows", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", required: false }] }], showCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCount", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], contentHost: [{ type: i0.ViewChild, args: ['content', { isSignal: true }] }], linkInput: [{ type: i0.ViewChild, args: ['linkInput', { isSignal: true }] }], toolButtons: [{ type: i0.ViewChildren, args: ['toolButton', { isSignal: true }] }], toolbarEnd: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SdsRichTextToolbarEnd), { isSignal: true }] }] } });
1151
+ /** The tag names (lower case) of every element in an HTML fragment. */
1152
+ function tagsOf(html) {
1153
+ const body = new DOMParser().parseFromString(html, 'text/html').body;
1154
+ return new Set(Array.from(body.querySelectorAll('*'), (el) => el.tagName.toLowerCase()));
1155
+ }
1156
+ /** Whether the table around the selection starts with a row of header cells. */
1157
+ function firstRowIsHeader(editor) {
1158
+ const { $from } = editor.state.selection;
1159
+ for (let depth = $from.depth; depth > 0; depth--) {
1160
+ const node = $from.node(depth);
1161
+ if (node.type.name === 'table') {
1162
+ return node.firstChild?.firstChild?.type.name === 'tableHeader';
1163
+ }
1164
+ }
1165
+ return false;
1166
+ }
1167
+ /**
1168
+ * A document holding nothing but one empty paragraph. (Tiptap's `isEmpty` only looks at the text, so a fresh
1169
+ * empty table or a lone divider would count as empty and be dropped from the value.)
1170
+ */
1171
+ function isBlank(editor) {
1172
+ const doc = editor.state.doc;
1173
+ return (doc.childCount === 0 ||
1174
+ (doc.childCount === 1 &&
1175
+ doc.firstChild.type.name === 'paragraph' &&
1176
+ doc.firstChild.content.size === 0));
1177
+ }
1178
+
1179
+ let nextId = 0;
1180
+ const TAB_BASE = 'flex items-center gap-1 rounded px-1.5 py-1 text-[11px] font-semibold uppercase leading-none transition-colors ' +
1181
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ' +
1182
+ 'disabled:cursor-not-allowed disabled:opacity-50';
1183
+ const TAB_ON = 'bg-brand-50 text-brand-700 ' +
1184
+ 'dark:bg-[color:var(--sds-dark-tint,rgb(255_255_255/0.12))] ' +
1185
+ 'dark:text-[color:var(--sds-dark-on-tint,var(--color-ink))]';
1186
+ const TAB_OFF = 'text-ink-faint hover:bg-muted hover:text-ink-soft';
1187
+ /**
1188
+ * The translatable twin of {@link SdsRichTextEditor}: one rich-text field over a {@link SdsLocalizedValue} —
1189
+ * `{ fr: '<p>…</p>', de: '<p>…</p>', en: … }` — for content stored per language (course pages, homework
1190
+ * instructions, articles). It works like `sds-localized-input`: one language is edited at a time, a compact
1191
+ * switcher at the end of the toolbar flips the language, and a dot marks the languages that already have
1192
+ * content (green), a required one that is still empty (danger) or a missing translation (faint). Empty
1193
+ * translations are pruned from the emitted value, so an untouched language never appears in the map.
1194
+ *
1195
+ * Each language has its own editor underneath, kept alive while hidden: undo / redo, the caret and the
1196
+ * scroll position stay per language, and undoing after a switch can never bring another language's text
1197
+ * back. The document carries its `lang`, so the browser spell-checks each translation in its language.
1198
+ *
1199
+ * The active language defaults to {@link currentLanguage} when it is one of {@link languages}, otherwise the
1200
+ * first accepted language. Everything else — `[tools]` (the schema), safe links, `rows` / `maxRows`,
1201
+ * `maxLength` (per language), labels — behaves as in {@link SdsRichTextEditor}.
1202
+ *
1203
+ * Implements ControlValueAccessor over the map — use with reactive forms or `[(ngModel)]`:
1204
+ * `<sds-localized-rich-text-editor [languages]="['fr','de','en']" formControlName="description" />`.
1205
+ * Import from `@besaitech/ng-design-system/rich-text`.
1206
+ */
1207
+ class SdsLocalizedRichTextEditor {
1208
+ baseId = `sds-lrte-${nextId++}`;
1209
+ label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1210
+ /** Accessible name when there is no visible {@link label}; each language's editor adds its code. */
1211
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1212
+ placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1213
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
1214
+ /** Error message under the field (danger frame + `aria-invalid`); replaces the hint. */
1215
+ error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
1216
+ /** Mark the field invalid (danger frame + `aria-invalid`) without showing an [error] message. */
1217
+ invalid = input(false, ...(ngDevMode ? [{ debugName: "invalid", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
1218
+ /** An asterisk after the [label] and `aria-required`. It does not validate — keep your own validator. */
1219
+ required = input(false, ...(ngDevMode ? [{ debugName: "required", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
1220
+ /**
1221
+ * Languages that must be filled (e.g. the site's main language): while empty, their switcher dot shows in the
1222
+ * danger colour and its title says {@link requiredLanguageLabel}. Display only — validate in the form.
1223
+ */
1224
+ requiredLanguages = input([], ...(ngDevMode ? [{ debugName: "requiredLanguages" }] : []));
1225
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
1226
+ /** Every translation can be read (the switcher still works) but none changed; the toolbar is hidden. */
1227
+ readonly = input(false, ...(ngDevMode ? [{ debugName: "readonly", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
1228
+ /** The accepted language codes, in display order (e.g. `['fr','de','en']`). */
1229
+ languages = input([], ...(ngDevMode ? [{ debugName: "languages" }] : []));
1230
+ /** Language to open on; used when it is one of {@link languages}, else the first accepted language. */
1231
+ currentLanguage = input(null, ...(ngDevMode ? [{ debugName: "currentLanguage" }] : []));
1232
+ /** The toolbar and the schema, as on {@link SdsRichTextEditor}. */
1233
+ tools = input(SDS_RICH_TEXT_DEFAULT_TOOLS, ...(ngDevMode ? [{ debugName: "tools" }] : []));
1234
+ rows = input(6, ...(ngDevMode ? [{ debugName: "rows", transform: numberAttribute }] : [{ transform: numberAttribute }]));
1235
+ maxRows = input(0, ...(ngDevMode ? [{ debugName: "maxRows", transform: numberAttribute }] : [{ transform: numberAttribute }]));
1236
+ /** Cap on each language's plain-text length; 0 means no limit. */
1237
+ maxLength = input(0, ...(ngDevMode ? [{ debugName: "maxLength", transform: numberAttribute }] : [{ transform: numberAttribute }]));
1238
+ /** Show the character counter of the language being edited (always shown with a [maxLength]). */
1239
+ showCount = input(false, ...(ngDevMode ? [{ debugName: "showCount", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
1240
+ appearance = input('default', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
1241
+ /** Editor strings, as on {@link SdsRichTextEditor}. */
1242
+ labels = input({}, ...(ngDevMode ? [{ debugName: "labels" }] : []));
1243
+ /**
1244
+ * Accessible name of the language switcher, and the title suffixes of an empty language. English defaults;
1245
+ * translate them in a multi-language app (the tabs show only the language code).
1246
+ */
1247
+ languageSwitcherLabel = input('Language', ...(ngDevMode ? [{ debugName: "languageSwitcherLabel" }] : []));
1248
+ untranslatedLabel = input('not translated yet', ...(ngDevMode ? [{ debugName: "untranslatedLabel" }] : []));
1249
+ /** Title suffix of a required language that is still empty (see {@link requiredLanguages}). */
1250
+ requiredLanguageLabel = input('required', ...(ngDevMode ? [{ debugName: "requiredLanguageLabel" }] : []));
1251
+ host = inject(ElementRef);
1252
+ cdr = inject(ChangeDetectorRef);
1253
+ editors = viewChildren(SdsRichTextEditor, ...(ngDevMode ? [{ debugName: "editors" }] : []));
1254
+ valueMap = signal({}, ...(ngDevMode ? [{ debugName: "valueMap" }] : []));
1255
+ /** Language chosen with the switcher; null means "follow currentLanguage / the first language". */
1256
+ manual = signal(null, ...(ngDevMode ? [{ debugName: "manual" }] : []));
1257
+ cvaDisabled = signal(false, ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : []));
1258
+ isDisabled = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
1259
+ /** The language being edited. */
1260
+ activeLang = computed(() => {
1261
+ const langs = this.languages();
1262
+ if (!langs.length)
1263
+ return '';
1264
+ const manual = this.manual();
1265
+ if (manual && langs.includes(manual))
1266
+ return manual;
1267
+ const preferred = this.currentLanguage();
1268
+ return preferred && langs.includes(preferred) ? preferred : langs[0];
1269
+ }, ...(ngDevMode ? [{ debugName: "activeLang" }] : []));
1270
+ /** Move the caret into the active language's document. */
1271
+ focus() {
1272
+ this.editorFor(this.activeLang())?.focus();
1273
+ }
1274
+ filled(lang) {
1275
+ const html = this.valueMap()[lang];
1276
+ return !!html && html.trim().length > 0;
1277
+ }
1278
+ /** A required language (see {@link requiredLanguages}) that is still empty. */
1279
+ mustFill(lang) {
1280
+ return this.requiredLanguages().includes(lang) && !this.filled(lang);
1281
+ }
1282
+ editorName(lang) {
1283
+ const name = this.label() || this.ariaLabel();
1284
+ return name ? `${name} (${lang.toUpperCase()})` : lang.toUpperCase();
1285
+ }
1286
+ placeholderFor(lang) {
1287
+ const base = this.placeholder();
1288
+ return base ? `${base} (${lang.toUpperCase()})` : '';
1289
+ }
1290
+ tabTitle(lang) {
1291
+ const code = lang.toUpperCase();
1292
+ if (this.filled(lang))
1293
+ return code;
1294
+ return `${code} — ${this.mustFill(lang) ? this.requiredLanguageLabel() : this.untranslatedLabel()}`;
1295
+ }
1296
+ tabClass(lang) {
1297
+ return `${TAB_BASE} ${lang === this.activeLang() ? TAB_ON : TAB_OFF}`;
1298
+ }
1299
+ dotClass(lang) {
1300
+ return this.filled(lang) ? 'bg-success' : this.mustFill(lang) ? 'bg-danger' : 'bg-current/30';
1301
+ }
1302
+ /**
1303
+ * Switch language. From a click, the caret goes into the new language's document (ready to type); from the
1304
+ * arrow keys, focus follows to the new language's tab. Rendered at once so focus never lands on the
1305
+ * editor that was just hidden.
1306
+ */
1307
+ select(lang, focusTarget) {
1308
+ if (lang === this.activeLang()) {
1309
+ if (focusTarget === 'editor')
1310
+ this.focus();
1311
+ return;
1312
+ }
1313
+ this.manual.set(lang);
1314
+ // Switching language counts as touching the field, as in sds-localized-input.
1315
+ this.onTouched();
1316
+ this.cdr.detectChanges();
1317
+ if (focusTarget === 'editor') {
1318
+ this.editorFor(lang)?.focus();
1319
+ }
1320
+ else {
1321
+ this.host.nativeElement
1322
+ .querySelector(`sds-rich-text-editor[data-active-language] [role="tab"][data-lang="${CSS.escape(lang)}"]`)
1323
+ ?.focus();
1324
+ }
1325
+ }
1326
+ /** The switcher is one Tab stop; arrows / Home / End change the language (automatic activation). */
1327
+ onTabsKeydown(event) {
1328
+ const langs = this.languages();
1329
+ const index = langs.indexOf(this.activeLang());
1330
+ let next = -1;
1331
+ if (event.key === 'ArrowRight')
1332
+ next = (index + 1) % langs.length;
1333
+ else if (event.key === 'ArrowLeft')
1334
+ next = (index - 1 + langs.length) % langs.length;
1335
+ else if (event.key === 'Home')
1336
+ next = 0;
1337
+ else if (event.key === 'End')
1338
+ next = langs.length - 1;
1339
+ if (next >= 0 && !this.isDisabled()) {
1340
+ event.preventDefault();
1341
+ event.stopPropagation();
1342
+ this.select(langs[next], 'tab');
1343
+ }
1344
+ }
1345
+ write(lang, html) {
1346
+ const current = this.valueMap()[lang] ?? '';
1347
+ if ((html ?? '') === current)
1348
+ return;
1349
+ const next = { ...this.valueMap() };
1350
+ if (html) {
1351
+ next[lang] = html;
1352
+ }
1353
+ else {
1354
+ delete next[lang];
1355
+ }
1356
+ this.valueMap.set(next);
1357
+ this.onChange(next);
1358
+ }
1359
+ /** Touched once focus leaves the whole field (moving between toolbar, link field and document is not leaving). */
1360
+ onFocusOut(event) {
1361
+ const next = event.relatedTarget;
1362
+ if (!next || !this.host.nativeElement.contains(next))
1363
+ this.onTouched();
1364
+ }
1365
+ editorFor(lang) {
1366
+ return this.editors()[this.languages().indexOf(lang)];
1367
+ }
1368
+ // --- ControlValueAccessor -------------------------------------------------
1369
+ onChange = () => { };
1370
+ onTouched = () => { };
1371
+ writeValue(value) {
1372
+ this.valueMap.set(value ? { ...value } : {});
1373
+ }
1374
+ registerOnChange(fn) {
1375
+ this.onChange = fn;
1376
+ }
1377
+ registerOnTouched(fn) {
1378
+ this.onTouched = fn;
1379
+ }
1380
+ setDisabledState(isDisabled) {
1381
+ this.cvaDisabled.set(isDisabled);
1382
+ }
1383
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsLocalizedRichTextEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
1384
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsLocalizedRichTextEditor, isStandalone: true, selector: "sds-localized-rich-text-editor", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, requiredLanguages: { classPropertyName: "requiredLanguages", publicName: "requiredLanguages", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, languages: { classPropertyName: "languages", publicName: "languages", isSignal: true, isRequired: false, transformFunction: null }, currentLanguage: { classPropertyName: "currentLanguage", publicName: "currentLanguage", isSignal: true, isRequired: false, transformFunction: null }, tools: { classPropertyName: "tools", publicName: "tools", isSignal: true, isRequired: false, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: false, transformFunction: null }, maxRows: { classPropertyName: "maxRows", publicName: "maxRows", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, showCount: { classPropertyName: "showCount", publicName: "showCount", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, languageSwitcherLabel: { classPropertyName: "languageSwitcherLabel", publicName: "languageSwitcherLabel", isSignal: true, isRequired: false, transformFunction: null }, untranslatedLabel: { classPropertyName: "untranslatedLabel", publicName: "untranslatedLabel", isSignal: true, isRequired: false, transformFunction: null }, requiredLanguageLabel: { classPropertyName: "requiredLanguageLabel", publicName: "requiredLanguageLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "focusout": "onFocusOut($event)" }, classAttribute: "block" }, providers: [
1385
+ {
1386
+ provide: NG_VALUE_ACCESSOR,
1387
+ useExisting: forwardRef(() => SdsLocalizedRichTextEditor),
1388
+ multi: true,
1389
+ },
1390
+ ], viewQueries: [{ propertyName: "editors", predicate: SdsRichTextEditor, descendants: true, isSignal: true }], ngImport: i0, template: `
1391
+ @if (label()) {
1392
+ <label
1393
+ [id]="baseId + '-label'"
1394
+ class="mb-1.5 block text-sm font-medium text-ink"
1395
+ (click)="focus()"
1396
+ >{{ label() }}
1397
+ @if (required()) {
1398
+ <span class="ml-0.5 text-danger" aria-hidden="true">*</span>
1399
+ }
1400
+ </label>
1401
+ }
1402
+
1403
+ @for (lang of languages(); track lang) {
1404
+ <sds-rich-text-editor
1405
+ [style.display]="lang === activeLang() ? null : 'none'"
1406
+ [attr.data-active-language]="lang === activeLang() ? '' : null"
1407
+ [ariaLabel]="editorName(lang)"
1408
+ [contentLanguage]="lang"
1409
+ [placeholder]="placeholderFor(lang)"
1410
+ [hint]="hint()"
1411
+ [error]="error()"
1412
+ [invalid]="invalid()"
1413
+ [required]="required()"
1414
+ [disabled]="isDisabled()"
1415
+ [readonly]="readonly()"
1416
+ [tools]="tools()"
1417
+ [rows]="rows()"
1418
+ [maxRows]="maxRows()"
1419
+ [maxLength]="maxLength()"
1420
+ [showCount]="showCount()"
1421
+ [appearance]="appearance()"
1422
+ [labels]="labels()"
1423
+ [ngModel]="valueMap()[lang] ?? ''"
1424
+ [ngModelOptions]="{ standalone: true }"
1425
+ (ngModelChange)="write(lang, $event)"
1426
+ >
1427
+ @if (languages().length > 1) {
1428
+ <div
1429
+ sdsRichTextToolbarEnd
1430
+ role="tablist"
1431
+ class="flex items-center gap-0.5"
1432
+ [attr.aria-label]="languageSwitcherLabel()"
1433
+ (keydown)="onTabsKeydown($event)"
1434
+ >
1435
+ @for (code of languages(); track code) {
1436
+ <button
1437
+ type="button"
1438
+ role="tab"
1439
+ [attr.data-lang]="code"
1440
+ [attr.aria-selected]="code === activeLang() ? 'true' : 'false'"
1441
+ [attr.tabindex]="code === activeLang() ? 0 : -1"
1442
+ [attr.title]="tabTitle(code)"
1443
+ [disabled]="isDisabled()"
1444
+ [class]="tabClass(code)"
1445
+ (mousedown)="$event.preventDefault()"
1446
+ (click)="select(code, 'editor')"
1447
+ >
1448
+ {{ code }}
1449
+ <span class="inline-block h-1.5 w-1.5 rounded-full" [class]="dotClass(code)"></span>
1450
+ </button>
1451
+ }
1452
+ </div>
1453
+ }
1454
+ </sds-rich-text-editor>
1455
+ }
1456
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: SdsRichTextEditor, selector: "sds-rich-text-editor", inputs: ["label", "ariaLabel", "placeholder", "hint", "error", "invalid", "contentLanguage", "required", "disabled", "readonly", "tools", "rows", "maxRows", "maxLength", "showCount", "appearance", "labels"] }, { kind: "directive", type: SdsRichTextToolbarEnd, selector: "[sdsRichTextToolbarEnd]" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1457
+ }
1458
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsLocalizedRichTextEditor, decorators: [{
1459
+ type: Component,
1460
+ args: [{
1461
+ selector: 'sds-localized-rich-text-editor',
1462
+ changeDetection: ChangeDetectionStrategy.OnPush,
1463
+ imports: [FormsModule, SdsRichTextEditor, SdsRichTextToolbarEnd],
1464
+ providers: [
1465
+ {
1466
+ provide: NG_VALUE_ACCESSOR,
1467
+ useExisting: forwardRef(() => SdsLocalizedRichTextEditor),
1468
+ multi: true,
1469
+ },
1470
+ ],
1471
+ host: {
1472
+ class: 'block',
1473
+ '(focusout)': 'onFocusOut($event)',
1474
+ },
1475
+ template: `
1476
+ @if (label()) {
1477
+ <label
1478
+ [id]="baseId + '-label'"
1479
+ class="mb-1.5 block text-sm font-medium text-ink"
1480
+ (click)="focus()"
1481
+ >{{ label() }}
1482
+ @if (required()) {
1483
+ <span class="ml-0.5 text-danger" aria-hidden="true">*</span>
1484
+ }
1485
+ </label>
1486
+ }
1487
+
1488
+ @for (lang of languages(); track lang) {
1489
+ <sds-rich-text-editor
1490
+ [style.display]="lang === activeLang() ? null : 'none'"
1491
+ [attr.data-active-language]="lang === activeLang() ? '' : null"
1492
+ [ariaLabel]="editorName(lang)"
1493
+ [contentLanguage]="lang"
1494
+ [placeholder]="placeholderFor(lang)"
1495
+ [hint]="hint()"
1496
+ [error]="error()"
1497
+ [invalid]="invalid()"
1498
+ [required]="required()"
1499
+ [disabled]="isDisabled()"
1500
+ [readonly]="readonly()"
1501
+ [tools]="tools()"
1502
+ [rows]="rows()"
1503
+ [maxRows]="maxRows()"
1504
+ [maxLength]="maxLength()"
1505
+ [showCount]="showCount()"
1506
+ [appearance]="appearance()"
1507
+ [labels]="labels()"
1508
+ [ngModel]="valueMap()[lang] ?? ''"
1509
+ [ngModelOptions]="{ standalone: true }"
1510
+ (ngModelChange)="write(lang, $event)"
1511
+ >
1512
+ @if (languages().length > 1) {
1513
+ <div
1514
+ sdsRichTextToolbarEnd
1515
+ role="tablist"
1516
+ class="flex items-center gap-0.5"
1517
+ [attr.aria-label]="languageSwitcherLabel()"
1518
+ (keydown)="onTabsKeydown($event)"
1519
+ >
1520
+ @for (code of languages(); track code) {
1521
+ <button
1522
+ type="button"
1523
+ role="tab"
1524
+ [attr.data-lang]="code"
1525
+ [attr.aria-selected]="code === activeLang() ? 'true' : 'false'"
1526
+ [attr.tabindex]="code === activeLang() ? 0 : -1"
1527
+ [attr.title]="tabTitle(code)"
1528
+ [disabled]="isDisabled()"
1529
+ [class]="tabClass(code)"
1530
+ (mousedown)="$event.preventDefault()"
1531
+ (click)="select(code, 'editor')"
1532
+ >
1533
+ {{ code }}
1534
+ <span class="inline-block h-1.5 w-1.5 rounded-full" [class]="dotClass(code)"></span>
1535
+ </button>
1536
+ }
1537
+ </div>
1538
+ }
1539
+ </sds-rich-text-editor>
1540
+ }
1541
+ `,
1542
+ }]
1543
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], requiredLanguages: [{ type: i0.Input, args: [{ isSignal: true, alias: "requiredLanguages", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], languages: [{ type: i0.Input, args: [{ isSignal: true, alias: "languages", required: false }] }], currentLanguage: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentLanguage", required: false }] }], tools: [{ type: i0.Input, args: [{ isSignal: true, alias: "tools", required: false }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: false }] }], maxRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxRows", required: false }] }], maxLength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxLength", required: false }] }], showCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCount", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], languageSwitcherLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "languageSwitcherLabel", required: false }] }], untranslatedLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "untranslatedLabel", required: false }] }], requiredLanguageLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "requiredLanguageLabel", required: false }] }], editors: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => SdsRichTextEditor), { isSignal: true }] }] } });
1544
+
1545
+ /*
1546
+ * Secondary entry point `@besaitech/ng-design-system/rich-text` — the Tiptap (ProseMirror) powered
1547
+ * rich-text editor.
1548
+ *
1549
+ * Kept out of the root entry for the same reason as the charts: the root is one module, so anything it
1550
+ * exports that an app uses anywhere lands in the app's initial chunk. Importing the editor from here lets
1551
+ * the bundler keep it, and ProseMirror, in the lazy chunk of the page that edits rich text.
1552
+ */
1553
+
1554
+ /**
1555
+ * Generated bundle index. Do not edit.
1556
+ */
1557
+
1558
+ export { SDS_RICH_TEXT_DEFAULT_TOOLS, SDS_RICH_TEXT_LABELS, SDS_RICH_TEXT_LABELS_EN, SdsLocalizedRichTextEditor, SdsRichTextEditor, SdsRichTextToolbarEnd, sdsSafeHref };
1559
+ //# sourceMappingURL=besaitech-ng-design-system-rich-text.mjs.map