kempo-ui 0.4.15 → 0.4.17
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/CHANGELOG.md +22 -0
- package/dist/components/MarkdownEditor.js +6 -2
- package/docs/components/markdown-editor.html +24 -4
- package/docs/src/components/MarkdownEditor.js +6 -2
- package/docs-src/components/markdown-editor.page.html +24 -4
- package/package.json +1 -1
- package/src/components/MarkdownEditor.js +33 -75
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.17] - 2026-05-03
|
|
10
|
+
### Added
|
|
11
|
+
- **Markdown Editor component**: CSS size variables `--min-height` and `--max-height`
|
|
12
|
+
- New `--min-height` CSS variable to prevent editor from shrinking below a minimum size
|
|
13
|
+
- New `--max-height` CSS variable to add vertical scrollbar when content exceeds max height
|
|
14
|
+
- Works alongside existing `--height` variable for flexible sizing
|
|
15
|
+
- Documentation updated with sizing example showing all three variables used together
|
|
16
|
+
|
|
17
|
+
## [0.4.16] - 2026-05-03
|
|
18
|
+
### Added
|
|
19
|
+
- **Markdown Editor component**: Debounced change event during input
|
|
20
|
+
- `change` event now fires with a 300ms debounce when text is being typed
|
|
21
|
+
- Previously, `change` events only fired on blur or native change events
|
|
22
|
+
- Enables apps to use the `change` event for enable/disable logic during long typing sessions
|
|
23
|
+
- Useful for "Save" button state management without waiting for blur
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Markdown Editor component**: Code style improvements
|
|
27
|
+
- Converted from hash private members (`#field`) to Symbol-keyed properties for full browser compatibility (Safari support)
|
|
28
|
+
- Reorganized comments to follow component-code skill pattern
|
|
29
|
+
- Removed unnecessary multi-line comments, keeping only documentation for properties and complex logic
|
|
30
|
+
|
|
9
31
|
## [0.4.13] - 2026-05-03
|
|
10
32
|
### Added
|
|
11
33
|
- **Markdown Editor component**: spacing but
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import
|
|
1
|
+
import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import o from"../utils/renderMarkdown.js";import l,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import n from"../utils/debounce.js";import"./Resize.js";import"./Tabs.js";const r=Symbol(),d=Symbol(),m=Symbol(),h=Symbol(),c=Symbol();export default class u extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this[r]="",this[m]=!1,this[d]=n(()=>this.handleChange(),300)}async loadControls(){if(this[m])return;this[m]=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this[r]=this.getAttribute("value")),!this.value&&this[r]&&(this.value=this[r])}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this[c]()}formResetCallback(){this.value=this[r],this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o}=i,l=i.value.substring(0,s),a=i.value.substring(o),n=l+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:o,selectionEnd:l,value:a}=s,n=a.substring(o,l);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,o)+i+a.substring(l);return s.value=r,s.selectionStart=o,s.selectionEnd=o+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,o-t.length),o),d=a.substring(l,Math.min(a.length,l+e.length));if(r===t&&d===e){const i=a.substring(0,o-t.length)+n+a.substring(l+e.length);return s.value=i,s.selectionStart=o-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,h=a.substring(0,o)+t+m+e+a.substring(l);s.value=h;const c=o+t.length;s.selectionStart=c,s.selectionEnd=c+m.length,this.value=h,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const h=l.substring(0,a)+m+l.substring(r);i.value=h,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=h,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),h=m.length>0&&m.every(e=>e.startsWith(t)),c=d.map(i=>0===i.length?i:h?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=l.substring(0,a)+c+l.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+c.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this[h];if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return l(o(this.value||""),t)}get[h](){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}[c]=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0})),this[d]()};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
|
|
2
2
|
<k-resize dimension="height" ?disabled=${this.disabled}>
|
|
3
3
|
<div class="frame">
|
|
4
4
|
<k-tabs fixed-height active=${this.mode} @tab=${this.handleTabChange}>
|
|
@@ -18,6 +18,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
18
18
|
aria-label=${this.name||this.placeholder}
|
|
19
19
|
@input=${this.handleInput}
|
|
20
20
|
@blur=${this.handleChange}
|
|
21
|
+
@change=${this.handleChange}
|
|
21
22
|
></textarea>
|
|
22
23
|
</k-tab-content>
|
|
23
24
|
<k-tab-content name="preview">
|
|
@@ -61,6 +62,8 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
61
62
|
k-resize {
|
|
62
63
|
width: 100%;
|
|
63
64
|
height: var(--height, 14rem);
|
|
65
|
+
min-height: var(--min-height);
|
|
66
|
+
max-height: var(--max-height);
|
|
64
67
|
background: var(--c_bg);
|
|
65
68
|
}
|
|
66
69
|
.frame {
|
|
@@ -99,6 +102,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
99
102
|
overflow-y: auto;
|
|
100
103
|
scrollbar-width: thin;
|
|
101
104
|
resize: none;
|
|
105
|
+
max-height: none !important;
|
|
102
106
|
}
|
|
103
107
|
.preview-empty {
|
|
104
108
|
color: var(--tc_muted);
|
|
@@ -165,4 +169,4 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
165
169
|
<k-md-bullet-list></k-md-bullet-list>
|
|
166
170
|
<k-md-numbered-list></k-md-numbered-list>
|
|
167
171
|
<k-md-speech-to-text></k-md-speech-to-text>
|
|
168
|
-
`,bottom:null}}}customElements.define("k-markdown-editor",
|
|
172
|
+
`,bottom:null}}}customElements.define("k-markdown-editor",u);
|
|
@@ -502,6 +502,7 @@
|
|
|
502
502
|
<a href="#disabled">Disabled / Read-only</a><br />
|
|
503
503
|
<a href="#tagFiltering">Tag Filtering</a><br />
|
|
504
504
|
<a href="#customControls">Custom Controls</a><br />
|
|
505
|
+
<a href="#sizingExample">Sizing Example</a><br />
|
|
505
506
|
|
|
506
507
|
<h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
|
|
507
508
|
<a href="#constructor">Constructor</a><br />
|
|
@@ -768,6 +769,25 @@
|
|
|
768
769
|
customElements.<span class="hljs-title function_">define</span>(<span class="hljs-string">'k-md-hr'</span>, <span class="hljs-title class_">MarkdownHr</span>);</code></pre>
|
|
769
770
|
</k-card>
|
|
770
771
|
|
|
772
|
+
<h3 id="sizingExample"><a href="#sizingExample" class="no-link">Sizing Example</a></h3>
|
|
773
|
+
<p>Use the three sizing CSS variables together to control the editor dimensions. Set <code>--height</code> for the default size, <code>--min-height</code> to prevent it from shrinking, and <code>--max-height</code> to add a vertical scrollbar when content grows.</p>
|
|
774
|
+
<div class="row -mx">
|
|
775
|
+
<div class="col m-span-12 px">
|
|
776
|
+
<k-card label="CSS">
|
|
777
|
+
<pre><code class="hljs css"><span class="hljs-selector-tag">k-markdown-editor</span> {
|
|
778
|
+
<span class="hljs-attr">--height</span>: <span class="hljs-number">20rem</span>;
|
|
779
|
+
<span class="hljs-attr">--min-height</span>: <span class="hljs-number">10rem</span>;
|
|
780
|
+
<span class="hljs-attr">--max-height</span>: <span class="hljs-number">40rem</span>;
|
|
781
|
+
}</code></pre>
|
|
782
|
+
</k-card>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="col m-span-12 px">
|
|
785
|
+
<k-card label="Output">
|
|
786
|
+
<k-markdown-editor placeholder="Type to expand, but max out at 40rem..." style="--height: 20rem; --min-height: 10rem; --max-height: 40rem;"></k-markdown-editor>
|
|
787
|
+
</k-card>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
771
791
|
<h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
|
|
772
792
|
|
|
773
793
|
<h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
|
|
@@ -864,10 +884,10 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
|
|
|
864
884
|
<table class="b">
|
|
865
885
|
<thead><tr><th>Variable</th><th>Default</th><th>Description</th></tr></thead>
|
|
866
886
|
<tbody>
|
|
867
|
-
<tr><td><code>--
|
|
868
|
-
<tr><td><code>--
|
|
887
|
+
<tr><td><code>--height</code></td><td><code>14rem</code></td><td>Height of the editor container</td></tr>
|
|
888
|
+
<tr><td><code>--min-height</code></td><td>none</td><td>Minimum height of the editor</td></tr>
|
|
889
|
+
<tr><td><code>--max-height</code></td><td>none</td><td>Maximum height before the editor scrolls internally</td></tr>
|
|
869
890
|
<tr><td><code>--padding</code></td><td><code>0.5rem 0.75rem</code></td><td>Padding inside the textarea / preview</td></tr>
|
|
870
|
-
<tr><td><code>--border</code></td><td><code>1px solid var(--c_border)</code></td><td>Border around the editor</td></tr>
|
|
871
891
|
</tbody>
|
|
872
892
|
</table>
|
|
873
893
|
<p class="mt">The component also uses kempo-css's global <code>--c_border</code>, <code>--c_bg</code>, <code>--tc</code>, <code>--tc_muted</code>, <code>--radius</code>, <code>--animation_ms</code>, and <code>--focus_shadow</code>; override those globally for site-wide tuning.</p>
|
|
@@ -877,7 +897,7 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
|
|
|
877
897
|
<p>Fires on every keystroke (or programmatic change via the selection-manipulation API). <code>detail.value</code> is the current markdown.</p>
|
|
878
898
|
|
|
879
899
|
<h5><code>change</code></h5>
|
|
880
|
-
<p>Fires
|
|
900
|
+
<p>Fires with a 300ms debounce whenever text is typed, and also on blur. <code>detail.value</code> is the current markdown. Useful for enabling/disabling a "Save" button during long typing sessions without waiting for blur.</p>
|
|
881
901
|
|
|
882
902
|
<h5><code>mode-change</code></h5>
|
|
883
903
|
<p>Fires when the user switches between Write and Preview tabs. <code>detail.mode</code> is the new mode.</p>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import
|
|
1
|
+
import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./ShadowComponent.js";import o from"../utils/renderMarkdown.js";import l,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import n from"../utils/debounce.js";import"./Resize.js";import"./Tabs.js";const r=Symbol(),d=Symbol(),m=Symbol(),h=Symbol(),c=Symbol();export default class u extends s{static formAssociated=!0;static properties={value:{type:String},name:{type:String,reflect:!0},placeholder:{type:String,reflect:!0},disabled:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},readonly:{type:Boolean,reflect:!0},mode:{type:String,reflect:!0},allowedTags:{type:String,reflect:!0,attribute:"allowed-tags"},disallowedTags:{type:String,reflect:!0,attribute:"disallowed-tags"},scriptsEnabled:{type:Boolean,reflect:!0,attribute:"scripts-enabled"},controls:{type:String,reflect:!0}};constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.disabled=!1,this.required=!1,this.readonly=!1,this.mode="write",this.allowedTags="",this.disallowedTags="",this.scriptsEnabled=!1,this.controls="",this[r]="",this[m]=!1,this[d]=n(()=>this.handleChange(),300)}async loadControls(){if(this[m])return;this[m]=!0;const t=new URL("./markdownEditorControls/",import.meta.url).href;await Promise.all([import(`${t}Bold.js`),import(`${t}Italic.js`),import(`${t}Strikethrough.js`),import(`${t}Heading.js`),import(`${t}Code.js`),import(`${t}Link.js`),import(`${t}Image.js`),import(`${t}Table.js`),import(`${t}BulletList.js`),import(`${t}NumberedList.js`),import(`${t}Quote.js`),import(`${t}Menu.js`),import(`${t}FormatBlock.js`),import(`${t}SpeechToText.js`)]),this.requestUpdate()}connectedCallback(){super.connectedCallback(),this.hasAttribute("value")&&(this[r]=this.getAttribute("value")),!this.value&&this[r]&&(this.value=this[r])}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this[c]()}formResetCallback(){this.value=this[r],this.mode="write"}formStateRestoreCallback(t){"string"==typeof t&&(this.value=t)}formDisabledCallback(t){this.disabled=t}focus(){"write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{this.shadowRoot?.querySelector("textarea")?.focus()})}blur(){this.shadowRoot?.querySelector("textarea")?.blur()}clear(){this.value=""}setMode(t){"write"!==t&&"preview"!==t||(this.mode=t)}togglePreview(){this.mode="write"===this.mode?"preview":"write"}get textarea(){return this.shadowRoot?.querySelector("textarea")||null}getSelection(){const t=this.textarea;return t?{start:t.selectionStart,end:t.selectionEnd,text:t.value.substring(t.selectionStart,t.selectionEnd)}:{start:0,end:0,text:""}}replaceSelection(t,{selectInserted:e=!0}={}){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o}=i,l=i.value.substring(0,s),a=i.value.substring(o),n=l+t+a;i.value=n;const r=s+t.length;i.selectionStart=e?s:r,i.selectionEnd=r,this.value=n,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}wrapSelection(t,e=t,i=""){const s=this.textarea;s?("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{s.focus();const{selectionStart:o,selectionEnd:l,value:a}=s,n=a.substring(o,l);if(n.length>=t.length+e.length&&n.startsWith(t)&&n.endsWith(e)){const i=n.slice(t.length,n.length-e.length),r=a.substring(0,o)+i+a.substring(l);return s.value=r,s.selectionStart=o,s.selectionEnd=o+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,o-t.length),o),d=a.substring(l,Math.min(a.length,l+e.length));if(r===t&&d===e){const i=a.substring(0,o-t.length)+n+a.substring(l+e.length);return s.value=i,s.selectionStart=o-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,h=a.substring(0,o)+t+m+e+a.substring(l);s.value=h;const c=o+t.length;s.selectionStart=c,s.selectionEnd=c+m.length,this.value=h,s.dispatchEvent(new Event("input",{bubbles:!0}))})):this.replaceSelection(t+i+e)}insertAtCursor(t){this.replaceSelection(t,{selectInserted:!1})}replaceInSelectedLines(t,e=""){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const h=l.substring(0,a)+m+l.substring(r);i.value=h,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=h,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}insertLinePrefix(t,e=null){const i=this.textarea;i&&("write"!==this.mode&&(this.mode="write"),this.updateComplete.then(()=>{i.focus();const{selectionStart:s,selectionEnd:o,value:l}=i,a=l.lastIndexOf("\n",s-1)+1,n=l.indexOf("\n",o),r=-1===n?l.length:n,d=l.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),h=m.length>0&&m.every(e=>e.startsWith(t)),c=d.map(i=>0===i.length?i:h?i.startsWith(t)?i.slice(t.length):i:i.startsWith(t)?i:e&&e.test(i)?i.replace(e,t):t+i).join("\n"),u=l.substring(0,a)+c+l.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+c.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this[h];if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return l(o(this.value||""),t)}get[h](){const t=(this.allowedTags||"").trim(),e=(this.disallowedTags||"").trim();if(t&&e&&console.warn("[k-markdown-editor] `allowed-tags` and `disallowed-tags` are mutually exclusive; using `allowed-tags`."),t)return"*"===t?{has:()=>!0}:new Set(t.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));if(e){const t=new Set(e.split(",").map(t=>t.trim().toUpperCase()).filter(Boolean));return{has:e=>!t.has(e)}}return null}[c]=()=>{const t=this.shadowRoot?.querySelector("textarea");this.required&&this.isEmpty?this.internals.setValidity({valueMissing:!0},"Please fill out this field.",t):this.internals.setValidity({})};handleInput=t=>{this.value=t.target.value,this.dispatchEvent(new CustomEvent("input",{detail:{value:this.value},bubbles:!0})),this[d]()};handleChange=()=>{this.dispatchEvent(new CustomEvent("change",{detail:{value:this.value},bubbles:!0}))};handleTabChange=t=>{const e=t.detail?.tab;e&&e!==this.mode&&(this.mode=e,this.dispatchEvent(new CustomEvent("mode-change",{detail:{mode:e},bubbles:!0})))};render(){const e=this.constructor.controlSets[this.controls]??this.constructor.controlSets[""];return t`
|
|
2
2
|
<k-resize dimension="height" ?disabled=${this.disabled}>
|
|
3
3
|
<div class="frame">
|
|
4
4
|
<k-tabs fixed-height active=${this.mode} @tab=${this.handleTabChange}>
|
|
@@ -18,6 +18,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
18
18
|
aria-label=${this.name||this.placeholder}
|
|
19
19
|
@input=${this.handleInput}
|
|
20
20
|
@blur=${this.handleChange}
|
|
21
|
+
@change=${this.handleChange}
|
|
21
22
|
></textarea>
|
|
22
23
|
</k-tab-content>
|
|
23
24
|
<k-tab-content name="preview">
|
|
@@ -61,6 +62,8 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
61
62
|
k-resize {
|
|
62
63
|
width: 100%;
|
|
63
64
|
height: var(--height, 14rem);
|
|
65
|
+
min-height: var(--min-height);
|
|
66
|
+
max-height: var(--max-height);
|
|
64
67
|
background: var(--c_bg);
|
|
65
68
|
}
|
|
66
69
|
.frame {
|
|
@@ -99,6 +102,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
99
102
|
overflow-y: auto;
|
|
100
103
|
scrollbar-width: thin;
|
|
101
104
|
resize: none;
|
|
105
|
+
max-height: none !important;
|
|
102
106
|
}
|
|
103
107
|
.preview-empty {
|
|
104
108
|
color: var(--tc_muted);
|
|
@@ -165,4 +169,4 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
|
|
|
165
169
|
<k-md-bullet-list></k-md-bullet-list>
|
|
166
170
|
<k-md-numbered-list></k-md-numbered-list>
|
|
167
171
|
<k-md-speech-to-text></k-md-speech-to-text>
|
|
168
|
-
`,bottom:null}}}customElements.define("k-markdown-editor",
|
|
172
|
+
`,bottom:null}}}customElements.define("k-markdown-editor",u);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="#disabled">Disabled / Read-only</a><br />
|
|
15
15
|
<a href="#tagFiltering">Tag Filtering</a><br />
|
|
16
16
|
<a href="#customControls">Custom Controls</a><br />
|
|
17
|
+
<a href="#sizingExample">Sizing Example</a><br />
|
|
17
18
|
|
|
18
19
|
<h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
|
|
19
20
|
<a href="#constructor">Constructor</a><br />
|
|
@@ -280,6 +281,25 @@
|
|
|
280
281
|
customElements.<span class="hljs-title function_">define</span>(<span class="hljs-string">'k-md-hr'</span>, <span class="hljs-title class_">MarkdownHr</span>);</code></pre>
|
|
281
282
|
</k-card>
|
|
282
283
|
|
|
284
|
+
<h3 id="sizingExample"><a href="#sizingExample" class="no-link">Sizing Example</a></h3>
|
|
285
|
+
<p>Use the three sizing CSS variables together to control the editor dimensions. Set <code>--height</code> for the default size, <code>--min-height</code> to prevent it from shrinking, and <code>--max-height</code> to add a vertical scrollbar when content grows.</p>
|
|
286
|
+
<div class="row -mx">
|
|
287
|
+
<div class="col m-span-12 px">
|
|
288
|
+
<k-card label="CSS">
|
|
289
|
+
<pre><code class="hljs css"><span class="hljs-selector-tag">k-markdown-editor</span> {
|
|
290
|
+
<span class="hljs-attr">--height</span>: <span class="hljs-number">20rem</span>;
|
|
291
|
+
<span class="hljs-attr">--min-height</span>: <span class="hljs-number">10rem</span>;
|
|
292
|
+
<span class="hljs-attr">--max-height</span>: <span class="hljs-number">40rem</span>;
|
|
293
|
+
}</code></pre>
|
|
294
|
+
</k-card>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="col m-span-12 px">
|
|
297
|
+
<k-card label="Output">
|
|
298
|
+
<k-markdown-editor placeholder="Type to expand, but max out at 40rem..." style="--height: 20rem; --min-height: 10rem; --max-height: 40rem;"></k-markdown-editor>
|
|
299
|
+
</k-card>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
283
303
|
<h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
|
|
284
304
|
|
|
285
305
|
<h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
|
|
@@ -376,10 +396,10 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
|
|
|
376
396
|
<table class="b">
|
|
377
397
|
<thead><tr><th>Variable</th><th>Default</th><th>Description</th></tr></thead>
|
|
378
398
|
<tbody>
|
|
379
|
-
<tr><td><code>--
|
|
380
|
-
<tr><td><code>--
|
|
399
|
+
<tr><td><code>--height</code></td><td><code>14rem</code></td><td>Height of the editor container</td></tr>
|
|
400
|
+
<tr><td><code>--min-height</code></td><td>none</td><td>Minimum height of the editor</td></tr>
|
|
401
|
+
<tr><td><code>--max-height</code></td><td>none</td><td>Maximum height before the editor scrolls internally</td></tr>
|
|
381
402
|
<tr><td><code>--padding</code></td><td><code>0.5rem 0.75rem</code></td><td>Padding inside the textarea / preview</td></tr>
|
|
382
|
-
<tr><td><code>--border</code></td><td><code>1px solid var(--c_border)</code></td><td>Border around the editor</td></tr>
|
|
383
403
|
</tbody>
|
|
384
404
|
</table>
|
|
385
405
|
<p class="mt">The component also uses kempo-css's global <code>--c_border</code>, <code>--c_bg</code>, <code>--tc</code>, <code>--tc_muted</code>, <code>--radius</code>, <code>--animation_ms</code>, and <code>--focus_shadow</code>; override those globally for site-wide tuning.</p>
|
|
@@ -389,7 +409,7 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
|
|
|
389
409
|
<p>Fires on every keystroke (or programmatic change via the selection-manipulation API). <code>detail.value</code> is the current markdown.</p>
|
|
390
410
|
|
|
391
411
|
<h5><code>change</code></h5>
|
|
392
|
-
<p>Fires
|
|
412
|
+
<p>Fires with a 300ms debounce whenever text is typed, and also on blur. <code>detail.value</code> is the current markdown. Useful for enabling/disabling a "Save" button during long typing sessions without waiting for blur.</p>
|
|
393
413
|
|
|
394
414
|
<h5><code>mode-change</code></h5>
|
|
395
415
|
<p>Fires when the user switches between Write and Preview tabs. <code>detail.mode</code> is the new mode.</p>
|
package/package.json
CHANGED
|
@@ -2,12 +2,17 @@ import { html, css, nothing } from '../lit-all.min.js';
|
|
|
2
2
|
import ShadowComponent from './ShadowComponent.js';
|
|
3
3
|
import renderMarkdown from '../utils/renderMarkdown.js';
|
|
4
4
|
import sanitizeHtml, { STRIP_COMPLETELY } from '../utils/sanitizeHtml.js';
|
|
5
|
+
import debounce from '../utils/debounce.js';
|
|
5
6
|
import './Resize.js';
|
|
6
7
|
import './Tabs.js';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const defaultValue = Symbol();
|
|
10
|
+
const debouncedChange = Symbol();
|
|
11
|
+
const controlsLoaded = Symbol();
|
|
12
|
+
const resolvedAllowedTags = Symbol();
|
|
13
|
+
const updateValidity = Symbol();
|
|
10
14
|
|
|
15
|
+
/*
|
|
11
16
|
Textarea that writes markdown, with a live "Preview" tab. Designed to mimic
|
|
12
17
|
GitHub's comment editor — write tab + preview tab + control slots above and
|
|
13
18
|
below the textarea. Markdown is parsed by snarkdown, which intentionally
|
|
@@ -17,10 +22,12 @@ import './Tabs.js';
|
|
|
17
22
|
Controls go in the `controls-top` and `controls-bottom` slots; subclass
|
|
18
23
|
MarkdownEditorControl (./MarkdownEditorControl.js) to write your own.
|
|
19
24
|
*/
|
|
20
|
-
|
|
21
25
|
export default class MarkdownEditor extends ShadowComponent {
|
|
22
26
|
static formAssociated = true;
|
|
23
27
|
|
|
28
|
+
/*
|
|
29
|
+
Reactive Properties / Attributes
|
|
30
|
+
*/
|
|
24
31
|
static properties = {
|
|
25
32
|
value: { type: String },
|
|
26
33
|
name: { type: String, reflect: true },
|
|
@@ -62,10 +69,8 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
62
69
|
controls: { type: String, reflect: true }
|
|
63
70
|
};
|
|
64
71
|
|
|
65
|
-
#defaultValue = '';
|
|
66
|
-
|
|
67
72
|
/*
|
|
68
|
-
|
|
73
|
+
Constructor
|
|
69
74
|
*/
|
|
70
75
|
constructor() {
|
|
71
76
|
super();
|
|
@@ -81,20 +86,17 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
81
86
|
this.disallowedTags = '';
|
|
82
87
|
this.scriptsEnabled = false;
|
|
83
88
|
this.controls = '';
|
|
84
|
-
this
|
|
89
|
+
this[defaultValue] = '';
|
|
90
|
+
this[controlsLoaded] = false;
|
|
91
|
+
this[debouncedChange] = debounce(() => this.handleChange(), 300);
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
#controlsLoaded = false;
|
|
88
|
-
|
|
89
94
|
/*
|
|
90
|
-
|
|
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.
|
|
95
|
+
Lifecycle
|
|
94
96
|
*/
|
|
95
97
|
async loadControls() {
|
|
96
|
-
if(this
|
|
97
|
-
this
|
|
98
|
+
if(this[controlsLoaded]) return;
|
|
99
|
+
this[controlsLoaded] = true;
|
|
98
100
|
const base = new URL('./markdownEditorControls/', import.meta.url).href;
|
|
99
101
|
await Promise.all([
|
|
100
102
|
import(/* @vite-ignore */ `${base}Bold.js`),
|
|
@@ -117,14 +119,11 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
117
119
|
|
|
118
120
|
connectedCallback() {
|
|
119
121
|
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
122
|
if(this.hasAttribute('value')){
|
|
124
|
-
this
|
|
123
|
+
this[defaultValue] = this.getAttribute('value');
|
|
125
124
|
}
|
|
126
|
-
if(!this.value && this
|
|
127
|
-
this.value = this
|
|
125
|
+
if(!this.value && this[defaultValue]){
|
|
126
|
+
this.value = this[defaultValue];
|
|
128
127
|
}
|
|
129
128
|
}
|
|
130
129
|
|
|
@@ -136,14 +135,11 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
136
135
|
if(changedProperties.has('controls') && this.controls && this.controls !== 'none'){
|
|
137
136
|
this.loadControls();
|
|
138
137
|
}
|
|
139
|
-
this
|
|
138
|
+
this[updateValidity]();
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
/*
|
|
143
|
-
Form-associated callbacks
|
|
144
|
-
*/
|
|
145
141
|
formResetCallback() {
|
|
146
|
-
this.value = this
|
|
142
|
+
this.value = this[defaultValue];
|
|
147
143
|
this.mode = 'write';
|
|
148
144
|
}
|
|
149
145
|
|
|
@@ -156,7 +152,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
156
152
|
}
|
|
157
153
|
|
|
158
154
|
/*
|
|
159
|
-
Public
|
|
155
|
+
Public Methods
|
|
160
156
|
*/
|
|
161
157
|
focus() {
|
|
162
158
|
if(this.mode !== 'write') this.mode = 'write';
|
|
@@ -181,11 +177,6 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
181
177
|
this.mode = this.mode === 'write' ? 'preview' : 'write';
|
|
182
178
|
}
|
|
183
179
|
|
|
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
180
|
get textarea() {
|
|
190
181
|
return this.shadowRoot?.querySelector('textarea') || null;
|
|
191
182
|
}
|
|
@@ -219,12 +210,6 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
219
210
|
});
|
|
220
211
|
}
|
|
221
212
|
|
|
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
213
|
wrapSelection(prefix, suffix = prefix, placeholder = '') {
|
|
229
214
|
const ta = this.textarea;
|
|
230
215
|
if(!ta){
|
|
@@ -282,19 +267,6 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
282
267
|
this.replaceSelection(text, { selectInserted: false });
|
|
283
268
|
}
|
|
284
269
|
|
|
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
270
|
replaceInSelectedLines(pattern, replacement = '') {
|
|
299
271
|
const ta = this.textarea;
|
|
300
272
|
if(!ta) return;
|
|
@@ -349,7 +321,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
349
321
|
}
|
|
350
322
|
|
|
351
323
|
/*
|
|
352
|
-
|
|
324
|
+
Protected Members
|
|
353
325
|
*/
|
|
354
326
|
get isEmpty() {
|
|
355
327
|
return !(this.value || '').trim();
|
|
@@ -357,12 +329,9 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
357
329
|
|
|
358
330
|
get renderedHtml() {
|
|
359
331
|
const opts = {};
|
|
360
|
-
const resolved = this
|
|
332
|
+
const resolved = this[resolvedAllowedTags];
|
|
361
333
|
if(resolved) opts.allowedTags = resolved;
|
|
362
334
|
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
335
|
const stripCompletely = new Set(STRIP_COMPLETELY);
|
|
367
336
|
stripCompletely.delete('SCRIPT');
|
|
368
337
|
opts.stripCompletely = stripCompletely;
|
|
@@ -370,19 +339,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
370
339
|
return sanitizeHtml(renderMarkdown(this.value || ''), opts);
|
|
371
340
|
}
|
|
372
341
|
|
|
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() {
|
|
342
|
+
get [resolvedAllowedTags]() {
|
|
386
343
|
const allow = (this.allowedTags || '').trim();
|
|
387
344
|
const deny = (this.disallowedTags || '').trim();
|
|
388
345
|
if(allow && deny){
|
|
@@ -399,7 +356,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
399
356
|
return null;
|
|
400
357
|
}
|
|
401
358
|
|
|
402
|
-
|
|
359
|
+
[updateValidity] = () => {
|
|
403
360
|
const ta = this.shadowRoot?.querySelector('textarea');
|
|
404
361
|
if(this.required && this.isEmpty){
|
|
405
362
|
this.internals.setValidity(
|
|
@@ -421,6 +378,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
421
378
|
detail: { value: this.value },
|
|
422
379
|
bubbles: true
|
|
423
380
|
}));
|
|
381
|
+
this[debouncedChange]();
|
|
424
382
|
};
|
|
425
383
|
|
|
426
384
|
handleChange = () => {
|
|
@@ -465,6 +423,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
465
423
|
aria-label=${this.name || this.placeholder}
|
|
466
424
|
@input=${this.handleInput}
|
|
467
425
|
@blur=${this.handleChange}
|
|
426
|
+
@change=${this.handleChange}
|
|
468
427
|
></textarea>
|
|
469
428
|
</k-tab-content>
|
|
470
429
|
<k-tab-content name="preview">
|
|
@@ -483,9 +442,6 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
483
442
|
`;
|
|
484
443
|
}
|
|
485
444
|
|
|
486
|
-
/*
|
|
487
|
-
Styles
|
|
488
|
-
*/
|
|
489
445
|
static styles = css`
|
|
490
446
|
:host {
|
|
491
447
|
--padding: 0.5rem 0.75rem;
|
|
@@ -514,6 +470,8 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
514
470
|
k-resize {
|
|
515
471
|
width: 100%;
|
|
516
472
|
height: var(--height, 14rem);
|
|
473
|
+
min-height: var(--min-height);
|
|
474
|
+
max-height: var(--max-height);
|
|
517
475
|
background: var(--c_bg);
|
|
518
476
|
}
|
|
519
477
|
.frame {
|
|
@@ -552,6 +510,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
552
510
|
overflow-y: auto;
|
|
553
511
|
scrollbar-width: thin;
|
|
554
512
|
resize: none;
|
|
513
|
+
max-height: none !important;
|
|
555
514
|
}
|
|
556
515
|
.preview-empty {
|
|
557
516
|
color: var(--tc_muted);
|
|
@@ -576,8 +535,7 @@ export default class MarkdownEditor extends ShadowComponent {
|
|
|
576
535
|
attribute is set. Tags reference custom elements that are loaded by
|
|
577
536
|
`loadControls()` — listing them here doesn't require those modules to
|
|
578
537
|
be imported eagerly. Lit creates the elements as plain HTMLElements
|
|
579
|
-
until their definitions arrive, then the browser upgrades them in
|
|
580
|
-
place.
|
|
538
|
+
until their definitions arrive, then the browser upgrades them in place.
|
|
581
539
|
*/
|
|
582
540
|
static controlSets = {
|
|
583
541
|
'': { top: null, bottom: null },
|