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 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 l from"../utils/renderMarkdown.js";import o,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import"./Resize.js";import"./Tabs.js";export default class n 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}};#t="";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.#e=!1}#e=!1;async loadControls(){if(this.#e)return;this.#e=!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.#t=this.getAttribute("value")),!this.value&&this.#t&&(this.value=this.#t)}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this.#i()}formResetCallback(){this.value=this.#t,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:l}=i,o=i.value.substring(0,s),a=i.value.substring(l),n=o+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:l,selectionEnd:o,value:a}=s,n=a.substring(l,o);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,l)+i+a.substring(o);return s.value=r,s.selectionStart=l,s.selectionEnd=l+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,l-t.length),l),d=a.substring(o,Math.min(a.length,o+e.length));if(r===t&&d===e){const i=a.substring(0,l-t.length)+n+a.substring(o+e.length);return s.value=i,s.selectionStart=l-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,c=a.substring(0,l)+t+m+e+a.substring(o);s.value=c;const h=l+t.length;s.selectionStart=h,s.selectionEnd=h+m.length,this.value=c,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:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const c=o.substring(0,a)+m+o.substring(r);i.value=c,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=c,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:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),c=m.length>0&&m.every(e=>e.startsWith(t)),h=d.map(i=>0===i.length?i:c?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=o.substring(0,a)+h+o.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+h.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this.#s;if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return o(l(this.value||""),t)}get#s(){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}#i=()=>{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}))};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`
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",n);
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>--min_height</code></td><td><code>6rem</code></td><td>Minimum height of the textarea / preview area</td></tr>
868
- <tr><td><code>--max_height</code></td><td><code>24rem</code></td><td>Maximum height before the editor scrolls internally</td></tr>
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 when the textarea blurs. <code>detail.value</code> is the markdown at the moment focus left.</p>
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 l from"../utils/renderMarkdown.js";import o,{STRIP_COMPLETELY as a}from"../utils/sanitizeHtml.js";import"./Resize.js";import"./Tabs.js";export default class n 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}};#t="";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.#e=!1}#e=!1;async loadControls(){if(this.#e)return;this.#e=!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.#t=this.getAttribute("value")),!this.value&&this.#t&&(this.value=this.#t)}updated(t){super.updated(t),t.has("value")&&this.internals.setFormValue(this.value),t.has("controls")&&this.controls&&"none"!==this.controls&&this.loadControls(),this.#i()}formResetCallback(){this.value=this.#t,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:l}=i,o=i.value.substring(0,s),a=i.value.substring(l),n=o+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:l,selectionEnd:o,value:a}=s,n=a.substring(l,o);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,l)+i+a.substring(o);return s.value=r,s.selectionStart=l,s.selectionEnd=l+i.length,this.value=r,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const r=a.substring(Math.max(0,l-t.length),l),d=a.substring(o,Math.min(a.length,o+e.length));if(r===t&&d===e){const i=a.substring(0,l-t.length)+n+a.substring(o+e.length);return s.value=i,s.selectionStart=l-t.length,s.selectionEnd=s.selectionStart+n.length,this.value=i,void s.dispatchEvent(new Event("input",{bubbles:!0}))}const m=n||i,c=a.substring(0,l)+t+m+e+a.substring(o);s.value=c;const h=l+t.length;s.selectionStart=h,s.selectionEnd=h+m.length,this.value=c,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:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r),m=d.split("\n").map(i=>i.replace(t,e)).join("\n");if(m===d)return;const c=o.substring(0,a)+m+o.substring(r);i.value=c,i.selectionStart=a,i.selectionEnd=a+m.length,this.value=c,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:l,value:o}=i,a=o.lastIndexOf("\n",s-1)+1,n=o.indexOf("\n",l),r=-1===n?o.length:n,d=o.substring(a,r).split("\n"),m=d.filter(t=>t.length>0),c=m.length>0&&m.every(e=>e.startsWith(t)),h=d.map(i=>0===i.length?i:c?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=o.substring(0,a)+h+o.substring(r);i.value=u,i.selectionStart=a,i.selectionEnd=a+h.length,this.value=u,i.dispatchEvent(new Event("input",{bubbles:!0}))}))}get isEmpty(){return!(this.value||"").trim()}get renderedHtml(){const t={},e=this.#s;if(e&&(t.allowedTags=e),this.scriptsEnabled){const e=new Set(a);e.delete("SCRIPT"),t.stripCompletely=e}return o(l(this.value||""),t)}get#s(){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}#i=()=>{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}))};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`
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",n);
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>--min_height</code></td><td><code>6rem</code></td><td>Minimum height of the textarea / preview area</td></tr>
380
- <tr><td><code>--max_height</code></td><td><code>24rem</code></td><td>Maximum height before the editor scrolls internally</td></tr>
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 when the textarea blurs. <code>detail.value</code> is the markdown at the moment focus left.</p>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -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
- k-markdown-editor
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
- Lifecycle Callbacks
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.#controlsLoaded = false;
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
- 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.
95
+ Lifecycle
94
96
  */
95
97
  async loadControls() {
96
- if(this.#controlsLoaded) return;
97
- this.#controlsLoaded = true;
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.#defaultValue = this.getAttribute('value');
123
+ this[defaultValue] = this.getAttribute('value');
125
124
  }
126
- if(!this.value && this.#defaultValue){
127
- this.value = this.#defaultValue;
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.#updateValidity();
138
+ this[updateValidity]();
140
139
  }
141
140
 
142
- /*
143
- Form-associated callbacks
144
- */
145
141
  formResetCallback() {
146
- this.value = this.#defaultValue;
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 API — generic
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
- Utility
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.#resolvedAllowedTags;
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
- #updateValidity = () => {
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 },