@besaitech/ng-design-system 0.0.71 → 0.0.72

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