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.
- package/dist/components/Chat.js +201 -0
- package/dist/components/CodeEditor.js +21 -2
- package/dist/components/Dropdown.js +1 -1
- package/dist/components/HtmlEditor.js +42 -3
- package/dist/components/MarkdownEditor.js +173 -0
- package/dist/components/Resize.js +12 -2
- package/dist/components/TextToSpeech.js +4 -4
- package/dist/components/VoiceSelector.js +49 -0
- package/dist/components/markdownEditorControls/Bold.js +11 -0
- package/dist/components/markdownEditorControls/BulletList.js +11 -0
- package/dist/components/markdownEditorControls/Code.js +11 -0
- package/dist/components/markdownEditorControls/FormatBlock.js +30 -0
- package/dist/components/markdownEditorControls/Heading.js +11 -0
- package/dist/components/markdownEditorControls/Image.js +93 -0
- package/dist/components/markdownEditorControls/Italic.js +11 -0
- package/dist/components/markdownEditorControls/Link.js +11 -0
- package/dist/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
- package/dist/components/markdownEditorControls/Menu.js +30 -0
- package/dist/components/markdownEditorControls/NumberedList.js +11 -0
- package/dist/components/markdownEditorControls/Quote.js +11 -0
- package/dist/components/markdownEditorControls/SpeechToText.js +16 -0
- package/dist/components/markdownEditorControls/Strikethrough.js +11 -0
- package/dist/components/markdownEditorControls/Table.js +80 -0
- package/dist/utils/marked.esm.js +1 -0
- package/dist/utils/renderMarkdown.js +1 -0
- package/dist/utils/sanitizeHtml.js +1 -0
- package/dist/utils/voice.js +1 -0
- package/docs/components/accordion.html +19 -0
- package/docs/components/aside.html +19 -0
- package/docs/components/calendar.html +19 -0
- package/docs/components/card.html +19 -0
- package/docs/components/chat.html +715 -0
- package/docs/components/code-editor.html +55 -0
- package/docs/components/color-picker.html +19 -0
- package/docs/components/combobox.html +19 -0
- package/docs/components/content-slider.html +19 -0
- package/docs/components/context.html +19 -0
- package/docs/components/dialog.html +19 -0
- package/docs/components/dropdown.html +19 -0
- package/docs/components/filter-list.html +19 -0
- package/docs/components/focus-capture.html +19 -0
- package/docs/components/html-editor.html +60 -0
- package/docs/components/hybrid-component.html +19 -0
- package/docs/components/icon.html +19 -0
- package/docs/components/import.html +19 -0
- package/docs/components/light-component.html +19 -0
- package/docs/components/markdown-editor.html +924 -0
- package/docs/components/nav-spacer.html +19 -0
- package/docs/components/nav.html +19 -0
- package/docs/components/photo-viewer.html +19 -0
- package/docs/components/progress.html +19 -0
- package/docs/components/resize.html +22 -0
- package/docs/components/rich-textarea.html +650 -0
- package/docs/components/shadow-component.html +19 -0
- package/docs/components/show-more.html +19 -0
- package/docs/components/slider.html +19 -0
- package/docs/components/sortable.html +19 -0
- package/docs/components/speech-to-text.html +19 -0
- package/docs/components/spinner.html +19 -0
- package/docs/components/split.html +19 -0
- package/docs/components/table.html +19 -0
- package/docs/components/tableControls.html +19 -0
- package/docs/components/tableCustomFields.html +19 -0
- package/docs/components/tableFetchRecords.html +19 -0
- package/docs/components/tableFieldSortHide.html +19 -0
- package/docs/components/tablePagination.html +19 -0
- package/docs/components/tablePlaceholder.html +19 -0
- package/docs/components/tableRecordEditing.html +19 -0
- package/docs/components/tableRecordFiltering.html +19 -0
- package/docs/components/tableRecordHiding.html +19 -0
- package/docs/components/tableRecordSearching.html +19 -0
- package/docs/components/tableRecordSelection.html +19 -0
- package/docs/components/tableRowControls.html +19 -0
- package/docs/components/tableServerSync.html +19 -0
- package/docs/components/tableSorting.html +19 -0
- package/docs/components/tabs.html +19 -0
- package/docs/components/tags.html +19 -0
- package/docs/components/text-to-speech.html +63 -1
- package/docs/components/theme-select.html +19 -0
- package/docs/components/theme-switcher.html +19 -0
- package/docs/components/time.html +19 -0
- package/docs/components/timestamp.html +19 -0
- package/docs/components/toast.html +19 -0
- package/docs/components/toggle.html +19 -0
- package/docs/components/tree.html +19 -0
- package/docs/components/voice-selector.html +610 -0
- package/docs/icons/done_all.svg +1 -0
- package/docs/icons/format_quote.svg +1 -0
- package/docs/icons/format_strikethrough.svg +1 -0
- package/docs/icons/send.svg +1 -0
- package/docs/icons/text_fields.svg +1 -0
- package/docs/index.html +49 -0
- package/docs/src/components/Chat.js +201 -0
- package/docs/src/components/CodeEditor.js +21 -2
- package/docs/src/components/Dropdown.js +1 -1
- package/docs/src/components/HtmlEditor.js +42 -3
- package/docs/src/components/MarkdownEditor.js +173 -0
- package/docs/src/components/Resize.js +12 -2
- package/docs/src/components/TextToSpeech.js +4 -4
- package/docs/src/components/VoiceSelector.js +49 -0
- package/docs/src/components/markdownEditorControls/Bold.js +11 -0
- package/docs/src/components/markdownEditorControls/BulletList.js +11 -0
- package/docs/src/components/markdownEditorControls/Code.js +11 -0
- package/docs/src/components/markdownEditorControls/FormatBlock.js +30 -0
- package/docs/src/components/markdownEditorControls/Heading.js +11 -0
- package/docs/src/components/markdownEditorControls/Image.js +93 -0
- package/docs/src/components/markdownEditorControls/Italic.js +11 -0
- package/docs/src/components/markdownEditorControls/Link.js +11 -0
- package/docs/src/components/markdownEditorControls/MarkdownEditorControl.js +39 -0
- package/docs/src/components/markdownEditorControls/Menu.js +30 -0
- package/docs/src/components/markdownEditorControls/NumberedList.js +11 -0
- package/docs/src/components/markdownEditorControls/Quote.js +11 -0
- package/docs/src/components/markdownEditorControls/SpeechToText.js +16 -0
- package/docs/src/components/markdownEditorControls/Strikethrough.js +11 -0
- package/docs/src/components/markdownEditorControls/Table.js +80 -0
- package/docs/src/utils/marked.esm.js +1 -0
- package/docs/src/utils/renderMarkdown.js +1 -0
- package/docs/src/utils/sanitizeHtml.js +1 -0
- package/docs/src/utils/voice.js +1 -0
- package/docs/utils/context.html +19 -0
- package/docs/utils/cookie.html +19 -0
- package/docs/utils/debounce.html +19 -0
- package/docs/utils/drag.html +19 -0
- package/docs/utils/elevation.html +23 -10
- package/docs/utils/formatTimestamp.html +19 -0
- package/docs/utils/object.html +19 -0
- package/docs/utils/propConverters.html +19 -0
- package/docs/utils/sanitizeHtml.html +555 -0
- package/docs/utils/string.html +19 -0
- package/docs/utils/theme.html +19 -0
- package/docs/utils/toTitleCase.html +19 -0
- package/docs/utils/type.html +19 -0
- package/docs/utils/voice.html +548 -0
- package/docs/utils/wait.html +19 -0
- package/docs-src/components/chat.page.html +230 -0
- package/docs-src/components/code-editor.page.html +36 -0
- package/docs-src/components/html-editor.page.html +41 -0
- package/docs-src/components/markdown-editor.page.html +439 -0
- package/docs-src/components/resize.page.html +3 -0
- package/docs-src/components/text-to-speech.page.html +44 -1
- package/docs-src/components/voice-selector.page.html +125 -0
- package/docs-src/index.page.html +30 -0
- package/docs-src/nav.fragment.html +19 -0
- package/docs-src/utils/elevation.page.html +4 -10
- package/docs-src/utils/sanitizeHtml.page.html +73 -0
- package/docs-src/utils/voice.page.html +66 -0
- package/icons/done_all.svg +1 -0
- package/icons/format_quote.svg +1 -0
- package/icons/format_strikethrough.svg +1 -0
- package/icons/send.svg +1 -0
- package/icons/text_fields.svg +1 -0
- package/llms.txt +7 -2
- package/package.json +3 -2
- package/src/components/Chat.js +435 -0
- package/src/components/CodeEditor.js +58 -2
- package/src/components/Dropdown.js +1 -1
- package/src/components/HtmlEditor.js +84 -4
- package/src/components/MarkdownEditor.js +654 -0
- package/src/components/Resize.js +17 -1
- package/src/components/TextToSpeech.js +13 -2
- package/src/components/VoiceSelector.js +271 -0
- package/src/components/markdownEditorControls/Bold.js +30 -0
- package/src/components/markdownEditorControls/BulletList.js +30 -0
- package/src/components/markdownEditorControls/Code.js +39 -0
- package/src/components/markdownEditorControls/FormatBlock.js +102 -0
- package/src/components/markdownEditorControls/Heading.js +45 -0
- package/src/components/markdownEditorControls/Image.js +250 -0
- package/src/components/markdownEditorControls/Italic.js +30 -0
- package/src/components/markdownEditorControls/Link.js +74 -0
- package/src/components/markdownEditorControls/MarkdownEditorControl.js +158 -0
- package/src/components/markdownEditorControls/Menu.js +86 -0
- package/src/components/markdownEditorControls/NumberedList.js +69 -0
- package/src/components/markdownEditorControls/Quote.js +30 -0
- package/src/components/markdownEditorControls/SpeechToText.js +65 -0
- package/src/components/markdownEditorControls/Strikethrough.js +35 -0
- package/src/components/markdownEditorControls/Table.js +164 -0
- package/src/utils/marked.esm.js +77 -0
- package/src/utils/renderMarkdown.js +29 -0
- package/src/utils/sanitizeHtml.js +137 -0
- package/src/utils/voice.js +103 -0
- package/tests/components/Chat.browser-test.js +540 -0
- package/tests/components/TextToSpeech.browser-test.js +150 -1
- package/tests/components/VoiceSelector.browser-test.js +374 -0
- package/tests/utils/sanitizeHtml.browser-test.js +179 -0
- 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);
|