kempo-ui 0.4.4 → 0.4.6

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.
Files changed (185) hide show
  1. package/dist/components/Chat.js +201 -0
  2. package/dist/components/CodeEditor.js +21 -2
  3. package/dist/components/Dropdown.js +1 -1
  4. package/dist/components/HtmlEditor.js +42 -3
  5. package/dist/components/MarkdownEditor.js +173 -0
  6. package/dist/components/Resize.js +12 -2
  7. package/dist/components/TextToSpeech.js +4 -4
  8. package/dist/components/VoiceSelector.js +49 -0
  9. package/dist/components/markdownEditorControls/Bold.js +11 -0
  10. package/dist/components/markdownEditorControls/BulletList.js +11 -0
  11. package/dist/components/markdownEditorControls/Code.js +11 -0
  12. package/dist/components/markdownEditorControls/FormatBlock.js +30 -0
  13. package/dist/components/markdownEditorControls/Heading.js +11 -0
  14. package/dist/components/markdownEditorControls/Image.js +93 -0
  15. package/dist/components/markdownEditorControls/Italic.js +11 -0
  16. package/dist/components/markdownEditorControls/Link.js +11 -0
  17. package/dist/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  18. package/dist/components/markdownEditorControls/Menu.js +30 -0
  19. package/dist/components/markdownEditorControls/NumberedList.js +11 -0
  20. package/dist/components/markdownEditorControls/Quote.js +11 -0
  21. package/dist/components/markdownEditorControls/SpeechToText.js +16 -0
  22. package/dist/components/markdownEditorControls/Strikethrough.js +11 -0
  23. package/dist/components/markdownEditorControls/Table.js +80 -0
  24. package/dist/utils/marked.esm.js +1 -0
  25. package/dist/utils/renderMarkdown.js +1 -0
  26. package/dist/utils/sanitizeHtml.js +1 -0
  27. package/dist/utils/voice.js +1 -0
  28. package/docs/components/accordion.html +19 -0
  29. package/docs/components/aside.html +19 -0
  30. package/docs/components/calendar.html +19 -0
  31. package/docs/components/card.html +19 -0
  32. package/docs/components/chat.html +715 -0
  33. package/docs/components/code-editor.html +55 -0
  34. package/docs/components/color-picker.html +19 -0
  35. package/docs/components/combobox.html +19 -0
  36. package/docs/components/content-slider.html +19 -0
  37. package/docs/components/context.html +19 -0
  38. package/docs/components/dialog.html +19 -0
  39. package/docs/components/dropdown.html +19 -0
  40. package/docs/components/filter-list.html +19 -0
  41. package/docs/components/focus-capture.html +19 -0
  42. package/docs/components/html-editor.html +60 -0
  43. package/docs/components/hybrid-component.html +19 -0
  44. package/docs/components/icon.html +19 -0
  45. package/docs/components/import.html +19 -0
  46. package/docs/components/light-component.html +19 -0
  47. package/docs/components/markdown-editor.html +924 -0
  48. package/docs/components/nav-spacer.html +19 -0
  49. package/docs/components/nav.html +19 -0
  50. package/docs/components/photo-viewer.html +19 -0
  51. package/docs/components/progress.html +19 -0
  52. package/docs/components/resize.html +22 -0
  53. package/docs/components/rich-textarea.html +650 -0
  54. package/docs/components/shadow-component.html +19 -0
  55. package/docs/components/show-more.html +19 -0
  56. package/docs/components/slider.html +19 -0
  57. package/docs/components/sortable.html +19 -0
  58. package/docs/components/speech-to-text.html +19 -0
  59. package/docs/components/spinner.html +19 -0
  60. package/docs/components/split.html +19 -0
  61. package/docs/components/table.html +19 -0
  62. package/docs/components/tableControls.html +19 -0
  63. package/docs/components/tableCustomFields.html +19 -0
  64. package/docs/components/tableFetchRecords.html +19 -0
  65. package/docs/components/tableFieldSortHide.html +19 -0
  66. package/docs/components/tablePagination.html +19 -0
  67. package/docs/components/tablePlaceholder.html +19 -0
  68. package/docs/components/tableRecordEditing.html +19 -0
  69. package/docs/components/tableRecordFiltering.html +19 -0
  70. package/docs/components/tableRecordHiding.html +19 -0
  71. package/docs/components/tableRecordSearching.html +19 -0
  72. package/docs/components/tableRecordSelection.html +19 -0
  73. package/docs/components/tableRowControls.html +19 -0
  74. package/docs/components/tableServerSync.html +19 -0
  75. package/docs/components/tableSorting.html +19 -0
  76. package/docs/components/tabs.html +19 -0
  77. package/docs/components/tags.html +19 -0
  78. package/docs/components/text-to-speech.html +63 -1
  79. package/docs/components/theme-select.html +19 -0
  80. package/docs/components/theme-switcher.html +19 -0
  81. package/docs/components/time.html +19 -0
  82. package/docs/components/timestamp.html +19 -0
  83. package/docs/components/toast.html +19 -0
  84. package/docs/components/toggle.html +19 -0
  85. package/docs/components/tree.html +19 -0
  86. package/docs/components/voice-selector.html +610 -0
  87. package/docs/icons/done_all.svg +1 -0
  88. package/docs/icons/format_quote.svg +1 -0
  89. package/docs/icons/format_strikethrough.svg +1 -0
  90. package/docs/icons/send.svg +1 -0
  91. package/docs/icons/text_fields.svg +1 -0
  92. package/docs/index.html +49 -0
  93. package/docs/src/components/Chat.js +201 -0
  94. package/docs/src/components/CodeEditor.js +21 -2
  95. package/docs/src/components/Dropdown.js +1 -1
  96. package/docs/src/components/HtmlEditor.js +42 -3
  97. package/docs/src/components/MarkdownEditor.js +173 -0
  98. package/docs/src/components/Resize.js +12 -2
  99. package/docs/src/components/TextToSpeech.js +4 -4
  100. package/docs/src/components/VoiceSelector.js +49 -0
  101. package/docs/src/components/markdownEditorControls/Bold.js +11 -0
  102. package/docs/src/components/markdownEditorControls/BulletList.js +11 -0
  103. package/docs/src/components/markdownEditorControls/Code.js +11 -0
  104. package/docs/src/components/markdownEditorControls/FormatBlock.js +30 -0
  105. package/docs/src/components/markdownEditorControls/Heading.js +11 -0
  106. package/docs/src/components/markdownEditorControls/Image.js +93 -0
  107. package/docs/src/components/markdownEditorControls/Italic.js +11 -0
  108. package/docs/src/components/markdownEditorControls/Link.js +11 -0
  109. package/docs/src/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
  110. package/docs/src/components/markdownEditorControls/Menu.js +30 -0
  111. package/docs/src/components/markdownEditorControls/NumberedList.js +11 -0
  112. package/docs/src/components/markdownEditorControls/Quote.js +11 -0
  113. package/docs/src/components/markdownEditorControls/SpeechToText.js +16 -0
  114. package/docs/src/components/markdownEditorControls/Strikethrough.js +11 -0
  115. package/docs/src/components/markdownEditorControls/Table.js +80 -0
  116. package/docs/src/utils/marked.esm.js +1 -0
  117. package/docs/src/utils/renderMarkdown.js +1 -0
  118. package/docs/src/utils/sanitizeHtml.js +1 -0
  119. package/docs/src/utils/voice.js +1 -0
  120. package/docs/utils/context.html +19 -0
  121. package/docs/utils/cookie.html +19 -0
  122. package/docs/utils/debounce.html +19 -0
  123. package/docs/utils/drag.html +19 -0
  124. package/docs/utils/elevation.html +23 -10
  125. package/docs/utils/formatTimestamp.html +19 -0
  126. package/docs/utils/object.html +19 -0
  127. package/docs/utils/propConverters.html +19 -0
  128. package/docs/utils/sanitizeHtml.html +555 -0
  129. package/docs/utils/string.html +19 -0
  130. package/docs/utils/theme.html +19 -0
  131. package/docs/utils/toTitleCase.html +19 -0
  132. package/docs/utils/type.html +19 -0
  133. package/docs/utils/voice.html +548 -0
  134. package/docs/utils/wait.html +19 -0
  135. package/docs-src/components/chat.page.html +230 -0
  136. package/docs-src/components/code-editor.page.html +36 -0
  137. package/docs-src/components/html-editor.page.html +41 -0
  138. package/docs-src/components/markdown-editor.page.html +439 -0
  139. package/docs-src/components/resize.page.html +3 -0
  140. package/docs-src/components/text-to-speech.page.html +44 -1
  141. package/docs-src/components/voice-selector.page.html +125 -0
  142. package/docs-src/index.page.html +30 -0
  143. package/docs-src/nav.fragment.html +19 -0
  144. package/docs-src/utils/elevation.page.html +4 -10
  145. package/docs-src/utils/sanitizeHtml.page.html +73 -0
  146. package/docs-src/utils/voice.page.html +66 -0
  147. package/icons/done_all.svg +1 -0
  148. package/icons/format_quote.svg +1 -0
  149. package/icons/format_strikethrough.svg +1 -0
  150. package/icons/send.svg +1 -0
  151. package/icons/text_fields.svg +1 -0
  152. package/llms.txt +7 -2
  153. package/package.json +3 -2
  154. package/src/components/Chat.js +435 -0
  155. package/src/components/CodeEditor.js +58 -2
  156. package/src/components/Dropdown.js +1 -1
  157. package/src/components/HtmlEditor.js +84 -4
  158. package/src/components/MarkdownEditor.js +654 -0
  159. package/src/components/Resize.js +17 -1
  160. package/src/components/TextToSpeech.js +13 -2
  161. package/src/components/VoiceSelector.js +271 -0
  162. package/src/components/markdownEditorControls/Bold.js +30 -0
  163. package/src/components/markdownEditorControls/BulletList.js +30 -0
  164. package/src/components/markdownEditorControls/Code.js +39 -0
  165. package/src/components/markdownEditorControls/FormatBlock.js +102 -0
  166. package/src/components/markdownEditorControls/Heading.js +45 -0
  167. package/src/components/markdownEditorControls/Image.js +250 -0
  168. package/src/components/markdownEditorControls/Italic.js +30 -0
  169. package/src/components/markdownEditorControls/Link.js +74 -0
  170. package/src/components/markdownEditorControls/MarkdownEditorControl.js +158 -0
  171. package/src/components/markdownEditorControls/Menu.js +86 -0
  172. package/src/components/markdownEditorControls/NumberedList.js +69 -0
  173. package/src/components/markdownEditorControls/Quote.js +30 -0
  174. package/src/components/markdownEditorControls/SpeechToText.js +65 -0
  175. package/src/components/markdownEditorControls/Strikethrough.js +35 -0
  176. package/src/components/markdownEditorControls/Table.js +164 -0
  177. package/src/utils/marked.esm.js +77 -0
  178. package/src/utils/renderMarkdown.js +29 -0
  179. package/src/utils/sanitizeHtml.js +137 -0
  180. package/src/utils/voice.js +103 -0
  181. package/tests/components/Chat.browser-test.js +540 -0
  182. package/tests/components/TextToSpeech.browser-test.js +150 -1
  183. package/tests/components/VoiceSelector.browser-test.js +374 -0
  184. package/tests/utils/sanitizeHtml.browser-test.js +179 -0
  185. package/tests/utils/voice.browser-test.js +128 -0
@@ -0,0 +1,654 @@
1
+ import { html, css, nothing } from '../lit-all.min.js';
2
+ import ShadowComponent from './ShadowComponent.js';
3
+ import renderMarkdown from '../utils/renderMarkdown.js';
4
+ import sanitizeHtml, { STRIP_COMPLETELY } from '../utils/sanitizeHtml.js';
5
+ import './Resize.js';
6
+ import './Tabs.js';
7
+
8
+ /*
9
+ k-markdown-editor
10
+
11
+ Textarea that writes markdown, with a live "Preview" tab. Designed to mimic
12
+ GitHub's comment editor — write tab + preview tab + control slots above and
13
+ below the textarea. Markdown is parsed by snarkdown, which intentionally
14
+ does NOT support inline HTML so user input can't smuggle in <script> or
15
+ similar. Form-associated; the submitted value is the raw markdown.
16
+
17
+ Controls go in the `controls-top` and `controls-bottom` slots; subclass
18
+ MarkdownEditorControl (./MarkdownEditorControl.js) to write your own.
19
+ */
20
+
21
+ export default class MarkdownEditor extends ShadowComponent {
22
+ static formAssociated = true;
23
+
24
+ static properties = {
25
+ value: { type: String },
26
+ name: { type: String, reflect: true },
27
+ placeholder: { type: String, reflect: true },
28
+ disabled: { type: Boolean, reflect: true },
29
+ required: { type: Boolean, reflect: true },
30
+ readonly: { type: Boolean, reflect: true },
31
+ mode: { type: String, reflect: true }, // 'write' | 'preview'
32
+ /*
33
+ Comma-separated allowlist for the rendered preview. Tags not in the
34
+ list are unwrapped (their text survives, the wrapper is dropped). Pass
35
+ `*` to allow every tag. Mutually exclusive with `disallowed-tags` —
36
+ setting both warns and `allowed-tags` wins. Empty / unset = use
37
+ sanitizeHtml's DEFAULT_TAGS.
38
+ */
39
+ allowedTags: { type: String, reflect: true, attribute: 'allowed-tags' },
40
+ /*
41
+ Comma-separated denylist for the rendered preview — allows everything
42
+ except the listed tags. Useful when you want to ban a couple of tags
43
+ rather than enumerate the whole allowed set. Mutually exclusive with
44
+ `allowed-tags`.
45
+ */
46
+ disallowedTags: { type: String, reflect: true, attribute: 'disallowed-tags' },
47
+ /*
48
+ Opt-in: when this attribute is present, `<script>` tags are kept
49
+ through sanitization (provided they also pass the allow/deny tag
50
+ check). When absent (the default), `<script>` is always stripped
51
+ entirely — including its content — to prevent XSS regardless of any
52
+ other configuration.
53
+ */
54
+ scriptsEnabled: { type: Boolean, reflect: true, attribute: 'scripts-enabled' },
55
+ /*
56
+ Pre-built control set. One of `'minimal'`, `'normal'`, `'full'`, or
57
+ empty/`'none'` (default — toolbar empty until consumers slot their own
58
+ controls in). When set, the matching set of buttons is rendered as
59
+ slot fallback content for `controls-top` / `controls-bottom`, and the
60
+ relevant control modules are dynamically imported.
61
+ */
62
+ controls: { type: String, reflect: true }
63
+ };
64
+
65
+ #defaultValue = '';
66
+
67
+ /*
68
+ Lifecycle Callbacks
69
+ */
70
+ constructor() {
71
+ super();
72
+ this.internals = this.attachInternals();
73
+ this.value = '';
74
+ this.name = '';
75
+ this.placeholder = '';
76
+ this.disabled = false;
77
+ this.required = false;
78
+ this.readonly = false;
79
+ this.mode = 'write';
80
+ this.allowedTags = '';
81
+ this.disallowedTags = '';
82
+ this.scriptsEnabled = false;
83
+ this.controls = '';
84
+ this.#controlsLoaded = false;
85
+ }
86
+
87
+ #controlsLoaded = false;
88
+
89
+ /*
90
+ Dynamically imports every built-in control module. Called whenever a
91
+ non-empty `controls` preset is set. Mirrors the HtmlEditor pattern so
92
+ consumers don't have to remember which control imports each preset
93
+ needs — they just set `controls="full"` and everything is loaded.
94
+ */
95
+ async loadControls() {
96
+ if(this.#controlsLoaded) return;
97
+ this.#controlsLoaded = true;
98
+ const base = new URL('./markdownEditorControls/', import.meta.url).href;
99
+ await Promise.all([
100
+ import(/* @vite-ignore */ `${base}Bold.js`),
101
+ import(/* @vite-ignore */ `${base}Italic.js`),
102
+ import(/* @vite-ignore */ `${base}Strikethrough.js`),
103
+ import(/* @vite-ignore */ `${base}Heading.js`),
104
+ import(/* @vite-ignore */ `${base}Code.js`),
105
+ import(/* @vite-ignore */ `${base}Link.js`),
106
+ import(/* @vite-ignore */ `${base}Image.js`),
107
+ import(/* @vite-ignore */ `${base}Table.js`),
108
+ import(/* @vite-ignore */ `${base}BulletList.js`),
109
+ import(/* @vite-ignore */ `${base}NumberedList.js`),
110
+ import(/* @vite-ignore */ `${base}Quote.js`),
111
+ import(/* @vite-ignore */ `${base}Menu.js`),
112
+ import(/* @vite-ignore */ `${base}FormatBlock.js`),
113
+ import(/* @vite-ignore */ `${base}SpeechToText.js`)
114
+ ]);
115
+ this.requestUpdate();
116
+ }
117
+
118
+ connectedCallback() {
119
+ super.connectedCallback();
120
+ // Initial markdown comes from the `value` attribute only. Don't fall back
121
+ // to children/textContent — slotted controls' own text (icon labels,
122
+ // separators, etc.) would leak in and pollute the value.
123
+ if(this.hasAttribute('value')){
124
+ this.#defaultValue = this.getAttribute('value');
125
+ }
126
+ if(!this.value && this.#defaultValue){
127
+ this.value = this.#defaultValue;
128
+ }
129
+ }
130
+
131
+ updated(changedProperties) {
132
+ super.updated(changedProperties);
133
+ if(changedProperties.has('value')){
134
+ this.internals.setFormValue(this.value);
135
+ }
136
+ if(changedProperties.has('controls') && this.controls && this.controls !== 'none'){
137
+ this.loadControls();
138
+ }
139
+ this.#updateValidity();
140
+ }
141
+
142
+ /*
143
+ Form-associated callbacks
144
+ */
145
+ formResetCallback() {
146
+ this.value = this.#defaultValue;
147
+ this.mode = 'write';
148
+ }
149
+
150
+ formStateRestoreCallback(state) {
151
+ if(typeof state === 'string') this.value = state;
152
+ }
153
+
154
+ formDisabledCallback(disabled) {
155
+ this.disabled = disabled;
156
+ }
157
+
158
+ /*
159
+ Public API — generic
160
+ */
161
+ focus() {
162
+ if(this.mode !== 'write') this.mode = 'write';
163
+ this.updateComplete.then(() => {
164
+ this.shadowRoot?.querySelector('textarea')?.focus();
165
+ });
166
+ }
167
+
168
+ blur() {
169
+ this.shadowRoot?.querySelector('textarea')?.blur();
170
+ }
171
+
172
+ clear() {
173
+ this.value = '';
174
+ }
175
+
176
+ setMode(mode) {
177
+ if(mode === 'write' || mode === 'preview') this.mode = mode;
178
+ }
179
+
180
+ togglePreview() {
181
+ this.mode = this.mode === 'write' ? 'preview' : 'write';
182
+ }
183
+
184
+ /*
185
+ Public API — selection / text manipulation, used by MarkdownEditorControl
186
+ subclasses. All of these operate on the textarea (write mode); they
187
+ silently no-op in preview mode.
188
+ */
189
+ get textarea() {
190
+ return this.shadowRoot?.querySelector('textarea') || null;
191
+ }
192
+
193
+ getSelection() {
194
+ const ta = this.textarea;
195
+ if(!ta) return { start: 0, end: 0, text: '' };
196
+ return {
197
+ start: ta.selectionStart,
198
+ end: ta.selectionEnd,
199
+ text: ta.value.substring(ta.selectionStart, ta.selectionEnd)
200
+ };
201
+ }
202
+
203
+ replaceSelection(replacement, { selectInserted = true } = {}) {
204
+ const ta = this.textarea;
205
+ if(!ta) return;
206
+ if(this.mode !== 'write') this.mode = 'write';
207
+ this.updateComplete.then(() => {
208
+ ta.focus();
209
+ const { selectionStart: start, selectionEnd: end } = ta;
210
+ const before = ta.value.substring(0, start);
211
+ const after = ta.value.substring(end);
212
+ const next = before + replacement + after;
213
+ ta.value = next;
214
+ const cursorEnd = start + replacement.length;
215
+ ta.selectionStart = selectInserted ? start : cursorEnd;
216
+ ta.selectionEnd = cursorEnd;
217
+ this.value = next;
218
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
219
+ });
220
+ }
221
+
222
+ /*
223
+ Wrap (or unwrap) the selection. If the selection is already wrapped with
224
+ the given markers — either the selection itself starts/ends with them, or
225
+ the surrounding characters are the markers — the wrap is removed instead
226
+ of being added again. This makes formatting buttons toggle.
227
+ */
228
+ wrapSelection(prefix, suffix = prefix, placeholder = '') {
229
+ const ta = this.textarea;
230
+ if(!ta){
231
+ this.replaceSelection(prefix + placeholder + suffix);
232
+ return;
233
+ }
234
+ if(this.mode !== 'write') this.mode = 'write';
235
+ this.updateComplete.then(() => {
236
+ ta.focus();
237
+ const { selectionStart: start, selectionEnd: end, value } = ta;
238
+ const selected = value.substring(start, end);
239
+
240
+ // Case 1: selection itself contains the wrap → strip it.
241
+ if(selected.length >= prefix.length + suffix.length
242
+ && selected.startsWith(prefix)
243
+ && selected.endsWith(suffix)){
244
+ const inner = selected.slice(prefix.length, selected.length - suffix.length);
245
+ const next = value.substring(0, start) + inner + value.substring(end);
246
+ ta.value = next;
247
+ ta.selectionStart = start;
248
+ ta.selectionEnd = start + inner.length;
249
+ this.value = next;
250
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
251
+ return;
252
+ }
253
+
254
+ // Case 2: characters around the selection are the wrap → extend & strip.
255
+ const before = value.substring(Math.max(0, start - prefix.length), start);
256
+ const after = value.substring(end, Math.min(value.length, end + suffix.length));
257
+ if(before === prefix && after === suffix){
258
+ const next = value.substring(0, start - prefix.length)
259
+ + selected
260
+ + value.substring(end + suffix.length);
261
+ ta.value = next;
262
+ ta.selectionStart = start - prefix.length;
263
+ ta.selectionEnd = ta.selectionStart + selected.length;
264
+ this.value = next;
265
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
266
+ return;
267
+ }
268
+
269
+ // Otherwise wrap as usual.
270
+ const inner = selected || placeholder;
271
+ const next = value.substring(0, start) + prefix + inner + suffix + value.substring(end);
272
+ ta.value = next;
273
+ const innerStart = start + prefix.length;
274
+ ta.selectionStart = innerStart;
275
+ ta.selectionEnd = innerStart + inner.length;
276
+ this.value = next;
277
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
278
+ });
279
+ }
280
+
281
+ insertAtCursor(text) {
282
+ this.replaceSelection(text, { selectInserted: false });
283
+ }
284
+
285
+ /*
286
+ Toggle a line-prefix on the selected lines (or the current line). If every
287
+ non-empty line in range already starts with `prefix`, the prefix is
288
+ removed; otherwise it's added. When `replacePattern` is provided, lines
289
+ that match it but do NOT yet have `prefix` get their match swapped out
290
+ rather than stacked — this is what lets headings switch levels (e.g.
291
+ `### Hello` becomes `## Hello` instead of `## ### Hello`).
292
+ */
293
+ /*
294
+ Run a regex `.replace(pattern, replacement)` on every selected line
295
+ (or the current line if no selection). Useful for "convert to plain
296
+ paragraph" — strip any heading prefix without toggling.
297
+ */
298
+ replaceInSelectedLines(pattern, replacement = '') {
299
+ const ta = this.textarea;
300
+ if(!ta) return;
301
+ if(this.mode !== 'write') this.mode = 'write';
302
+ this.updateComplete.then(() => {
303
+ ta.focus();
304
+ const { selectionStart: start, selectionEnd: end, value } = ta;
305
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
306
+ const lineEndIdx = value.indexOf('\n', end);
307
+ const lineEnd = lineEndIdx === -1 ? value.length : lineEndIdx;
308
+ const block = value.substring(lineStart, lineEnd);
309
+ const next_block = block.split('\n').map(l => l.replace(pattern, replacement)).join('\n');
310
+ if(next_block === block) return;
311
+ const next = value.substring(0, lineStart) + next_block + value.substring(lineEnd);
312
+ ta.value = next;
313
+ ta.selectionStart = lineStart;
314
+ ta.selectionEnd = lineStart + next_block.length;
315
+ this.value = next;
316
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
317
+ });
318
+ }
319
+
320
+ insertLinePrefix(prefix, replacePattern = null) {
321
+ const ta = this.textarea;
322
+ if(!ta) return;
323
+ if(this.mode !== 'write') this.mode = 'write';
324
+ this.updateComplete.then(() => {
325
+ ta.focus();
326
+ const { selectionStart: start, selectionEnd: end, value } = ta;
327
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
328
+ const lineEndIdx = value.indexOf('\n', end);
329
+ const lineEnd = lineEndIdx === -1 ? value.length : lineEndIdx;
330
+ const block = value.substring(lineStart, lineEnd);
331
+ const lines = block.split('\n');
332
+ const nonEmpty = lines.filter(l => l.length > 0);
333
+ const allHavePrefix = nonEmpty.length > 0 && nonEmpty.every(l => l.startsWith(prefix));
334
+ const processed = lines.map(line => {
335
+ if(line.length === 0) return line;
336
+ if(allHavePrefix) return line.startsWith(prefix) ? line.slice(prefix.length) : line;
337
+ if(line.startsWith(prefix)) return line;
338
+ if(replacePattern && replacePattern.test(line)) return line.replace(replacePattern, prefix);
339
+ return prefix + line;
340
+ });
341
+ const next_block = processed.join('\n');
342
+ const next = value.substring(0, lineStart) + next_block + value.substring(lineEnd);
343
+ ta.value = next;
344
+ ta.selectionStart = lineStart;
345
+ ta.selectionEnd = lineStart + next_block.length;
346
+ this.value = next;
347
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
348
+ });
349
+ }
350
+
351
+ /*
352
+ Utility
353
+ */
354
+ get isEmpty() {
355
+ return !(this.value || '').trim();
356
+ }
357
+
358
+ get renderedHtml() {
359
+ const opts = {};
360
+ const resolved = this.#resolvedAllowedTags;
361
+ if(resolved) opts.allowedTags = resolved;
362
+ if(this.scriptsEnabled){
363
+ // Remove SCRIPT from the always-strip set so the allow/deny check
364
+ // gets the final say. SCRIPT is the only tag scripts-enabled affects;
365
+ // iframe/style/etc. stay nuked unconditionally.
366
+ const stripCompletely = new Set(STRIP_COMPLETELY);
367
+ stripCompletely.delete('SCRIPT');
368
+ opts.stripCompletely = stripCompletely;
369
+ }
370
+ return sanitizeHtml(renderMarkdown(this.value || ''), opts);
371
+ }
372
+
373
+ /*
374
+ Resolve `allowed-tags` / `disallowed-tags` into the Set sanitizeHtml
375
+ expects. The two attributes are mutually exclusive — if both are set,
376
+ `allowed-tags` wins and a console warning fires.
377
+
378
+ Returns:
379
+ - For `allowed-tags="*"` → proxy-Set that says yes to everything.
380
+ - For `allowed-tags="a,b,c"` → real Set of those uppercase names.
381
+ - For `disallowed-tags="x,y"` → proxy-Set that returns true for any
382
+ tag NOT in the deny list.
383
+ - For neither set → null (sanitizeHtml uses DEFAULT_TAGS).
384
+ */
385
+ get #resolvedAllowedTags() {
386
+ const allow = (this.allowedTags || '').trim();
387
+ const deny = (this.disallowedTags || '').trim();
388
+ if(allow && deny){
389
+ console.warn('[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`.');
390
+ }
391
+ if(allow){
392
+ if(allow === '*') return { has: () => true };
393
+ return new Set(allow.split(',').map(t => t.trim().toUpperCase()).filter(Boolean));
394
+ }
395
+ if(deny){
396
+ const denySet = new Set(deny.split(',').map(t => t.trim().toUpperCase()).filter(Boolean));
397
+ return { has: (tag) => !denySet.has(tag) };
398
+ }
399
+ return null;
400
+ }
401
+
402
+ #updateValidity = () => {
403
+ const ta = this.shadowRoot?.querySelector('textarea');
404
+ if(this.required && this.isEmpty){
405
+ this.internals.setValidity(
406
+ { valueMissing: true },
407
+ 'Please fill out this field.',
408
+ ta
409
+ );
410
+ } else {
411
+ this.internals.setValidity({});
412
+ }
413
+ };
414
+
415
+ /*
416
+ Event Handlers
417
+ */
418
+ handleInput = (e) => {
419
+ this.value = e.target.value;
420
+ this.dispatchEvent(new CustomEvent('input', {
421
+ detail: { value: this.value },
422
+ bubbles: true
423
+ }));
424
+ };
425
+
426
+ handleChange = () => {
427
+ this.dispatchEvent(new CustomEvent('change', {
428
+ detail: { value: this.value },
429
+ bubbles: true
430
+ }));
431
+ };
432
+
433
+ handleTabChange = (e) => {
434
+ const newMode = e.detail?.tab;
435
+ if(!newMode || newMode === this.mode) return;
436
+ this.mode = newMode;
437
+ this.dispatchEvent(new CustomEvent('mode-change', {
438
+ detail: { mode: newMode },
439
+ bubbles: true
440
+ }));
441
+ };
442
+
443
+ /*
444
+ Rendering
445
+ */
446
+ render() {
447
+ const set = this.constructor.controlSets[this.controls] ?? this.constructor.controlSets[''];
448
+ return html`
449
+ <k-resize dimension="height" ?disabled=${this.disabled}>
450
+ <div class="frame">
451
+ <k-tabs fixed-height active=${this.mode} @tab=${this.handleTabChange}>
452
+ <k-tab slot="tabs" for="write">Write</k-tab>
453
+ <k-tab slot="tabs" for="preview">Preview</k-tab>
454
+ <k-tab-spacer slot="tabs"></k-tab-spacer>
455
+ <div slot="tabs" class="controls-top">
456
+ <slot name="controls-top">${set.top ?? nothing}</slot>
457
+ </div>
458
+ <k-tab-content name="write">
459
+ <textarea
460
+ class="editor"
461
+ .value=${this.value}
462
+ placeholder=${this.placeholder}
463
+ ?disabled=${this.disabled}
464
+ ?readonly=${this.readonly}
465
+ aria-label=${this.name || this.placeholder}
466
+ @input=${this.handleInput}
467
+ @blur=${this.handleChange}
468
+ ></textarea>
469
+ </k-tab-content>
470
+ <k-tab-content name="preview">
471
+ <div
472
+ class="preview"
473
+ role="article"
474
+ .innerHTML=${this.isEmpty ? '<p class="preview-empty">Nothing to preview</p>' : this.renderedHtml}
475
+ ></div>
476
+ </k-tab-content>
477
+ </k-tabs>
478
+ <div class="footer">
479
+ <slot name="controls-bottom">${set.bottom ?? nothing}</slot>
480
+ </div>
481
+ </div>
482
+ </k-resize>
483
+ `;
484
+ }
485
+
486
+ /*
487
+ Styles
488
+ */
489
+ static styles = css`
490
+ :host {
491
+ --padding: 0.5rem 0.75rem;
492
+ display: block;
493
+ }
494
+ :host([disabled]) {
495
+ opacity: 0.6;
496
+ }
497
+ :host([disabled]) k-tabs {
498
+ pointer-events: none;
499
+ }
500
+ /* readonly: textarea is read-only via the native attribute, but the
501
+ toolbar buttons mutate value programmatically, bypassing it. Mute
502
+ the controls-top wrapper and the footer so their buttons can't
503
+ fire. The Write/Preview tabs themselves stay interactive. */
504
+ :host([readonly]) .controls-top,
505
+ :host([readonly]) .footer {
506
+ pointer-events: none;
507
+ opacity: 0.5;
508
+ }
509
+ .controls-top {
510
+ display: inline-flex;
511
+ align-items: center;
512
+ flex-wrap: wrap;
513
+ }
514
+ k-resize {
515
+ width: 100%;
516
+ height: var(--height, 14rem);
517
+ background: var(--c_bg);
518
+ }
519
+ .frame {
520
+ display: flex;
521
+ flex-direction: column;
522
+ height: 100%;
523
+ width: 100%;
524
+ min-height: 0;
525
+ box-sizing: border-box;
526
+ }
527
+ k-tabs {
528
+ flex: 1 1 auto;
529
+ min-height: 0;
530
+ display: flex;
531
+ flex-direction: column;
532
+ }
533
+ k-tab-content {
534
+ --spacer: 0;
535
+ }
536
+ k-tab-content[active] {
537
+ display: flex;
538
+ flex-direction: column;
539
+ padding: var(--spacer_q);
540
+ }
541
+ k-tab-content:not([active]) {
542
+ display: none;
543
+ }
544
+ .editor,
545
+ .preview {
546
+ box-sizing: border-box;
547
+ flex: 1 1 0;
548
+ min-height: 0;
549
+ width: 100%;
550
+ padding: var(--padding);
551
+ background: transparent;
552
+ color: var(--tc);
553
+ font: inherit;
554
+ line-height: 1.5;
555
+ overflow-y: auto;
556
+ scrollbar-width: thin;
557
+ resize: none;
558
+ }
559
+ .preview > :first-child { margin-top: 0; }
560
+ .preview > :last-child { margin-bottom: 0; }
561
+ .preview-empty {
562
+ color: var(--tc_muted);
563
+ font-style: italic;
564
+ }
565
+ .footer {
566
+ display: flex;
567
+ align-items: center;
568
+ gap: 0.25rem;
569
+ padding: 0.25rem 0.5rem;
570
+ flex: 0 0 auto;
571
+ border-top: 1px solid var(--c_border);
572
+ box-sizing: border-box;
573
+ }
574
+ .footer:not(:has(::slotted(*))) {
575
+ display: none;
576
+ }
577
+ `;
578
+
579
+ /*
580
+ Pre-built sets used as slot fallback content when the `controls`
581
+ attribute is set. Tags reference custom elements that are loaded by
582
+ `loadControls()` — listing them here doesn't require those modules to
583
+ be imported eagerly. Lit creates the elements as plain HTMLElements
584
+ until their definitions arrive, then the browser upgrades them in
585
+ place.
586
+ */
587
+ static controlSets = {
588
+ '': { top: null, bottom: null },
589
+ none: { top: null, bottom: null },
590
+ minimal: {
591
+ top: html`
592
+ <k-md-menu label="Heading">
593
+ <k-icon slot="trigger" name="text_fields"></k-icon>
594
+ <k-md-format-block tag="h1"></k-md-format-block>
595
+ <k-md-format-block tag="h3"></k-md-format-block>
596
+ <k-md-format-block tag="h5"></k-md-format-block>
597
+ </k-md-menu>
598
+ <k-md-bold></k-md-bold>
599
+ <k-md-italic></k-md-italic>
600
+ <k-md-bullet-list></k-md-bullet-list>
601
+ <k-md-numbered-list></k-md-numbered-list>
602
+ `,
603
+ bottom: null
604
+ },
605
+ normal: {
606
+ top: html`
607
+ <k-md-menu label="Heading">
608
+ <k-icon slot="trigger" name="text_fields"></k-icon>
609
+ <k-md-format-block tag="h1"></k-md-format-block>
610
+ <k-md-format-block tag="h2"></k-md-format-block>
611
+ <k-md-format-block tag="h3"></k-md-format-block>
612
+ <k-md-format-block tag="h4"></k-md-format-block>
613
+ <k-md-format-block tag="h5"></k-md-format-block>
614
+ <k-md-format-block tag="h6"></k-md-format-block>
615
+ </k-md-menu>
616
+ <k-md-bold></k-md-bold>
617
+ <k-md-italic></k-md-italic>
618
+ <k-md-quote></k-md-quote>
619
+ <k-md-code></k-md-code>
620
+ <k-md-link></k-md-link>
621
+ <k-md-bullet-list></k-md-bullet-list>
622
+ <k-md-numbered-list></k-md-numbered-list>
623
+ `,
624
+ bottom: null
625
+ },
626
+ full: {
627
+ top: html`
628
+ <k-md-menu label="Heading">
629
+ <k-icon slot="trigger" name="text_fields"></k-icon>
630
+ <k-md-format-block tag="h1"></k-md-format-block>
631
+ <k-md-format-block tag="h2"></k-md-format-block>
632
+ <k-md-format-block tag="h3"></k-md-format-block>
633
+ <k-md-format-block tag="h4"></k-md-format-block>
634
+ <k-md-format-block tag="h5"></k-md-format-block>
635
+ <k-md-format-block tag="h6"></k-md-format-block>
636
+ </k-md-menu>
637
+ <k-md-bold></k-md-bold>
638
+ <k-md-italic></k-md-italic>
639
+ <k-md-strikethrough></k-md-strikethrough>
640
+ <k-md-quote></k-md-quote>
641
+ <k-md-code></k-md-code>
642
+ <k-md-link></k-md-link>
643
+ <k-md-image></k-md-image>
644
+ <k-md-table></k-md-table>
645
+ <k-md-bullet-list></k-md-bullet-list>
646
+ <k-md-numbered-list></k-md-numbered-list>
647
+ <k-md-speech-to-text></k-md-speech-to-text>
648
+ `,
649
+ bottom: null
650
+ }
651
+ };
652
+ }
653
+
654
+ customElements.define('k-markdown-editor', MarkdownEditor);