@mk-kit/ui 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/block-editor/README.md +254 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs +2158 -0
- package/fesm2022/mk-kit-ui-block-editor.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-button.mjs +81 -0
- package/fesm2022/mk-kit-ui-button.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs +136 -0
- package/fesm2022/mk-kit-ui-checkbox.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-chip.mjs +122 -0
- package/fesm2022/mk-kit-ui-chip.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs +144 -0
- package/fesm2022/mk-kit-ui-context-menu.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-core.mjs +1576 -0
- package/fesm2022/mk-kit-ui-core.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-data.mjs +6055 -0
- package/fesm2022/mk-kit-ui-data.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-datetime.mjs +3409 -0
- package/fesm2022/mk-kit-ui-datetime.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-directives.mjs +1779 -0
- package/fesm2022/mk-kit-ui-directives.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-dnd.mjs +1073 -0
- package/fesm2022/mk-kit-ui-dnd.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-feedback.mjs +2426 -0
- package/fesm2022/mk-kit-ui-feedback.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-forms.mjs +9208 -0
- package/fesm2022/mk-kit-ui-forms.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-icon.mjs +470 -0
- package/fesm2022/mk-kit-ui-icon.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-media.mjs +896 -0
- package/fesm2022/mk-kit-ui-media.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-navigation.mjs +2542 -0
- package/fesm2022/mk-kit-ui-navigation.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs +565 -0
- package/fesm2022/mk-kit-ui-rich-text.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-table.mjs +1378 -0
- package/fesm2022/mk-kit-ui-table.mjs.map +1 -0
- package/fesm2022/mk-kit-ui.mjs +32 -0
- package/fesm2022/mk-kit-ui.mjs.map +1 -0
- package/package.json +130 -0
- package/schematics/collection.json +10 -0
- package/schematics/ng-add/index.js +113 -0
- package/schematics/ng-add/schema.json +22 -0
- package/schematics/package.json +3 -0
- package/styles/mk-kit.css +750 -0
- package/types/mk-kit-ui-block-editor.d.ts +292 -0
- package/types/mk-kit-ui-button.d.ts +40 -0
- package/types/mk-kit-ui-checkbox.d.ts +62 -0
- package/types/mk-kit-ui-chip.d.ts +59 -0
- package/types/mk-kit-ui-context-menu.d.ts +57 -0
- package/types/mk-kit-ui-core.d.ts +1105 -0
- package/types/mk-kit-ui-data.d.ts +2580 -0
- package/types/mk-kit-ui-datetime.d.ts +1171 -0
- package/types/mk-kit-ui-directives.d.ts +807 -0
- package/types/mk-kit-ui-dnd.d.ts +423 -0
- package/types/mk-kit-ui-feedback.d.ts +1270 -0
- package/types/mk-kit-ui-forms.d.ts +3586 -0
- package/types/mk-kit-ui-icon.d.ts +108 -0
- package/types/mk-kit-ui-media.d.ts +549 -0
- package/types/mk-kit-ui-navigation.d.ts +1169 -0
- package/types/mk-kit-ui-rich-text.d.ts +187 -0
- package/types/mk-kit-ui-table.d.ts +739 -0
- package/types/mk-kit-ui.d.ts +17 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, DOCUMENT, ElementRef, viewChild, input, booleanAttribute, output, signal, computed, afterRenderEffect, ChangeDetectionStrategy, Component, model, forwardRef } from '@angular/core';
|
|
3
|
+
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
4
|
+
import { MK_I18N, MkFieldContext } from '@mk-kit/ui/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rich-text engine — an accessible contenteditable surface with a floating
|
|
8
|
+
* inline formatting toolbar (Bold, Italic, Underline, Strikethrough, Inline
|
|
9
|
+
* code, Link, Clear). Built on the native Selection API +
|
|
10
|
+
* `document.execCommand` (deprecated but universally supported and
|
|
11
|
+
* dependency-free — the only practical way to do inline formatting without a
|
|
12
|
+
* heavyweight editor engine).
|
|
13
|
+
*
|
|
14
|
+
* This is the low-level building block: it emits HTML via `contentChange`, and
|
|
15
|
+
* structural intents (`splitAt`, `removeEmpty`, `arrowOut`) so a block-editor
|
|
16
|
+
* host can create, merge or navigate blocks. For a standalone form control use
|
|
17
|
+
* {@link MkRichText} (`<mk-rich-text>`), which wraps this engine in a CVA over
|
|
18
|
+
* a sanitised HTML string.
|
|
19
|
+
*/
|
|
20
|
+
class MkRichTextEngine {
|
|
21
|
+
document = inject(DOCUMENT);
|
|
22
|
+
host = inject(ElementRef);
|
|
23
|
+
editableRef = viewChild.required('editable', /* @ts-ignore */
|
|
24
|
+
...(ngDevMode ? [{ debugName: "editableRef" }] : /* istanbul ignore next */ []));
|
|
25
|
+
i18n = inject(MK_I18N);
|
|
26
|
+
/** Pending toolbar-hide delay from `onBlur`; cleared on destroy. */
|
|
27
|
+
blurTimer = null;
|
|
28
|
+
/** Current HTML content. */
|
|
29
|
+
html = input('', /* @ts-ignore */
|
|
30
|
+
...(ngDevMode ? [{ debugName: "html" }] : /* istanbul ignore next */ []));
|
|
31
|
+
/** Placeholder shown when empty. */
|
|
32
|
+
placeholder = input('', /* @ts-ignore */
|
|
33
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
34
|
+
/** Disable editing. */
|
|
35
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
36
|
+
/** Allow soft line breaks (Shift+Enter). Plain Enter always splits. */
|
|
37
|
+
multiline = input(true, { ...(ngDevMode ? { debugName: "multiline" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
38
|
+
/**
|
|
39
|
+
* Emit `splitAt` on Enter (block-editor hosts). When `false` — standalone
|
|
40
|
+
* field usage — Enter inserts a line break inside this surface instead.
|
|
41
|
+
*/
|
|
42
|
+
splitOnEnter = input(true, { ...(ngDevMode ? { debugName: "splitOnEnter" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
43
|
+
/** Minimum height of the editable surface (any CSS length). */
|
|
44
|
+
minHeight = input(null, /* @ts-ignore */
|
|
45
|
+
...(ngDevMode ? [{ debugName: "minHeight" }] : /* istanbul ignore next */ []));
|
|
46
|
+
/** Accessible label for the editable region. */
|
|
47
|
+
ariaLabel = input(this.i18n.blockEditor.editableText, /* @ts-ignore */
|
|
48
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
49
|
+
/** Id of an external label element, for `aria-labelledby`. */
|
|
50
|
+
labelledBy = input(null, /* @ts-ignore */
|
|
51
|
+
...(ngDevMode ? [{ debugName: "labelledBy" }] : /* istanbul ignore next */ []));
|
|
52
|
+
/** Space-separated id list for `aria-describedby`. */
|
|
53
|
+
describedBy = input(null, /* @ts-ignore */
|
|
54
|
+
...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
|
|
55
|
+
/** Reflect an invalid state via `aria-invalid`. */
|
|
56
|
+
invalid = input(false, { ...(ngDevMode ? { debugName: "invalid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
57
|
+
/** Fired on every edit with the sanitised-by-construction HTML. */
|
|
58
|
+
contentChange = output();
|
|
59
|
+
/** Enter pressed: `before` stays in this block, `after` starts a new one. */
|
|
60
|
+
splitAt = output();
|
|
61
|
+
/** Backspace at the very start of an empty block. */
|
|
62
|
+
removeEmpty = output();
|
|
63
|
+
/** Caret tried to leave the top/bottom edge with an arrow key. */
|
|
64
|
+
arrowOut = output();
|
|
65
|
+
/** Focus/blur bubbled so the wrapper can show selection chrome. */
|
|
66
|
+
focusChange = output();
|
|
67
|
+
isEmpty = signal(true, /* @ts-ignore */
|
|
68
|
+
...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
|
|
69
|
+
toolbar = signal({
|
|
70
|
+
visible: false,
|
|
71
|
+
top: 0,
|
|
72
|
+
left: 0,
|
|
73
|
+
}, /* @ts-ignore */
|
|
74
|
+
...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
|
|
75
|
+
activeFormats = signal(new Set(), /* @ts-ignore */
|
|
76
|
+
...(ngDevMode ? [{ debugName: "activeFormats" }] : /* istanbul ignore next */ []));
|
|
77
|
+
tools = [
|
|
78
|
+
{ id: 'bold', label: this.i18n.blockEditor.bold, icon: 'B', shortcut: 'Ctrl+B' },
|
|
79
|
+
{ id: 'italic', label: this.i18n.blockEditor.italic, icon: 'I', shortcut: 'Ctrl+I' },
|
|
80
|
+
{ id: 'underline', label: this.i18n.blockEditor.underline, icon: 'U', shortcut: 'Ctrl+U' },
|
|
81
|
+
{ id: 'strikeThrough', label: this.i18n.blockEditor.strikethrough, icon: 'S' },
|
|
82
|
+
{ id: 'code', label: this.i18n.blockEditor.inlineCode, icon: '</>' },
|
|
83
|
+
{ id: 'link', label: this.i18n.blockEditor.link, icon: '🔗' },
|
|
84
|
+
{ id: 'clear', label: this.i18n.blockEditor.clearFormatting, icon: '⌫' },
|
|
85
|
+
];
|
|
86
|
+
showPlaceholder = computed(() => this.isEmpty(), /* @ts-ignore */
|
|
87
|
+
...(ngDevMode ? [{ debugName: "showPlaceholder" }] : /* istanbul ignore next */ []));
|
|
88
|
+
constructor() {
|
|
89
|
+
// Sync the DOM from the `html` input only when the user is not actively
|
|
90
|
+
// editing this element — otherwise setting innerHTML would reset the caret.
|
|
91
|
+
afterRenderEffect(() => {
|
|
92
|
+
const value = this.html() ?? '';
|
|
93
|
+
const el = this.editableRef().nativeElement;
|
|
94
|
+
if (this.document.activeElement === el)
|
|
95
|
+
return;
|
|
96
|
+
if (el.innerHTML !== value)
|
|
97
|
+
el.innerHTML = value;
|
|
98
|
+
this.isEmpty.set(this.computeEmpty(el));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Places the caret and focuses (called by the host for navigation). */
|
|
102
|
+
focus(position = 'end') {
|
|
103
|
+
const el = this.editableRef().nativeElement;
|
|
104
|
+
el.focus();
|
|
105
|
+
this.setCaret(el, position);
|
|
106
|
+
}
|
|
107
|
+
onInput() {
|
|
108
|
+
const el = this.editableRef().nativeElement;
|
|
109
|
+
this.isEmpty.set(this.computeEmpty(el));
|
|
110
|
+
this.contentChange.emit(el.innerHTML);
|
|
111
|
+
}
|
|
112
|
+
onFocus() {
|
|
113
|
+
this.focusChange.emit(true);
|
|
114
|
+
}
|
|
115
|
+
ngOnDestroy() {
|
|
116
|
+
if (this.blurTimer !== null) {
|
|
117
|
+
clearTimeout(this.blurTimer);
|
|
118
|
+
this.blurTimer = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
onBlur() {
|
|
122
|
+
this.focusChange.emit(false);
|
|
123
|
+
// Hide the toolbar on a delay, but only if focus really left the
|
|
124
|
+
// component — Tabbing into the toolbar itself must not dismiss it.
|
|
125
|
+
if (this.blurTimer !== null)
|
|
126
|
+
clearTimeout(this.blurTimer);
|
|
127
|
+
this.blurTimer = setTimeout(() => {
|
|
128
|
+
this.blurTimer = null;
|
|
129
|
+
const active = this.document.activeElement;
|
|
130
|
+
if (active && this.host.nativeElement.contains(active))
|
|
131
|
+
return;
|
|
132
|
+
this.toolbar.update((t) => (t.visible ? { ...t, visible: false } : t));
|
|
133
|
+
}, 150);
|
|
134
|
+
}
|
|
135
|
+
onSelectionChange() {
|
|
136
|
+
this.updateToolbar();
|
|
137
|
+
}
|
|
138
|
+
onKeydown(event) {
|
|
139
|
+
if (this.disabled())
|
|
140
|
+
return;
|
|
141
|
+
const el = this.editableRef().nativeElement;
|
|
142
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
if (this.splitOnEnter())
|
|
145
|
+
this.emitSplit(el);
|
|
146
|
+
else
|
|
147
|
+
this.insertLineBreak();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Shift+Enter in single-line mode is a hard split too.
|
|
151
|
+
if (event.key === 'Enter' && event.shiftKey && !this.multiline()) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
if (this.splitOnEnter())
|
|
154
|
+
this.emitSplit(el);
|
|
155
|
+
else
|
|
156
|
+
this.insertLineBreak();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.key === 'Backspace' && this.isCaretAtStart(el) && this.isSelectionCollapsed()) {
|
|
160
|
+
if (this.computeEmpty(el)) {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
this.removeEmpty.emit();
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (event.key === 'ArrowUp' && this.isCaretAtStart(el) && this.isSelectionCollapsed()) {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
this.arrowOut.emit('up');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (event.key === 'ArrowDown' && this.isCaretAtEnd(el) && this.isSelectionCollapsed()) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
this.arrowOut.emit('down');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Escape inside the toolbar returns focus to the text; arrows move tools. */
|
|
178
|
+
onToolbarKeydown(event) {
|
|
179
|
+
if (event.key === 'Escape') {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
this.toolbar.update((t) => ({ ...t, visible: false }));
|
|
183
|
+
this.editableRef().nativeElement.focus();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
|
187
|
+
const bar = event.currentTarget;
|
|
188
|
+
const buttons = Array.from(bar.querySelectorAll('.mk-rich-text__tool'));
|
|
189
|
+
const current = buttons.indexOf(this.document.activeElement);
|
|
190
|
+
if (current === -1 || buttons.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
|
194
|
+
buttons[(current + delta + buttons.length) % buttons.length].focus();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Runs a toolbar command, keeping the model + toolbar state in sync. */
|
|
198
|
+
runTool(id) {
|
|
199
|
+
const el = this.editableRef().nativeElement;
|
|
200
|
+
el.focus();
|
|
201
|
+
switch (id) {
|
|
202
|
+
case 'bold':
|
|
203
|
+
case 'italic':
|
|
204
|
+
case 'underline':
|
|
205
|
+
case 'strikeThrough':
|
|
206
|
+
this.document.execCommand(id);
|
|
207
|
+
break;
|
|
208
|
+
case 'code':
|
|
209
|
+
this.wrapInlineCode();
|
|
210
|
+
break;
|
|
211
|
+
case 'link':
|
|
212
|
+
this.applyLink();
|
|
213
|
+
break;
|
|
214
|
+
case 'clear':
|
|
215
|
+
this.document.execCommand('removeFormat');
|
|
216
|
+
this.document.execCommand('unlink');
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
this.onInput();
|
|
220
|
+
this.updateToolbar();
|
|
221
|
+
}
|
|
222
|
+
isActive(id) {
|
|
223
|
+
return this.activeFormats().has(id);
|
|
224
|
+
}
|
|
225
|
+
// --- internals ------------------------------------------------------------
|
|
226
|
+
emitSplit(el) {
|
|
227
|
+
const { before, after } = this.splitHtmlAtCaret(el);
|
|
228
|
+
this.splitAt.emit({ before, after });
|
|
229
|
+
}
|
|
230
|
+
/** Inserts a `<br>` at the caret (Enter in `splitOnEnter=false` mode). */
|
|
231
|
+
insertLineBreak() {
|
|
232
|
+
// insertHTML keeps the markup to the sanitiser's allow-list (`<br>`),
|
|
233
|
+
// unlike the browser's default Enter handling which wraps lines in divs.
|
|
234
|
+
this.document.execCommand('insertHTML', false, '<br>');
|
|
235
|
+
this.onInput();
|
|
236
|
+
}
|
|
237
|
+
/** Extracts markup after the caret so the host can start a new block with it. */
|
|
238
|
+
splitHtmlAtCaret(el) {
|
|
239
|
+
const selection = this.document.defaultView?.getSelection();
|
|
240
|
+
if (!selection || selection.rangeCount === 0) {
|
|
241
|
+
return { before: el.innerHTML, after: '' };
|
|
242
|
+
}
|
|
243
|
+
const range = selection.getRangeAt(0);
|
|
244
|
+
const tail = range.cloneRange();
|
|
245
|
+
tail.selectNodeContents(el);
|
|
246
|
+
tail.setStart(range.endContainer, range.endOffset);
|
|
247
|
+
const fragment = tail.cloneContents();
|
|
248
|
+
const holder = this.document.createElement('div');
|
|
249
|
+
holder.appendChild(fragment);
|
|
250
|
+
const after = holder.innerHTML;
|
|
251
|
+
const head = range.cloneRange();
|
|
252
|
+
head.selectNodeContents(el);
|
|
253
|
+
head.setEnd(range.startContainer, range.startOffset);
|
|
254
|
+
const headHolder = this.document.createElement('div');
|
|
255
|
+
headHolder.appendChild(head.cloneContents());
|
|
256
|
+
const before = headHolder.innerHTML;
|
|
257
|
+
return { before, after };
|
|
258
|
+
}
|
|
259
|
+
wrapInlineCode() {
|
|
260
|
+
const selection = this.document.defaultView?.getSelection();
|
|
261
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed)
|
|
262
|
+
return;
|
|
263
|
+
const range = selection.getRangeAt(0);
|
|
264
|
+
const code = this.document.createElement('code');
|
|
265
|
+
try {
|
|
266
|
+
range.surroundContents(code);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Selection crosses element boundaries — fall back to insertHTML.
|
|
270
|
+
const html = range.toString();
|
|
271
|
+
this.document.execCommand('insertHTML', false, `<code>${escapeText(html)}</code>`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
applyLink() {
|
|
275
|
+
const selection = this.document.defaultView?.getSelection();
|
|
276
|
+
if (!selection || selection.isCollapsed)
|
|
277
|
+
return;
|
|
278
|
+
const url = this.document.defaultView?.prompt(this.i18n.blockEditor.linkUrlPrompt, 'https://');
|
|
279
|
+
if (url == null)
|
|
280
|
+
return;
|
|
281
|
+
const trimmed = url.trim();
|
|
282
|
+
if (trimmed === '') {
|
|
283
|
+
this.document.execCommand('unlink');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!/^(https?:|mailto:|tel:|\/|#)/i.test(trimmed))
|
|
287
|
+
return; // block javascript: etc.
|
|
288
|
+
this.document.execCommand('createLink', false, trimmed);
|
|
289
|
+
}
|
|
290
|
+
updateToolbar() {
|
|
291
|
+
const el = this.editableRef().nativeElement;
|
|
292
|
+
const selection = this.document.defaultView?.getSelection();
|
|
293
|
+
if (this.disabled() ||
|
|
294
|
+
!selection ||
|
|
295
|
+
selection.rangeCount === 0 ||
|
|
296
|
+
selection.isCollapsed ||
|
|
297
|
+
!el.contains(selection.anchorNode)) {
|
|
298
|
+
this.toolbar.update((t) => (t.visible ? { ...t, visible: false } : t));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
302
|
+
const host = el.getBoundingClientRect();
|
|
303
|
+
this.toolbar.set({
|
|
304
|
+
visible: true,
|
|
305
|
+
top: rect.top - host.top - 8,
|
|
306
|
+
left: rect.left - host.left + rect.width / 2,
|
|
307
|
+
});
|
|
308
|
+
const active = new Set();
|
|
309
|
+
for (const id of ['bold', 'italic', 'underline', 'strikeThrough']) {
|
|
310
|
+
try {
|
|
311
|
+
if (this.document.queryCommandState(id))
|
|
312
|
+
active.add(id);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* queryCommandState can throw in some engines */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this.activeFormats.set(active);
|
|
319
|
+
}
|
|
320
|
+
computeEmpty(el) {
|
|
321
|
+
const text = el.textContent?.trim() ?? '';
|
|
322
|
+
return text === '' && !el.querySelector('img, hr, iframe');
|
|
323
|
+
}
|
|
324
|
+
setCaret(el, position) {
|
|
325
|
+
const selection = this.document.defaultView?.getSelection();
|
|
326
|
+
if (!selection)
|
|
327
|
+
return;
|
|
328
|
+
const range = this.document.createRange();
|
|
329
|
+
range.selectNodeContents(el);
|
|
330
|
+
range.collapse(position === 'start');
|
|
331
|
+
selection.removeAllRanges();
|
|
332
|
+
selection.addRange(range);
|
|
333
|
+
}
|
|
334
|
+
isSelectionCollapsed() {
|
|
335
|
+
return this.document.defaultView?.getSelection()?.isCollapsed ?? true;
|
|
336
|
+
}
|
|
337
|
+
isCaretAtStart(el) {
|
|
338
|
+
const selection = this.document.defaultView?.getSelection();
|
|
339
|
+
if (!selection || selection.rangeCount === 0)
|
|
340
|
+
return false;
|
|
341
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
342
|
+
range.selectNodeContents(el);
|
|
343
|
+
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
|
344
|
+
return range.toString().length === 0;
|
|
345
|
+
}
|
|
346
|
+
isCaretAtEnd(el) {
|
|
347
|
+
const selection = this.document.defaultView?.getSelection();
|
|
348
|
+
if (!selection || selection.rangeCount === 0)
|
|
349
|
+
return false;
|
|
350
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
351
|
+
range.selectNodeContents(el);
|
|
352
|
+
range.setStart(selection.anchorNode, selection.anchorOffset);
|
|
353
|
+
return range.toString().length === 0;
|
|
354
|
+
}
|
|
355
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRichTextEngine, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
356
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: MkRichTextEngine, isStandalone: true, selector: "mk-rich-text-engine", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, multiline: { classPropertyName: "multiline", publicName: "multiline", isSignal: true, isRequired: false, transformFunction: null }, splitOnEnter: { classPropertyName: "splitOnEnter", publicName: "splitOnEnter", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, labelledBy: { classPropertyName: "labelledBy", publicName: "labelledBy", isSignal: true, isRequired: false, transformFunction: null }, describedBy: { classPropertyName: "describedBy", publicName: "describedBy", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { contentChange: "contentChange", splitAt: "splitAt", removeEmpty: "removeEmpty", arrowOut: "arrowOut", focusChange: "focusChange" }, host: { classAttribute: "mk-rich-text" }, viewQueries: [{ propertyName: "editableRef", first: true, predicate: ["editable"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (toolbar().visible && !disabled()) {\n <div\n class=\"mk-rich-text__toolbar\"\n role=\"toolbar\"\n [attr.aria-label]=\"i18n.blockEditor.textFormatting\"\n [style.top.px]=\"toolbar().top\"\n [style.left.px]=\"toolbar().left\"\n (keydown)=\"onToolbarKeydown($event)\"\n >\n @for (tool of tools; track tool.id) {\n <button\n type=\"button\"\n class=\"mk-rich-text__tool\"\n [class.mk-rich-text__tool--active]=\"isActive(tool.id)\"\n [class.mk-rich-text__tool--code]=\"tool.id === 'code'\"\n [attr.aria-pressed]=\"isActive(tool.id)\"\n [attr.aria-label]=\"tool.label + (tool.shortcut ? ' (' + tool.shortcut + ')' : '')\"\n [title]=\"tool.label\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"runTool(tool.id)\"\n >\n {{ tool.icon }}\n </button>\n }\n </div>\n}\n\n<div\n #editable\n class=\"mk-rich-text__editable\"\n data-mk-editable\n role=\"textbox\"\n aria-multiline=\"true\"\n [attr.aria-label]=\"labelledBy() ? null : ariaLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.contenteditable]=\"disabled() ? null : 'true'\"\n [attr.data-placeholder]=\"placeholder()\"\n [style.min-height]=\"minHeight()\"\n [class.mk-rich-text__editable--empty]=\"showPlaceholder()\"\n (input)=\"onInput()\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n (keyup)=\"onSelectionChange()\"\n (mouseup)=\"onSelectionChange()\"\n></div>\n", styles: [":host{display:block;position:relative}.mk-rich-text__editable{display:block;width:100%;min-height:1.5em;outline:none;color:var(--mk-text);font-family:inherit;font-size:inherit;line-height:var(--mk-line-height-relaxed);white-space:pre-wrap;word-break:break-word}.mk-rich-text__editable:focus-visible{outline:none}.mk-rich-text__editable--empty:before{content:attr(data-placeholder);color:var(--mk-text-subtle);pointer-events:none}.mk-rich-text__editable a{color:var(--mk-primary);text-decoration:underline}.mk-rich-text__editable code{font-family:var(--mk-font-mono);font-size:.9em;padding:.1em .3em;border-radius:var(--mk-radius-xs);background:var(--mk-code-bg)}.mk-rich-text__toolbar{position:absolute;z-index:var(--mk-z-menu);display:flex;gap:2px;padding:var(--mk-space-1);transform:translate(-50%,-100%);background:var(--mk-surface-inverse);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);animation:mk-rich-text-pop var(--mk-duration-fast) var(--mk-ease-out)}@keyframes mk-rich-text-pop{0%{opacity:0;transform:translate(-50%,-90%)}to{opacity:1;transform:translate(-50%,-100%)}}@media(prefers-reduced-motion:reduce){.mk-rich-text__toolbar{animation:none}}.mk-rich-text__tool{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;padding:0 var(--mk-space-1);border:none;border-radius:var(--mk-radius-sm);background:transparent;color:var(--mk-text-inverse);font-family:inherit;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-bold);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-rich-text__tool--code{font-family:var(--mk-font-mono);font-size:var(--mk-font-size-xs);font-weight:var(--mk-font-weight-normal)}.mk-rich-text__tool:hover{background:var(--mk-hover-overlay)}.mk-rich-text__tool--active{background:var(--mk-primary);color:var(--mk-primary-contrast)}.mk-rich-text__tool:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
357
|
+
}
|
|
358
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRichTextEngine, decorators: [{
|
|
359
|
+
type: Component,
|
|
360
|
+
args: [{ selector: 'mk-rich-text-engine', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'mk-rich-text' }, template: "@if (toolbar().visible && !disabled()) {\n <div\n class=\"mk-rich-text__toolbar\"\n role=\"toolbar\"\n [attr.aria-label]=\"i18n.blockEditor.textFormatting\"\n [style.top.px]=\"toolbar().top\"\n [style.left.px]=\"toolbar().left\"\n (keydown)=\"onToolbarKeydown($event)\"\n >\n @for (tool of tools; track tool.id) {\n <button\n type=\"button\"\n class=\"mk-rich-text__tool\"\n [class.mk-rich-text__tool--active]=\"isActive(tool.id)\"\n [class.mk-rich-text__tool--code]=\"tool.id === 'code'\"\n [attr.aria-pressed]=\"isActive(tool.id)\"\n [attr.aria-label]=\"tool.label + (tool.shortcut ? ' (' + tool.shortcut + ')' : '')\"\n [title]=\"tool.label\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"runTool(tool.id)\"\n >\n {{ tool.icon }}\n </button>\n }\n </div>\n}\n\n<div\n #editable\n class=\"mk-rich-text__editable\"\n data-mk-editable\n role=\"textbox\"\n aria-multiline=\"true\"\n [attr.aria-label]=\"labelledBy() ? null : ariaLabel()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.contenteditable]=\"disabled() ? null : 'true'\"\n [attr.data-placeholder]=\"placeholder()\"\n [style.min-height]=\"minHeight()\"\n [class.mk-rich-text__editable--empty]=\"showPlaceholder()\"\n (input)=\"onInput()\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n (keyup)=\"onSelectionChange()\"\n (mouseup)=\"onSelectionChange()\"\n></div>\n", styles: [":host{display:block;position:relative}.mk-rich-text__editable{display:block;width:100%;min-height:1.5em;outline:none;color:var(--mk-text);font-family:inherit;font-size:inherit;line-height:var(--mk-line-height-relaxed);white-space:pre-wrap;word-break:break-word}.mk-rich-text__editable:focus-visible{outline:none}.mk-rich-text__editable--empty:before{content:attr(data-placeholder);color:var(--mk-text-subtle);pointer-events:none}.mk-rich-text__editable a{color:var(--mk-primary);text-decoration:underline}.mk-rich-text__editable code{font-family:var(--mk-font-mono);font-size:.9em;padding:.1em .3em;border-radius:var(--mk-radius-xs);background:var(--mk-code-bg)}.mk-rich-text__toolbar{position:absolute;z-index:var(--mk-z-menu);display:flex;gap:2px;padding:var(--mk-space-1);transform:translate(-50%,-100%);background:var(--mk-surface-inverse);border-radius:var(--mk-radius-md);box-shadow:var(--mk-shadow-lg);animation:mk-rich-text-pop var(--mk-duration-fast) var(--mk-ease-out)}@keyframes mk-rich-text-pop{0%{opacity:0;transform:translate(-50%,-90%)}to{opacity:1;transform:translate(-50%,-100%)}}@media(prefers-reduced-motion:reduce){.mk-rich-text__toolbar{animation:none}}.mk-rich-text__tool{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;padding:0 var(--mk-space-1);border:none;border-radius:var(--mk-radius-sm);background:transparent;color:var(--mk-text-inverse);font-family:inherit;font-size:var(--mk-font-size-sm);font-weight:var(--mk-font-weight-bold);cursor:pointer;transition:background-color var(--mk-duration-fast) var(--mk-ease-standard)}.mk-rich-text__tool--code{font-family:var(--mk-font-mono);font-size:var(--mk-font-size-xs);font-weight:var(--mk-font-weight-normal)}.mk-rich-text__tool:hover{background:var(--mk-hover-overlay)}.mk-rich-text__tool--active{background:var(--mk-primary);color:var(--mk-primary-contrast)}.mk-rich-text__tool:focus-visible{outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:var(--mk-focus-ring-offset)}\n"] }]
|
|
361
|
+
}], ctorParameters: () => [], propDecorators: { editableRef: [{ type: i0.ViewChild, args: ['editable', { isSignal: true }] }], html: [{ type: i0.Input, args: [{ isSignal: true, alias: "html", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], multiline: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiline", required: false }] }], splitOnEnter: [{ type: i0.Input, args: [{ isSignal: true, alias: "splitOnEnter", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], labelledBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelledBy", required: false }] }], describedBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "describedBy", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], contentChange: [{ type: i0.Output, args: ["contentChange"] }], splitAt: [{ type: i0.Output, args: ["splitAt"] }], removeEmpty: [{ type: i0.Output, args: ["removeEmpty"] }], arrowOut: [{ type: i0.Output, args: ["arrowOut"] }], focusChange: [{ type: i0.Output, args: ["focusChange"] }] } });
|
|
362
|
+
function escapeText(value) {
|
|
363
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Allow-list sanitiser for inline rich-text HTML — the security backbone of
|
|
368
|
+
* both `<mk-rich-text>` and the block editor's text blocks.
|
|
369
|
+
*
|
|
370
|
+
* SECURITY MODEL: rich-text surfaces store HTML authored through the editor's
|
|
371
|
+
* restricted formatting commands. Before that markup is emitted, persisted or
|
|
372
|
+
* re-rendered it is passed through {@link sanitizeInlineHtml}, an allow-list
|
|
373
|
+
* cleaner that strips scripts, event handlers and dangerous URLs. Angular's
|
|
374
|
+
* `DomSanitizer` re-sanitises at display time where applicable. This module is
|
|
375
|
+
* dependency-free so it can run on a server (SSG/SSR) — never feed its output
|
|
376
|
+
* back into `innerHTML` without sanitising again.
|
|
377
|
+
*/
|
|
378
|
+
const ALLOWED_INLINE_TAGS = new Set([
|
|
379
|
+
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'code', 'a', 'br', 'span', 'mark', 'sub', 'sup',
|
|
380
|
+
]);
|
|
381
|
+
/** Escapes a value for safe placement in a double-quoted attribute. */
|
|
382
|
+
function escapeAttr(value) {
|
|
383
|
+
return String(value ?? '')
|
|
384
|
+
.replace(/&/g, '&')
|
|
385
|
+
.replace(/</g, '<')
|
|
386
|
+
.replace(/>/g, '>')
|
|
387
|
+
.replace(/"/g, '"');
|
|
388
|
+
}
|
|
389
|
+
/** True for URL schemes safe to place in `href`/`src`. */
|
|
390
|
+
function isSafeUrl(url) {
|
|
391
|
+
const trimmed = String(url ?? '').trim();
|
|
392
|
+
if (trimmed === '')
|
|
393
|
+
return false;
|
|
394
|
+
// Allow relative, http(s), mailto, tel and data:image.
|
|
395
|
+
if (/^(https?:|mailto:|tel:)/i.test(trimmed))
|
|
396
|
+
return true;
|
|
397
|
+
if (/^data:image\//i.test(trimmed))
|
|
398
|
+
return true;
|
|
399
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed))
|
|
400
|
+
return false; // any other scheme
|
|
401
|
+
return true; // relative / anchor / protocol-relative-ish
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Allow-list cleaner for inline rich text. Keeps a small set of formatting
|
|
405
|
+
* tags, strips everything else's tags (preserving their text), removes event
|
|
406
|
+
* handler attributes and unsafe `href`s. Not a substitute for `DomSanitizer`,
|
|
407
|
+
* but makes the serialised string safe by construction.
|
|
408
|
+
*/
|
|
409
|
+
function sanitizeInlineHtml(html) {
|
|
410
|
+
if (!html)
|
|
411
|
+
return '';
|
|
412
|
+
let out = String(html);
|
|
413
|
+
// Drop script/style blocks entirely.
|
|
414
|
+
out = out.replace(/<\s*(script|style)[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
|
|
415
|
+
// Rewrite each tag: keep allow-listed ones with a scrubbed attribute set.
|
|
416
|
+
out = out.replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, (tag) => {
|
|
417
|
+
const closing = /^<\s*\//.test(tag);
|
|
418
|
+
const name = (tag.match(/^<\s*\/?\s*([a-z0-9]+)/i)?.[1] ?? '').toLowerCase();
|
|
419
|
+
if (!ALLOWED_INLINE_TAGS.has(name))
|
|
420
|
+
return '';
|
|
421
|
+
if (closing)
|
|
422
|
+
return `</${name}>`;
|
|
423
|
+
if (name === 'a') {
|
|
424
|
+
const href = tag.match(/\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
425
|
+
const url = href?.[1] ?? href?.[2] ?? '';
|
|
426
|
+
if (url && isSafeUrl(url)) {
|
|
427
|
+
return `<a href="${escapeAttr(url)}" rel="noopener noreferrer nofollow" target="_blank">`;
|
|
428
|
+
}
|
|
429
|
+
return '<a>';
|
|
430
|
+
}
|
|
431
|
+
return `<${name}>`;
|
|
432
|
+
});
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Rich text — a small WYSIWYG form control over a sanitised HTML string, for
|
|
438
|
+
* single "description"-style fields where the full block editor is overkill.
|
|
439
|
+
* Renders a textarea-like surface with the floating inline formatting toolbar
|
|
440
|
+
* (Bold, Italic, Underline, Strikethrough, Inline code, Link, Clear) on text
|
|
441
|
+
* selection; Enter inserts a line break.
|
|
442
|
+
*
|
|
443
|
+
* Implements `ControlValueAccessor` with a two-way `value` model, so it works
|
|
444
|
+
* with `[(ngModel)]`, reactive forms and `[(value)]`. Every value — written or
|
|
445
|
+
* emitted — passes through {@link sanitizeInlineHtml}, and a visually empty
|
|
446
|
+
* surface (whitespace / lone `<br>`s) normalises to `''` so `required`
|
|
447
|
+
* validation behaves.
|
|
448
|
+
*
|
|
449
|
+
* ```html
|
|
450
|
+
* <mk-rich-text [(ngModel)]="description" placeholder="Describe it…" />
|
|
451
|
+
* <mk-rich-text [(value)]="html" minHeight="8rem" />
|
|
452
|
+
* ```
|
|
453
|
+
*/
|
|
454
|
+
class MkRichText {
|
|
455
|
+
field = inject(MkFieldContext, { optional: true });
|
|
456
|
+
i18n = inject(MK_I18N);
|
|
457
|
+
/** Two-way HTML string value (sanitised; `''` when visually empty). */
|
|
458
|
+
value = model('', /* @ts-ignore */
|
|
459
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
460
|
+
/** Placeholder shown when empty. */
|
|
461
|
+
placeholder = input('', /* @ts-ignore */
|
|
462
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
463
|
+
/** Disable the control. */
|
|
464
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
465
|
+
/** Force the invalid visual + `aria-invalid` when used standalone. */
|
|
466
|
+
invalid = input(false, { ...(ngDevMode ? { debugName: "invalid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
467
|
+
/** Minimum height of the editable area (any CSS length). */
|
|
468
|
+
minHeight = input('4.5em', /* @ts-ignore */
|
|
469
|
+
...(ngDevMode ? [{ debugName: "minHeight" }] : /* istanbul ignore next */ []));
|
|
470
|
+
/** Accessible label when not labelled by an `mk-form-field`. */
|
|
471
|
+
ariaLabel = input('', { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
|
|
472
|
+
cvaDisabled = signal(false, /* @ts-ignore */
|
|
473
|
+
...(ngDevMode ? [{ debugName: "cvaDisabled" }] : /* istanbul ignore next */ []));
|
|
474
|
+
onChange = () => { };
|
|
475
|
+
onTouched = () => { };
|
|
476
|
+
isDisabled = computed(() => this.disabled() || this.cvaDisabled(), /* @ts-ignore */
|
|
477
|
+
...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
|
|
478
|
+
isInvalid = computed(() => this.invalid() || (this.field?.hasError() ?? false), /* @ts-ignore */
|
|
479
|
+
...(ngDevMode ? [{ debugName: "isInvalid" }] : /* istanbul ignore next */ []));
|
|
480
|
+
labelledBy = this.field?.labelId ?? null;
|
|
481
|
+
describedBy = computed(() => this.field?.describedBy() ?? null, /* @ts-ignore */
|
|
482
|
+
...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
|
|
483
|
+
effectiveAriaLabel = computed(() => this.ariaLabel() || this.i18n.blockEditor.editableText, /* @ts-ignore */
|
|
484
|
+
...(ngDevMode ? [{ debugName: "effectiveAriaLabel" }] : /* istanbul ignore next */ []));
|
|
485
|
+
/** Sanitised view of the model, rendered into the engine. */
|
|
486
|
+
safeHtml = computed(() => sanitizeInlineHtml(this.value()), /* @ts-ignore */
|
|
487
|
+
...(ngDevMode ? [{ debugName: "safeHtml" }] : /* istanbul ignore next */ []));
|
|
488
|
+
/** User edit: sanitise, normalise the empty state, propagate. */
|
|
489
|
+
onContentChange(html) {
|
|
490
|
+
const value = normalizeRichTextValue(html);
|
|
491
|
+
this.value.set(value);
|
|
492
|
+
this.onChange(value);
|
|
493
|
+
}
|
|
494
|
+
onFocusChange(focused) {
|
|
495
|
+
if (!focused)
|
|
496
|
+
this.onTouched();
|
|
497
|
+
}
|
|
498
|
+
// --- ControlValueAccessor -------------------------------------------------
|
|
499
|
+
writeValue(value) {
|
|
500
|
+
this.value.set(normalizeRichTextValue(value == null ? '' : String(value)));
|
|
501
|
+
}
|
|
502
|
+
registerOnChange(fn) {
|
|
503
|
+
this.onChange = fn;
|
|
504
|
+
}
|
|
505
|
+
registerOnTouched(fn) {
|
|
506
|
+
this.onTouched = fn;
|
|
507
|
+
}
|
|
508
|
+
setDisabledState(isDisabled) {
|
|
509
|
+
this.cvaDisabled.set(isDisabled);
|
|
510
|
+
}
|
|
511
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRichText, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
512
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.7", type: MkRichText, isStandalone: true, selector: "mk-rich-text", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange" }, host: { properties: { "class.mk-rich-text-field--disabled": "isDisabled()", "class.mk-rich-text-field--invalid": "isInvalid()" }, classAttribute: "mk-rich-text-field" }, providers: [
|
|
513
|
+
{
|
|
514
|
+
provide: NG_VALUE_ACCESSOR,
|
|
515
|
+
useExisting: forwardRef(() => MkRichText),
|
|
516
|
+
multi: true,
|
|
517
|
+
},
|
|
518
|
+
], ngImport: i0, template: "<mk-rich-text-engine\n [html]=\"safeHtml()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [splitOnEnter]=\"false\"\n [minHeight]=\"minHeight()\"\n [ariaLabel]=\"effectiveAriaLabel()\"\n [labelledBy]=\"labelledBy\"\n [describedBy]=\"describedBy()\"\n [invalid]=\"isInvalid()\"\n (contentChange)=\"onContentChange($event)\"\n (focusChange)=\"onFocusChange($event)\"\n/>\n", styles: [":host{display:block;width:100%;padding:var(--mk-space-2) var(--mk-space-3);font-family:inherit;font-size:var(--mk-font-size-md);color:var(--mk-text);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);transition:border-color var(--mk-duration-fast) var(--mk-ease-standard),box-shadow var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}:host(:hover:not(.mk-rich-text-field--disabled)){border-color:var(--mk-border-strong)}:host(:focus-within){outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:-2px;border-color:var(--mk-primary)}:host(.mk-rich-text-field--invalid){border-color:var(--mk-danger)}:host(.mk-rich-text-field--invalid:focus-within){outline-color:var(--mk-danger);border-color:var(--mk-danger)}:host(.mk-rich-text-field--disabled){background-color:var(--mk-surface-2);color:var(--mk-text-disabled);border-color:var(--mk-border-subtle);cursor:not-allowed;opacity:.7}@media(pointer:coarse){:host{font-size:max(var(--mk-font-size-md),16px)}}\n"], dependencies: [{ kind: "component", type: MkRichTextEngine, selector: "mk-rich-text-engine", inputs: ["html", "placeholder", "disabled", "multiline", "splitOnEnter", "minHeight", "ariaLabel", "labelledBy", "describedBy", "invalid"], outputs: ["contentChange", "splitAt", "removeEmpty", "arrowOut", "focusChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
519
|
+
}
|
|
520
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkRichText, decorators: [{
|
|
521
|
+
type: Component,
|
|
522
|
+
args: [{ selector: 'mk-rich-text', imports: [MkRichTextEngine], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
523
|
+
class: 'mk-rich-text-field',
|
|
524
|
+
'[class.mk-rich-text-field--disabled]': 'isDisabled()',
|
|
525
|
+
'[class.mk-rich-text-field--invalid]': 'isInvalid()',
|
|
526
|
+
}, providers: [
|
|
527
|
+
{
|
|
528
|
+
provide: NG_VALUE_ACCESSOR,
|
|
529
|
+
useExisting: forwardRef(() => MkRichText),
|
|
530
|
+
multi: true,
|
|
531
|
+
},
|
|
532
|
+
], template: "<mk-rich-text-engine\n [html]=\"safeHtml()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [splitOnEnter]=\"false\"\n [minHeight]=\"minHeight()\"\n [ariaLabel]=\"effectiveAriaLabel()\"\n [labelledBy]=\"labelledBy\"\n [describedBy]=\"describedBy()\"\n [invalid]=\"isInvalid()\"\n (contentChange)=\"onContentChange($event)\"\n (focusChange)=\"onFocusChange($event)\"\n/>\n", styles: [":host{display:block;width:100%;padding:var(--mk-space-2) var(--mk-space-3);font-family:inherit;font-size:var(--mk-font-size-md);color:var(--mk-text);background-color:var(--mk-surface);border:var(--mk-border-width) solid var(--mk-border);border-radius:var(--mk-radius-md);transition:border-color var(--mk-duration-fast) var(--mk-ease-standard),box-shadow var(--mk-duration-fast) var(--mk-ease-standard),background-color var(--mk-duration-fast) var(--mk-ease-standard)}:host(:hover:not(.mk-rich-text-field--disabled)){border-color:var(--mk-border-strong)}:host(:focus-within){outline:var(--mk-focus-ring-width) solid var(--mk-focus-ring);outline-offset:-2px;border-color:var(--mk-primary)}:host(.mk-rich-text-field--invalid){border-color:var(--mk-danger)}:host(.mk-rich-text-field--invalid:focus-within){outline-color:var(--mk-danger);border-color:var(--mk-danger)}:host(.mk-rich-text-field--disabled){background-color:var(--mk-surface-2);color:var(--mk-text-disabled);border-color:var(--mk-border-subtle);cursor:not-allowed;opacity:.7}@media(pointer:coarse){:host{font-size:max(var(--mk-font-size-md),16px)}}\n"] }]
|
|
533
|
+
}], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }] } });
|
|
534
|
+
/**
|
|
535
|
+
* Sanitises an HTML string and collapses visually empty markup (whitespace,
|
|
536
|
+
* ` `s, lone `<br>`s and empty formatting tags) to `''`.
|
|
537
|
+
*/
|
|
538
|
+
function normalizeRichTextValue(html) {
|
|
539
|
+
const sanitized = sanitizeInlineHtml(html);
|
|
540
|
+
const text = sanitized
|
|
541
|
+
.replace(/<[^>]*>/g, '')
|
|
542
|
+
.replace(/ /gi, ' ')
|
|
543
|
+
.trim();
|
|
544
|
+
return text === '' ? '' : sanitized;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* RICH TEXT entry for @mk-kit/ui.
|
|
549
|
+
*
|
|
550
|
+
* A small WYSIWYG form control (`<mk-rich-text>`) over a sanitised HTML
|
|
551
|
+
* string, plus the low-level contenteditable engine and the allow-list
|
|
552
|
+
* sanitiser it is built on. The block editor composes the same engine for its
|
|
553
|
+
* paragraph, heading, quote and list blocks.
|
|
554
|
+
*
|
|
555
|
+
* NOTE: this barrel deliberately does not export an HTML-escape helper — the
|
|
556
|
+
* block-editor entry exports `mkEscapeHtml` and the root umbrella re-exports
|
|
557
|
+
* both entries.
|
|
558
|
+
*/
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Generated bundle index. Do not edit.
|
|
562
|
+
*/
|
|
563
|
+
|
|
564
|
+
export { MkRichText, MkRichTextEngine, sanitizeInlineHtml };
|
|
565
|
+
//# sourceMappingURL=mk-kit-ui-rich-text.mjs.map
|