diff --git a/dist/base.min.js b/dist/base.min.js
index b338501..485878f 100644
--- a/dist/base.min.js
+++ b/dist/base.min.js
@@ -1 +1 @@
-import{Component as t,NewState as e,Util as i,$ as s,Hash as n,RefreshState as a}from"@web/state";const o={get:({url:t,...e})=>o.request({url:t,method:"GET",...e}),post:({url:t,data:e,...i})=>o.request({url:t,method:"POST",data:e,...i}),put:({url:t,data:e,...i})=>o.request({url:t,method:"PUT",data:e,...i}),delete:({url:t,...e})=>o.request({url:t,method:"DELETE",...e}),head:({url:t,...e})=>o.request({url:t,method:"HEAD",...e}),request:async({url:t,method:e="POST",data:i,headers:s={},responseType:n,timeout:a=1e4})=>{var o;const l={method:e=e.toUpperCase(),signal:null==(o=AbortSignal.timeout)?void 0:o.call(AbortSignal,a)};if(void 0!==i&&"GET"!==e&&"HEAD"!==e){if(i instanceof HTMLFormElement&&(i=new FormData(i)),i&&"object"==typeof i&&!(i instanceof FormData)&&!(i instanceof ArrayBuffer||ArrayBuffer.isView(i))&&Object.values(i).some(t=>t instanceof File||t instanceof Blob||t instanceof FileList||Array.isArray(t)&&t.some(t=>t instanceof File||t instanceof Blob))){const t=new FormData;for(const[e,s]of Object.entries(i))s instanceof FileList||Array.isArray(s)?Array.from(s).forEach(i=>t.append(e,i)):null!=s&&t.append(e,s);i=t}i instanceof FormData?delete s["Content-Type"]:"string"==typeof i||i instanceof ArrayBuffer||ArrayBuffer.isView(i)||(i=JSON.stringify(i),s["Content-Type"]||(s["Content-Type"]="application/json")),l.body=i}Object.keys(s).length&&(l.headers=s);const r={error:null,ok:null,status:0,headers:{},responseType:"",result:null};try{const e=await fetch(t,l);if(Object.assign(r,{ok:e.ok,status:e.status,headers:Object.fromEntries(e.headers.entries())}),!n){const t=e.headers.get("Content-Type")||"";n=t.includes("application/json")?"json":/image|video|audio|pdf|zip|octet-stream/.test(t)?"binary":"text",r.responseType=n}!1===r.ok&&(r.error=(r.statusText||"HTTP "+r.status+" error")+" for "+t),r.result="json"===n?await e.json():"binary"===n?await e.arrayBuffer():await e.text()}catch(t){Object.assign(r,{error:t.message||String(t),ok:!1})}return r}},l=t.register("API",t=>{t.request=e({url:"",method:"GET",headers:{},data:null,timeout:1e4,responseType:""}),t.response=e({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null}),t.result=e(),t.do=(e={})=>new Promise((i,s)=>{const n={...t.request,...e};if(!n.url)throw new Error(".url is required");n.headers={...t.request.headers,...e.headers},t.response.loading=!0,o.request(n).then(e=>{if(Object.keys(e).forEach(i=>{"result"!==i&&(t.response[i]=e[i])}),e.result&&"object"==typeof e.result&&t.result&&"object"==typeof t.result?Object.assign(t.result,e.result):t.result=e.result,t.response.loading=!1,!1===e.ok)throw new Error(e.error);if("object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1})),i(e)}).catch(i=>{var n;!e.noui&&(null==(n=globalThis.UI)?void 0:n.toast)&&UI.toast(i.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:i,bubbles:!0})),s(i)})});let i=null;t.request.__watch(null,()=>{t.hasAttribute("auto")&&t.request.url&&(i||(i=Promise.resolve().then(()=>{t.do(),i=null})))})}),r={};t.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",e=>{e.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{var e;null==(e=document.activeElement)||e.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),i.copyFunction(t,t.modal,"show","hide")},i.makeDom('\n
\n')),t.register("Dialog",t.getSetupFunction("Modal"),i.makeDom('\n
\n'));let d=0;r.showDialog=function({title:t="",message:e="",buttons:i=["{#Close#}"],type:s="body"}){const n=document.body.appendChild(document.createElement("Dialog"));return n.style.zIndex=2e3+ ++d,Promise.resolve().then(()=>{Object.assign(n.state,{message:e,title:t,type:s,buttons:i}),n.show()}),new Promise(t=>{n.addEventListener("change",e=>{d--,t(n.result||0),n.remove()})})},r.alert=function(t,e={}){return r.showDialog({message:t,...e})},r.confirm=function(t,e={}){return new Promise(i=>r.showDialog({message:t,buttons:["{#Cancel#}","{#Confirm#}"],...e}).then(t=>i(t>=2)).catch(()=>i(!1)))},t.register("Toast",t=>{t.toast=new bootstrap.Toast(t,{autohide:t.state.delay>0}),i.copyFunction(t,t.toast,"show","hide"),t.addEventListener("show.bs.toast",()=>{if(t.state.delay>0){let e;const i=()=>{t.state.left=t.state.delay/1e3,e=setInterval(()=>{(!t.isConnected||--t.state.left<=0)&&clearInterval(e)},1e3)};i(),t.addEventListener("mouseenter",()=>{clearInterval(e),t.state.left=void 0}),t.addEventListener("mouseleave",i)}})},i.makeDom('\n
\n'),i.makeDom('
')),r.toast=function(t,e={}){const i=e.delay??5e3,n=document.createElement("Toast");n.state={delay:i,left:i?i/1e3:void 0,type:e.type||"primary",message:t,buttons:e.buttons||[]},s(`[toast-container="${e.container||"default"}"]`).appendChild(n),Promise.resolve().then(()=>n.show())},r.toastConfirm=function(t,e={}){return new Promise(i=>r.toast(t,{buttons:["{#Confirm#}"],...e}).then(t=>i(1===t)).catch(()=>i(!1)))},t.register("AutoForm",t=>{t.state.schema||(t.state.schema=[]),t.vertical=t.hasAttribute("vertical"),t.inline=t.hasAttribute("inline"),t.request={method:"POST"},t.response={},t.result=null;const i=()=>{t.data&&t.data.__watch||(t.data=e(t.data||{})),t.data.__watch("*",()=>{if(t.inline){const e=t.closest("DataTable");e&&e.refresh&&e.refresh()}})};t.data?i():requestAnimationFrame(i),t.form=s(t,"form"),t.submit=(e={})=>{var i,s;if(!t.form.reportValidity())return null==(s=null==(i=globalThis.UI)?void 0:i.toast)?void 0:s.call(i,"{#verify failed#}",{type:"danger"});if(!t.dispatchEvent(new CustomEvent("submit",{detail:t.data,cancelable:!0,bubbles:!1})))return;const n={...t.request,data:t.data,noui:!0,...e};let a=null;if(t.api)a=t.api.do(n);else{if(!t.request.url)return console.warn("{#please config .api or .request.url to auto submit#}");a=o.request(n)}a.then(e=>{if(t.response=e,t.result=e.result,"object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1}))}).catch(e=>{var i;(null==(i=globalThis.UI)?void 0:i.toast)&&UI.toast(e.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:e,bubbles:!0}))})}},i.makeDom('\n
\n'),i.makeDom(""));const c=[],m={register:e=>{"undefined"!=typeof document&&("loading"!==document.readyState&&t.getTemplate("AutoForm")?m._addAutoFormComponent(e):c.push(e))},_addAutoFormComponent:e=>{var n;const a=t.getTemplate("AutoForm");a&&(null==(n=s(a.content,"[control-wrapper]"))||n.appendChild(i.makeDom(`<${e} $if="item.type?.toUpperCase() === '${e.toUpperCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]">${e}>`)))}};if("undefined"!=typeof document){const t=()=>{c.forEach(t=>m._addAutoFormComponent(t)),c.length=0};"loading"!==document.readyState?setTimeout(t,100):document.addEventListener("DOMContentLoaded",()=>setTimeout(t,100),!0)}t.register("TagsInput",t=>{t.addEventListener("bind",e=>{t.state.tags=e.detail||[]})},i.makeDom(`\n
\n \n {if(!t.isComposing&&["Enter",","," "].includes(t.key)){t.preventDefault();const e=thisNode.value.trim();e&&!(void 0).state.tags.includes(e)&&((void 0).state.tags.push(e),(void 0).state.tags=(void 0).state.tags,(void 0).dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:(void 0).state.tags}))),thisNode.value=""}})}">\n
\n`),i.makeDom("")),m.register("TagsInput");const u=()=>{const t=new Map,e=new Map;let s=1;const n=i.newAvg();let a=0,o=0,l=0,r=null,d=null,c=!1;return{reset:(i,m)=>{if(c=!1,t.clear(),e.clear(),n.clear(),l=0,r=null,d=null,!(null==i?void 0:i.length))return[];const u=i.length;s=Math.ceil(Math.sqrt(u))||10;const p=window.getComputedStyle(m);return a=parseFloat(p.paddingTop)||0,o=parseFloat(p.rowGap)||0,i.slice(0,Math.min(30,u))},init:(i,a)=>{if(c)return;const o=i.length,l=n.get()||32;for(let e=0;e
{if(null===r){const t=window.getComputedStyle(a);r=parseFloat(t.marginTop)||0,d=parseFloat(t.marginBottom)||0}0===i&&(l=r);const c=a.offsetHeight+r+d+o,m=t.get(i);if(c!==m){t.set(i,c),n.add(c);const a=c-(m||0),o=i-i%s;e.has(o)&&e.set(o,e.get(o)+a)}},calc:(i,r)=>{if(!c||!r)return null;const d=r.length,m=Math.ceil((i.clientHeight||100)/(n.get()||32));let u=a+l+o,p=0,h=0,b=0,f=0;for(let n=0;n{t.mode=t.getAttribute("mode")||"normal",t.fast=t.hasAttribute("fast"),t.collapsible=t.hasAttribute("collapsible");const s={idfield:"id",labelfield:"label",summaryfield:"summary",groupidfield:"id",grouplabelfield:"label",groupsummaryfield:"summary",groupfield:"group",parentfield:"parent",groupicon:"folder",itemicon:"file"};t.collapsed=e({});const n=()=>{i.updateDefaults(t,s);const e=t.state.list||[],n=[];if("group"===t.mode){const i={};e.forEach(e=>{var s;return(i[s=e[t.groupfield]]??(i[s]=[])).push(e)}),(t.state.groups||[]).forEach(e=>{n.push({type:"group",...e});const s=i[e[t.groupidfield]];s&&s.forEach(t=>n.push({type:"item",...t}))})}else if("tree"===t.mode){const i={};e.forEach(e=>{var s;return(i[s=e[t.parentfield]||""]??(i[s]=[])).push(e)});const s=(e,a,o)=>e.forEach(e=>{var l;const r=e[t.idfield],d=!!(null==(l=i[r])?void 0:l.length),c=t.collapsed[r];n.push({type:"item",...e,_level:a,_hasChildren:d,_parents:o}),d&&!c&&s(i[r],a+1,[...o,r])});s(i[""]||[],0,[])}else e.forEach(t=>n.push({type:"item",...t}));t.state._flatList=n};t.state.__watch("list",n);const a=t.fast?u():null;t.state._renderedList=[],t.refresh=()=>{if(!t.fast)return;const e=a.calc(t,t.state._flatList);e&&(t.state.prevHeight=e.prevHeight,t.state.postHeight=e.postHeight,t.state._listStartIndex=e.listStartIndex,t.state._renderedList=e.renderedList)},t.onItemUpdate=(e,i)=>{t.fast&&a.update(e+(t.state._listStartIndex||0),i)},t.state.__watch("_flatList",e=>{t.fast?(t.state._listStartIndex=0,t.state._renderedList=a.reset(e,t)||[],requestAnimationFrame(()=>{t.state._flatList===e&&a.init(e,t.refresh)})):t.state._renderedList=e}),t.selectItem=(e,i)=>{t.hasAttribute("auto-select")&&(t.state.selectedItem=t.state.selectedItem===e[t.idfield]?null:e[t.idfield]),t.dispatchEvent(new CustomEvent("itemclick",{bubbles:!1,detail:{item:e,index:i+(t.fast&&t.state._listStartIndex||0)}}))},t.selectGroup=(e,i)=>{t.hasAttribute("auto-select-group")&&(t.state.selectedGroup=t.state.selectedGroup===e[t.groupidfield]?null:e[t.groupidfield]),t.dispatchEvent(new CustomEvent("groupclick",{bubbles:!1,detail:{item:e,index:i}}))},t.toggleCollapse=e=>{t.collapsible&&e._hasChildren&&(t.collapsed[e[t.idfield]]=!t.collapsed[e[t.idfield]],n())},n()},i.makeDom('\n\n\t
0" $style="height:${this.state?.prevHeight}px;" class="flex-shrink-0">
\n\t
\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\n\t
0" $style="height:${this.state?.postHeight}px;" class="flex-shrink-0">
\n
\n')),t.register("Nav",t=>{t.click=(e,i)=>{e.noselect||i||(n.nav=e.name),t.dispatchEvent(new CustomEvent("nav",{detail:{item:e},bubbles:!1}))}},i.makeDom('\n\n\t
![]()
\n\t
\n\t
\n\t
\n\t
\n\t\t
\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t
\n
\n'));let p=!1,h={},b={};const f={start:(t,{onmousemove:e,onmouseup:i})=>{h={x:t.clientX,y:t.clientY,w:0,h:0},b={onmousemove:e,onmouseup:i},p=!0}};"undefined"!=typeof document&&(document.addEventListener("mouseup",t=>{var e;p&&(p=!1,null==(e=b.onmouseup)||e.call(b,{event:t,...h}))}),document.addEventListener("mousemove",t=>{var e;p&&(h.w=t.clientX-h.x,h.h=t.clientY-h.y,null==(e=b.onmousemove)||e.call(b,{event:t,...h}))})),t.register("Resizer",t=>{t.isVertical=t.hasAttribute("vertical");const e=parseInt(t.getAttribute("min"))||10,i=parseInt(t.getAttribute("max"))||1e3,s=t.target||t.previousElementSibling;t.addEventListener("bind",e=>{void 0!==e.detail&&null!==e.detail&&(s.style[t.isVertical?"height":"width"]=e.detail+"px")});const n=(s,n,a)=>{const o=s+(t.isVertical?a:n);return oi?i:o};t.addEventListener("mousedown",e=>{const i=t.isVertical?s.offsetHeight:s.offsetWidth;f.start(e,{onmousemove:({w:e,h:a})=>{const o=n(i,e,a);s.style[t.isVertical?"height":"width"]=o+"px",t.dispatchEvent(new CustomEvent("resizing",{detail:{oldSize:i,newSize:o},bubbles:!1}))},onmouseup:({w:e,h:s})=>{const a=n(i,e,s);t.dispatchEvent(new CustomEvent("resize",{detail:{oldSize:i,newSize:a},bubbles:!1})),t.dispatchEvent(new CustomEvent("change",{detail:a,bubbles:!1}))}})})},i.makeDom("\n\n"));const g=e({exitBlocks:0});globalThis.State=g,"undefined"!=typeof window&&window.addEventListener("beforeunload",t=>{g.exitBlocks>0&&t.preventDefault()});const v=document.documentElement;if(v.hasAttribute("$data-bs-theme")||v.hasAttribute("data-bs-theme")||v.setAttribute("$data-bs-theme","LocalStorage.darkMode?'dark':'light'"),globalThis.HTTP=o,globalThis.UI=r,globalThis.AutoForm=m,globalThis.MouseMover=f,"undefined"!=typeof document){const t=()=>a(document.documentElement);"loading"!==document.readyState?t():document.addEventListener("DOMContentLoaded",t,!0)}export{l as APIComponent,m as AutoForm,o as HTTP,f as MouseMover,g as State,r as UI,u as VirtualScroll};
+import{Component as t,NewState as e,Util as i,$ as s,Hash as n,RefreshState as a}from"@web/state";const o={get:({url:t,...e})=>o.request({url:t,method:"GET",...e}),post:({url:t,data:e,...i})=>o.request({url:t,method:"POST",data:e,...i}),put:({url:t,data:e,...i})=>o.request({url:t,method:"PUT",data:e,...i}),delete:({url:t,...e})=>o.request({url:t,method:"DELETE",...e}),head:({url:t,...e})=>o.request({url:t,method:"HEAD",...e}),request:async({url:t,method:e="POST",data:i,headers:s={},responseType:n,timeout:a=1e4})=>{var o;const l={method:e=e.toUpperCase(),signal:null==(o=AbortSignal.timeout)?void 0:o.call(AbortSignal,a)};if(void 0!==i&&"GET"!==e&&"HEAD"!==e){if(i instanceof HTMLFormElement&&(i=new FormData(i)),i&&"object"==typeof i&&!(i instanceof FormData)&&!(i instanceof ArrayBuffer||ArrayBuffer.isView(i))&&Object.values(i).some(t=>t instanceof File||t instanceof Blob||t instanceof FileList||Array.isArray(t)&&t.some(t=>t instanceof File||t instanceof Blob))){const t=new FormData;for(const[e,s]of Object.entries(i))s instanceof FileList||Array.isArray(s)?Array.from(s).forEach(i=>t.append(e,i)):null!=s&&t.append(e,s);i=t}i instanceof FormData?delete s["Content-Type"]:"string"==typeof i||i instanceof ArrayBuffer||ArrayBuffer.isView(i)||(i=JSON.stringify(i),s["Content-Type"]||(s["Content-Type"]="application/json")),l.body=i}Object.keys(s).length&&(l.headers=s);const r={error:null,ok:null,status:0,headers:{},responseType:"",result:null};try{const e=await fetch(t,l);if(Object.assign(r,{ok:e.ok,status:e.status,headers:Object.fromEntries(e.headers.entries())}),!n){const t=e.headers.get("Content-Type")||"";n=t.includes("application/json")?"json":/image|video|audio|pdf|zip|octet-stream/.test(t)?"binary":"text",r.responseType=n}!1===r.ok&&(r.error=(r.statusText||"HTTP "+r.status+" error")+" for "+t),r.result="json"===n?await e.json():"binary"===n?await e.arrayBuffer():await e.text()}catch(t){Object.assign(r,{error:t.message||String(t),ok:!1})}return r}},l=t.register("API",t=>{t.request=e({url:"",method:"GET",headers:{},data:null,timeout:1e4,responseType:""}),t.response=e({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null}),t.result=e(),t.do=(e={})=>new Promise((i,s)=>{const n={...t.request,...e};if(!n.url)throw new Error(".url is required");n.headers={...t.request.headers,...e.headers},t.response.loading=!0,o.request(n).then(e=>{if(Object.keys(e).forEach(i=>{"result"!==i&&(t.response[i]=e[i])}),e.result&&"object"==typeof e.result&&t.result&&"object"==typeof t.result?Object.assign(t.result,e.result):t.result=e.result,t.response.loading=!1,!1===e.ok)throw new Error(e.error);if("object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1})),i(e)}).catch(i=>{var n;!e.noui&&(null==(n=globalThis.UI)?void 0:n.toast)&&UI.toast(i.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:i,bubbles:!0})),s(i)})});let i=null;t.request.__watch(null,()=>{t.hasAttribute("auto")&&t.request.url&&(i||(i=Promise.resolve().then(()=>{t.do(),i=null})))})}),r={};t.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",e=>{e.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{var e;null==(e=document.activeElement)||e.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),i.copyFunction(t,t.modal,"show","hide")},i.makeDom('\n\n')),t.register("Dialog",t.getSetupFunction("Modal"),i.makeDom('\n\n'));let d=0;r.showDialog=function({title:t="",message:e="",buttons:i=["{#Close#}"],type:s="body"}){const n=document.body.appendChild(document.createElement("Dialog"));return n.style.zIndex=2e3+ ++d,Promise.resolve().then(()=>{Object.assign(n.state,{message:e,title:t,type:s,buttons:i}),n.show()}),new Promise(t=>{n.addEventListener("change",e=>{d--,t(n.result||0),n.remove()})})},r.alert=function(t,e={}){return r.showDialog({message:t,...e})},r.confirm=function(t,e={}){return new Promise(i=>r.showDialog({message:t,buttons:["{#Cancel#}","{#Confirm#}"],...e}).then(t=>i(t>=2)).catch(()=>i(!1)))},t.register("Toast",t=>{t.toast=new bootstrap.Toast(t,{autohide:t.state.delay>0}),i.copyFunction(t,t.toast,"show","hide"),t.addEventListener("show.bs.toast",()=>{if(t.state.delay>0){let e;const i=()=>{t.state.left=t.state.delay/1e3,e=setInterval(()=>{(!t.isConnected||--t.state.left<=0)&&clearInterval(e)},1e3)};i(),t.addEventListener("mouseenter",()=>{clearInterval(e),t.state.left=void 0}),t.addEventListener("mouseleave",i)}})},i.makeDom('\n\n'),i.makeDom('')),r.toast=function(t,e={}){const i=e.delay??5e3,n=document.createElement("Toast");n.state={delay:i,left:i?i/1e3:void 0,type:e.type||"primary",message:t,buttons:e.buttons||[]},s(`[toast-container="${e.container||"default"}"]`).appendChild(n),Promise.resolve().then(()=>n.show())},r.toastConfirm=function(t,e={}){return new Promise(i=>r.toast(t,{buttons:["{#Confirm#}"],...e}).then(t=>i(1===t)).catch(()=>i(!1)))},t.register("AutoForm",t=>{t.state.schema||(t.state.schema=[]),t.vertical=t.hasAttribute("vertical"),t.inline=t.hasAttribute("inline"),t.request={method:"POST"},t.response={},t.result=null;const i=()=>{t.data&&t.data.__watch||(t.data=e(t.data||{})),t.data.__watch("*",()=>{if(t.inline){const e=t.closest("DataTable");e&&e.refresh&&e.refresh()}})};t.data?i():requestAnimationFrame(i),t.form=s(t,"form"),t.submit=(e={})=>{var i,s;if(!t.form.reportValidity())return null==(s=null==(i=globalThis.UI)?void 0:i.toast)?void 0:s.call(i,"{#verify failed#}",{type:"danger"});if(!t.dispatchEvent(new CustomEvent("submit",{detail:t.data,cancelable:!0,bubbles:!1})))return;const n={...t.request,data:t.data,noui:!0,...e};let a=null;if(t.api)a=t.api.do(n);else{if(!t.request.url)return console.warn("{#please config .api or .request.url to auto submit#}");a=o.request(n)}a.then(e=>{if(t.response=e,t.result=e.result,"object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1}))}).catch(e=>{var i;(null==(i=globalThis.UI)?void 0:i.toast)&&UI.toast(e.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:e,bubbles:!0}))})}},i.makeDom('\n\n'),i.makeDom(""));const c=[],m={register:e=>{"undefined"!=typeof document&&("loading"!==document.readyState&&t.getTemplate("AutoForm")?m._addAutoFormComponent(e):c.push(e))},_addAutoFormComponent:e=>{var n;const a=t.getTemplate("AutoForm");a&&(null==(n=s(a.content,"[control-wrapper]"))||n.appendChild(i.makeDom(`<${e} $if="item.type?.toUpperCase() === '${e.toUpperCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]">${e}>`)))}};if("undefined"!=typeof document){const t=()=>{c.forEach(t=>m._addAutoFormComponent(t)),c.length=0};"loading"!==document.readyState?setTimeout(t,100):document.addEventListener("DOMContentLoaded",()=>setTimeout(t,100),!0)}t.register("TagsInput",t=>{t.addEventListener("bind",e=>{t.state.tags=e.detail||[]})},i.makeDom(`\n\n \n {if(!t.isComposing&&["Enter",","," "].includes(t.key)){t.preventDefault();const e=thisNode.value.trim();e&&!(void 0).state.tags.includes(e)&&((void 0).state.tags.push(e),(void 0).state.tags=(void 0).state.tags,(void 0).dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:(void 0).state.tags}))),thisNode.value=""}})}">\n
\n`),i.makeDom("")),m.register("TagsInput");const u=()=>{const t=new Map,e=new Map;let s=1;const n=i.newAvg();let a=0,o=0,l=0,r=null,d=null,c=!1;return{reset:(i,m)=>{if(c=!1,t.clear(),e.clear(),n.clear(),l=0,r=null,d=null,!(null==i?void 0:i.length))return[];const u=i.length;s=Math.ceil(Math.sqrt(u))||10;const p=window.getComputedStyle(m);return a=parseFloat(p.paddingTop)||0,o=parseFloat(p.rowGap)||0,i.slice(0,Math.min(30,u))},init:(i,a)=>{if(c)return;const o=i.length,l=n.get()||32;for(let e=0;e{if(null===r){const t=window.getComputedStyle(a);r=parseFloat(t.marginTop)||0,d=parseFloat(t.marginBottom)||0}0!==i||l||(l=r);const c=a.offsetHeight+r+d+o,m=t.get(i);if(c!==m){t.set(i,c),n.add(c);const a=c-(m||0),o=i-i%s;e.has(o)&&e.set(o,e.get(o)+a)}},calc:(i,r)=>{if(!c||!r)return null;const d=r.length;let m=Math.max(10,Math.ceil((i.clientHeight||100)/(n.get()||32))),u=a+l+o,p=0,h=0,b=0,f=0,g=[];const v=i.scrollTop;for(let i=0;i0?o:0)),postHeight:p>0?Math.max(0,p-2*o):0,renderedList:g,listStartIndex:b}}}};t.register("List",t=>{t.mode=t.getAttribute("mode")||"normal",t.fast=t.hasAttribute("fast"),t.collapsible=t.hasAttribute("collapsible");const s={idfield:"id",labelfield:"label",summaryfield:"summary",groupidfield:"id",grouplabelfield:"label",groupsummaryfield:"summary",groupfield:"group",parentfield:"parent",groupicon:"folder",itemicon:"file"};t.collapsed=e({});const n=()=>{i.updateDefaults(t,s);const e=t.state.list||[],n=[];if("group"===t.mode){const i={};e.forEach(e=>{var s;return(i[s=e[t.groupfield]]??(i[s]=[])).push(e)}),(t.state.groups||[]).forEach(e=>{n.push({type:"group",...e});const s=i[e[t.groupidfield]];s&&s.forEach(t=>n.push({type:"item",...t}))})}else if("tree"===t.mode){const i={};e.forEach(e=>{var s;return(i[s=e[t.parentfield]||""]??(i[s]=[])).push(e)});const s=(e,a,o)=>e.forEach(e=>{var l;const r=e[t.idfield],d=!!(null==(l=i[r])?void 0:l.length),c=t.collapsed[r];n.push({type:"item",...e,_level:a,_hasChildren:d,_parents:o}),d&&!c&&s(i[r],a+1,[...o,r])});s(i[""]||[],0,[])}else e.forEach(t=>n.push({type:"item",...t}));t.state._flatList=n};t.state.__watch("list",n);const a=t.fast?u():null;t.state._renderedList=[],t.refresh=()=>{if(!t.fast)return;const e=a.calc(t,t.state._flatList);e&&(t.state.prevHeight=e.prevHeight,t.state.postHeight=e.postHeight,t.state._listStartIndex=e.listStartIndex,t.state._renderedList=e.renderedList)},t.onItemUpdate=(e,i)=>{t.fast&&a.update(e+(t.state._listStartIndex||0),i)},t.state.__watch("_flatList",e=>{t.fast?(t.state._listStartIndex=0,t.state._renderedList=a.reset(e,t)||[],setTimeout(()=>{t.state._flatList===e&&a.init(e,t.refresh)})):t.state._renderedList=e}),t.selectItem=(e,i)=>{t.hasAttribute("auto-select")&&(t.state.selectedItem=t.state.selectedItem===e[t.idfield]?null:e[t.idfield]),t.dispatchEvent(new CustomEvent("itemclick",{bubbles:!1,detail:{item:e,index:i+(t.fast&&t.state._listStartIndex||0)}}))},t.selectGroup=(e,i)=>{t.hasAttribute("auto-select-group")&&(t.state.selectedGroup=t.state.selectedGroup===e[t.groupidfield]?null:e[t.groupidfield]),t.dispatchEvent(new CustomEvent("groupclick",{bubbles:!1,detail:{item:e,index:i}}))},t.toggleCollapse=e=>{t.collapsible&&e._hasChildren&&(t.collapsed[e[t.idfield]]=!t.collapsed[e[t.idfield]],n())},n()},i.makeDom('\n\n\t
0" $style="height:${this.state?.prevHeight}px;" class="flex-shrink-0">
\n\t
\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\n\t
0" $style="height:${this.state?.postHeight}px;" class="flex-shrink-0">
\n
\n')),t.register("Nav",t=>{t.click=(e,i)=>{e.noselect||i||(n.nav=e.name),t.dispatchEvent(new CustomEvent("nav",{detail:{item:e},bubbles:!1}))}},i.makeDom('\n\n\t
![]()
\n\t
\n\t
\n\t
\n\t
\n\t\t
\n\t\t
\n\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t
\n
\n'));let p=!1,h={},b={};const f={start:(t,{onmousemove:e,onmouseup:i})=>{h={x:t.clientX,y:t.clientY,w:0,h:0},b={onmousemove:e,onmouseup:i},p=!0}};"undefined"!=typeof document&&(document.addEventListener("mouseup",t=>{var e;p&&(p=!1,null==(e=b.onmouseup)||e.call(b,{event:t,...h}))}),document.addEventListener("mousemove",t=>{var e;p&&(h.w=t.clientX-h.x,h.h=t.clientY-h.y,null==(e=b.onmousemove)||e.call(b,{event:t,...h}))})),t.register("Resizer",t=>{t.isVertical=t.hasAttribute("vertical");const e=parseInt(t.getAttribute("min"))||10,i=parseInt(t.getAttribute("max"))||1e3,s=t.target||t.previousElementSibling;t.addEventListener("bind",e=>{void 0!==e.detail&&null!==e.detail&&(s.style[t.isVertical?"height":"width"]=e.detail+"px")});const n=(s,n,a)=>{const o=s+(t.isVertical?a:n);return oi?i:o};t.addEventListener("mousedown",e=>{const i=t.isVertical?s.offsetHeight:s.offsetWidth;f.start(e,{onmousemove:({w:e,h:a})=>{const o=n(i,e,a);s.style[t.isVertical?"height":"width"]=o+"px",t.dispatchEvent(new CustomEvent("resizing",{detail:{oldSize:i,newSize:o},bubbles:!1}))},onmouseup:({w:e,h:s})=>{const a=n(i,e,s);t.dispatchEvent(new CustomEvent("resize",{detail:{oldSize:i,newSize:a},bubbles:!1})),t.dispatchEvent(new CustomEvent("change",{detail:a,bubbles:!1}))}})})},i.makeDom("\n\n"));const g=e({exitBlocks:0});globalThis.State=g,"undefined"!=typeof window&&window.addEventListener("beforeunload",t=>{g.exitBlocks>0&&t.preventDefault()});const v=document.documentElement;if(v.hasAttribute("$data-bs-theme")||v.hasAttribute("data-bs-theme")||v.setAttribute("$data-bs-theme","LocalStorage.darkMode?'dark':'light'"),globalThis.HTTP=o,globalThis.UI=r,globalThis.AutoForm=m,globalThis.MouseMover=f,"undefined"!=typeof document){const t=()=>a(document.documentElement);"loading"!==document.readyState?t():document.addEventListener("DOMContentLoaded",t,!0)}export{l as APIComponent,m as AutoForm,o as HTTP,f as MouseMover,g as State,r as UI,u as VirtualScroll};
diff --git a/old/base.js b/old/base.js
new file mode 100644
index 0000000..aaa1b34
--- /dev/null
+++ b/old/base.js
@@ -0,0 +1,753 @@
+// base.js v2.3
+(() => {
+ const { Component, NewState } = globalThis
+
+ // 工具函数
+ globalThis.Util = {
+ clone: window.structuredClone || (obj => JSON.parse(JSON.stringify(obj))),
+ base64: str => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
+ unbase64: str => new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))),
+ urlbase64: str => $base64(str).replace(/[+/=]/g, m => ({ '+': '-', '/': '', '=': '' }[m])),
+ unurlbase64: str => $unbase64(str.replace(/[-_.]/g, m => ({ '-': '+', '_': '/', '.': '=' }[m])).padEnd(Math.ceil(str.length / 4) * 4, '=')),
+ safeJson: str => { try { return JSON.parse(str) } catch { return null } },
+ updateDefaults: (obj, defaults) => { for (const k in defaults) if (obj[k] === undefined) obj[k] = defaults[k] },
+ copyFunction: (toObj, fromObj, ...funcNames) => { funcNames.forEach(name => toObj[name] = fromObj[name].bind(fromObj)) },
+ getFunctionBody: fn => { const code = fn.toString(); return code.slice(code.indexOf('{') + 1, code.lastIndexOf('}')).trim() },
+ makeDom: html => {
+ if (html.includes('>\n')) html = html.replace(/>\s+<").trim()
+ const node = document.createElement('div')
+ node.innerHTML = html
+ return node.children[0]
+ },
+ newAvg: () => {
+ let total = 0, count = 0, avg = 0
+ return {
+ add: (v) => {
+ total += v
+ count++
+ return avg = total / count
+ },
+ get: () => avg,
+ clear: () => { total = 0, count = 0, avg = 0 }
+ }
+ },
+ newTimeCount: () => {
+ let startTime = 0, total = 0, count = 0
+ return {
+ start: () => startTime = new Date().getTime(),
+ end: () => {
+ const endTime = new Date().getTime()
+ const left = endTime - startTime
+ startTime = endTime
+ total += left
+ count++
+ return left
+ },
+ avg: () => total / count
+ }
+ },
+ }
+
+ // url hash 状态
+ let _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
+ globalThis.Hash = NewState({}, k => Util.safeJson(_hashParams.get(k)), (k, v) => {
+ const oldStr = _hashParams.get(k)
+ const newStr = v === undefined ? undefined : JSON.stringify(v)
+ if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
+ v === undefined ? _hashParams.delete(k) : _hashParams.set(k, newStr)
+ window.location.hash = '#' + _hashParams.toString()
+ })
+ window.addEventListener('hashchange', () => {
+ const oldHashParams = _hashParams
+ _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
+ _hashParams.forEach((v, k) => { if (oldHashParams.get(k) !== v) Hash[k] = Util.safeJson(v) })
+ oldHashParams.forEach((v, k) => { if (_hashParams.get(k) === undefined) Hash[k] = undefined })
+ })
+
+ // localstorage 状态
+ globalThis.LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem(k)), (k, v) => {
+ const oldStr = localStorage.getItem(k)
+ const newStr = v === undefined ? undefined : JSON.stringify(v)
+ if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
+ v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
+ })
+
+ document.addEventListener('DOMContentLoaded', () => {
+ const htmlNode = document.documentElement
+ if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'")
+ }, true)
+
+ // 页面退出状态
+ globalThis.State = NewState()
+ State.exitBlocks = 0
+ window.addEventListener('beforeunload', (event) => {
+ if (State.exitBlocks > 0) event.preventDefault()
+ })
+
+ // HTTP 和 API 组件
+ globalThis.HTTP = {
+ get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
+ post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
+ put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
+ delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
+ head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
+ request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
+ method = method.toUpperCase()
+ const options = { method, signal: AbortSignal.timeout?.(timeout) }
+ if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
+ if (data instanceof HTMLFormElement) data = new FormData(data)
+ if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
+ const fd = new FormData()
+ for (const [k, v] of Object.entries(data)) {
+ if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
+ else if (v !== undefined && v !== null) fd.append(k, v)
+ }
+ data = fd
+ }
+ if (data instanceof FormData) {
+ delete headers['Content-Type']
+ } else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
+ data = JSON.stringify(data)
+ if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
+ }
+ options.body = data
+ }
+ if (Object.keys(headers).length) options.headers = headers
+ const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
+ try {
+ const resp = await fetch(url, options)
+ Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
+ if (!responseType) {
+ const contentType = resp.headers.get('Content-Type') || ''
+ if (contentType.includes('application/json')) responseType = 'json'
+ else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
+ else responseType = 'text'
+ response.responseType = responseType
+ }
+ if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
+ if (responseType === 'json') response.result = await resp.json()
+ else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
+ } catch (err) {
+ Object.assign(response, { error: err.message || String(err), ok: false })
+ }
+ return response
+ }
+ }
+
+ Component.register('API', container => {
+ container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
+ container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
+ container.result = NewState()
+ container.do = (opt = {}) => {
+ return new Promise((resolve, reject) => {
+ const req = { ...container.request, ...opt }
+ if (!req.url) throw new Error('.url is required')
+ req.headers = { ...container.request.headers, ...opt.headers }
+ container.response.loading = true
+ HTTP.request(req).then(resp => {
+ Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
+ if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
+ Object.assign(container.result, resp.result)
+ } else {
+ container.result = resp.result
+ }
+ container.response.loading = false
+ if (resp.ok === false) throw new Error(resp.error)
+ if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
+ container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
+ resolve(resp)
+ }).catch(err => {
+ if (!opt.noui) UI.toast(err.message, { type: 'danger' })
+ container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
+ reject(err)
+ })
+ })
+ }
+ let _autoTimer = null
+ container.request.__watch(null, () => {
+ if (!container.hasAttribute('auto') || !container.request.url) return
+ if (_autoTimer) return
+ _autoTimer = Promise.resolve().then(() => {
+ container.do()
+ _autoTimer = null
+ })
+ })
+ })
+
+ globalThis.UI = {}
+ // 模态窗口和对话框组件
+ Component.register('Modal', container => {
+ container.modal = new bootstrap.Modal(container)
+ container.addEventListener('bind', e => {
+ e.detail ? container.modal.show() : container.modal.hide()
+ })
+ container.addEventListener('hide.bs.modal', () => {
+ document.activeElement?.blur()
+ container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
+ })
+ Util.copyFunction(container, container.modal, 'show', 'hide')
+ }, Util.makeDom(/*html*/`
+
+ `))
+
+ Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
+
+ `))
+
+ let _dialogCount = 0
+ UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
+ const d = document.body.appendChild(document.createElement('Dialog'))
+ d.style.zIndex = 2000 + ++_dialogCount
+ Promise.resolve().then(() => {
+ Object.assign(d.state, { message, title, type, buttons })
+ d.show()
+ })
+ return new Promise((resolve) => {
+ d.addEventListener('change', e => {
+ _dialogCount--
+ resolve(d.result || 0)
+ d.remove()
+ })
+ })
+ }
+
+ UI.alert = function (message, options = {}) {
+ return UI.showDialog({ message, ...options })
+ }
+ UI.confirm = function (message, options = {}) {
+ return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
+ }
+
+ // 通知组件
+ Component.register('Toast', container => {
+ container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
+ Util.copyFunction(container, container.toast, 'show', 'hide')
+ container.addEventListener('show.bs.toast', () => {
+ if (container.state.delay > 0) {
+ let timer
+ const startTimer = () => {
+ container.state.left = container.state.delay / 1000
+ timer = setInterval(() => {
+ if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
+ }, 1000)
+ }
+ startTimer()
+ container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
+ container.addEventListener('mouseleave', startTimer)
+ }
+ })
+ }, Util.makeDom(/*html*/`
+
+ `), Util.makeDom(/*html*/``))
+
+ UI.toast = function (message, options = {}) {
+ const delay = options.delay ?? 5000
+ const t = document.createElement('Toast')
+ t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'body', message, buttons: options.buttons || [] }
+ $(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
+ Promise.resolve().then(() => t.show())
+ }
+
+ UI.toastConfirm = function (message, options = {}) {
+ return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
+ }
+
+ Component.register('AutoForm', container => {
+ if (!container.schema) container.schema = []
+ container.vertical = container.hasAttribute('vertical')
+ container.request = { method: 'POST' }
+ container.response = {}
+ container.result = null
+ container.data = NewState(container.data || {})
+ container.form = $(container, 'form')
+ container.submit = (opt = {}) => {
+ if (!container.form.reportValidity()) return UI.toast('{#verify failed#}', { type: 'danger' })
+ if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
+ const req = { ...container.request, data: container.data, noui: true, ...opt }
+ let task = null
+ if (container.api) task = container.api.do(req)
+ else if (container.request.url) task = HTTP.request(req)
+ else return console.warn('{#please config .api or .request.url to auto submit#}')
+ task.then(resp => {
+ container.response = resp
+ container.result = resp.result
+ if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
+ container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
+ }).catch(err => {
+ UI.toast(err.message, { type: 'danger' })
+ container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
+ })
+ }
+ }, Util.makeDom(/*html*/`
+
+ `), Util.makeDom(/*html*/``))
+
+ const _pendingAutoFormComponents = []
+ globalThis.AutoForm = {
+ register: name => {
+ if (document.readyState !== 'loading') AutoForm._addAutoFormComponent(name)
+ else _pendingAutoFormComponents.push(name)
+ },
+ _addAutoFormComponent: name => {
+ $(Component.getTemplate('AutoForm')?.content, '[control-wrapper]').appendChild(Util.makeDom(`<${name} $if="item.type.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting" $bind="this.data[item.name]">${name}>`))
+ }
+ }
+ document.addEventListener('DOMContentLoaded', () => {
+ _pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
+ _pendingAutoFormComponents.length = 0
+ }, true)
+
+ Component.register('TagsInput', container => {
+ container.addEventListener('bind', e => {
+ container.state.tags = e.detail || []
+ })
+ }, Util.makeDom(/*html*/`
+
+
+ {
+ if (event.isComposing) return
+ if (['Enter', ',', ' '].includes(event.key)) {
+ event.preventDefault()
+ const v = thisNode.value.trim()
+ if (v && !this.state.tags.includes(v)) {
+ this.state.tags.push(v)
+ this.state.tags = this.state.tags
+ this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
+ }
+ thisNode.value = ''
+ }
+ })}">
+
+ `), Util.makeDom(/*html*/``))
+
+ AutoForm.register('TagsInput')
+
+ Component.register('FastList', container => {
+ const itemHeights = new Map()
+ const groupHeights = new Map()
+ let groupItemCount = 1
+ container.state.renderedList = []
+ const avg = Util.newAvg()
+ let containerPaddingTop = 0
+ let containerRowGap = 0
+ let topMargin = 0
+ let listStartIndex = 0
+ let visibleStartIndex = 0
+ let visibleCount = 10
+ let listInited = false
+
+ container.onItemUpdate = (index, node) => {
+ const absoluteIndex = index + listStartIndex
+ const oldHeight = itemHeights.get(absoluteIndex)
+ const style = window.getComputedStyle(node)
+ const marginTop = parseFloat(style.marginTop) || 0
+ const marginBottom = parseFloat(style.marginBottom) || 0
+ const newHeight = node.offsetHeight + marginTop + marginBottom + containerRowGap
+ if (absoluteIndex === 0 && !topMargin) topMargin = marginTop
+ if (newHeight !== oldHeight) {
+ itemHeights.set(absoluteIndex, newHeight)
+ avg.add(newHeight)
+ const offset = newHeight - oldHeight || 0
+ const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
+ if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
+ }
+ }
+
+ // const timeCount = Util.newTimeCount()
+ container.refresh = () => {
+ if (!listInited) return
+ // timeCount.start()
+ visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
+ let list = []
+ const fullList = container.state.list
+ const fullSize = fullList.length
+ let i = 0
+ let prev = containerPaddingTop + topMargin + containerRowGap
+ let post = 0
+ let starus = 0
+ for (i = 0; i < fullSize; i++) {
+ if (starus === 0) {
+ // 可视位置之前的计算
+ const gh = groupHeights.get(i)
+ if (gh && prev + gh < container.scrollTop) {
+ prev += gh
+ i += Math.min(groupItemCount, fullSize - i) - 1
+ } else {
+ const ih = itemHeights.get(i)
+ if (prev + ih < container.scrollTop) {
+ prev += ih
+ } else {
+ starus = 1
+ // 可视位置处理
+ visibleStartIndex = Math.max(0, i)
+ listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
+ const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
+ i = listEndIndex - 1
+ list = fullList.slice(listStartIndex, listEndIndex)
+ for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
+ }
+ }
+ } else if (starus === 1) {
+ // 底部填充计算
+ const gh = groupHeights.get(i)
+ if (gh) {
+ post += gh
+ i += groupItemCount - 1
+ } else {
+ post += itemHeights.get(i)
+ }
+ }
+ }
+ // console.info('FastList refresh make list:', timeCount.end())
+ container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
+ container.state.postHeight = post
+ container.state.renderedList = list
+ // console.info('FastList refresh time:', timeCount.end())
+ }
+ container.state.__watch('list', list => {
+ listInited = false
+ itemHeights.clear()
+ groupHeights.clear()
+ avg.clear()
+ if (!list?.length) return
+
+ const listSize = list.length || 0
+ groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
+ const style = window.getComputedStyle(container)
+ containerPaddingTop = parseFloat(style.paddingTop)
+ containerRowGap = parseFloat(style.rowGap) || 0
+ container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
+ setTimeout(() => {
+ // 渲染一批之后立刻计算所有高度
+ container.state.list.forEach((item, index) => itemHeights.set(index, avg.get()))
+ for (let i = 0; i < listSize; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, listSize - i) * avg.get())
+ listInited = true
+ container.refresh()
+ })
+ })
+
+ }, Util.makeDom(/*html*/`
+
+
0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0">
+
+
0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0">
+
+ `))
+
+ Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
+ const selectable = selectableAttr && container.hasAttribute(selectableAttr)
+ container[selectActionName] = (item, index) => {
+ if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
+ container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
+ }
+ container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
+ }
+ Component.register('List', container => {
+ Component.attachSelectFeature(container)
+ const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
+ container.state.__watch('list', oldList => {
+ Util.updateDefaults(container, defaultSets)
+ })
+ }, Util.makeDom(/*html*/`
+
+
+
+
\${item[this.labelfield]}
+
+
+
+
+ `))
+
+ Component.register('GroupedList', container => {
+ Component.getSetupFunction('List')(container)
+ container._selectItem = container.selectItem
+ container.selectItem = (item, index) => {
+ container.state.selectedGroup = null
+ container._selectItem(item, index)
+ }
+ Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select') && 'group-selectable')
+ container._selectGroup = container.selectGroup
+ container.selectGroup = (item, index) => {
+ container.state.selectedItem = null
+ container._selectGroup(item, index)
+ }
+
+ const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
+ container.state.__watch('list', oldList => {
+ Util.updateDefaults(container, defaultSets)
+ const newList = [];
+ (container.state.groups || []).forEach(group => {
+ newList.push({ [container.typefield]: 'group', ...group })
+ if (oldList) oldList.forEach(item => {
+ if (item[container.groupfield] === group[container.groupidfield]) {
+ newList.push({ [container.typefield]: 'item', ...item })
+ }
+ })
+ })
+ aa = container
+ return newList
+ })
+ }, Util.makeDom(/*html*/`
+
+
+
+
+
\${item[this.grouplabelfield]}
+
+
+
+
+
+
\${item[this.labelfield]}
+
+
+
+
+
+ `))
+
+ Component.register('FastGroupedList', container => {
+ Component.getSetupFunction('List')(container)
+ Component.getSetupFunction('GroupedList')(container)
+ }, Util.makeDom(/*html*/`
+
+
+
+
+
\${item[this.grouplabelfield]}
+
+
+
+
+
+
\${item[this.labelfield]}
+
+
+
+
+
+ `))
+
+ Component.register('Tree', container => {
+ const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
+ container.state.__watch('list', () => {
+ Util.updateDefaults(container, defaultSets)
+ const childrenMap = {};
+ (container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
+ const newList = []
+ const traverse = (list, level) => list.forEach(item => {
+ const id = item[container.idfield]
+ const hasChildren = !!childrenMap[id]?.length
+ newList.push({ ...item, _level: level, _hasChildren: hasChildren })
+ if (hasChildren) traverse(childrenMap[id], level + 1)
+ })
+ traverse(childrenMap[''] || [], 0)
+ return newList
+ })
+ }, Util.makeDom(/*html*/`
+
+
+
+ `))
+
+ Component.register('FastTree', container => {
+ Component.getSetupFunction('List')(container)
+ Component.getSetupFunction('Tree')(container)
+ }, Util.makeDom(/*html*/`
+
+
+
+ `))
+
+ Component.register('CollapseTree', container => {
+ Component.attachSelectFeature(container)
+ const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
+ container.collapsed = NewState({})
+ container.state.__watch('list', () => {
+ Util.updateDefaults(container, defaultSets)
+ const childrenMap = {};
+ (container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
+ const newList = []
+ const traverse = (list, level, parents) => list.forEach(item => {
+ const id = item[container.idfield]
+ const hasChildren = !!childrenMap[id]?.length
+ newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
+ if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
+ })
+ traverse(childrenMap[''] || [], 0, [])
+ return newList
+ })
+ }, Util.makeDom(/*html*/`
+
+ this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
+
+
+
+
+
+
+
+
+ `))
+
+ Component.register('Nav', container => {
+ container.click = (item, noselect) => {
+ if (!item.noselect && !noselect) Hash.nav = item.name
+ container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
+ }
+ }, Util.makeDom(/*html*/`
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+ `))
+
+ let _mouseMoverMoving = false
+ let _mouseMoverPos = {}
+ let _mouseMoverEvents = {}
+ globalThis.MouseMover = {
+ start: (event, { onmousemove, onmouseup }) => {
+ _mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
+ _mouseMoverEvents = { onmousemove, onmouseup }
+ _mouseMoverMoving = true
+ },
+ }
+ document.addEventListener('mouseup', event => {
+ if (!_mouseMoverMoving) return
+ _mouseMoverMoving = false
+ _mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
+ })
+ document.addEventListener('mousemove', event => {
+ if (!_mouseMoverMoving) return
+ _mouseMoverPos.w = event.clientX - _mouseMoverPos.x
+ _mouseMoverPos.h = event.clientY - _mouseMoverPos.y
+ _mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
+ })
+
+ Component.register('Resizer', container => {
+ const isVertical = container.hasAttribute('vertical')
+ const min = parseInt(container.getAttribute('min')) || 10
+ const max = parseInt(container.getAttribute('max')) || 1000
+ const target = container.target || container.previousElementSibling
+ const getSize = (startSize, w, h) => {
+ const newSize = startSize + (isVertical ? h : w)
+ return newSize < min ? min : newSize > max ? max : newSize
+ }
+ container.addEventListener('mousedown', event => {
+ const startSize = isVertical ? target.offsetHeight : target.offsetWidth
+ MouseMover.start(event, {
+ onmousemove: ({ w, h }) => target.style[isVertical ? 'height' : 'width'] = getSize(startSize, w, h) + 'px',
+ onmouseup: ({ w, h }) => container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize: getSize(startSize, w, h) }, bubbles: false })),
+ })
+ })
+ }, Util.makeDom(/*html*/`
+
+ `))
+
+})()
\ No newline at end of file
diff --git a/src/list.js b/src/list.js
index f3f11cd..3ebb622 100644
--- a/src/list.js
+++ b/src/list.js
@@ -20,11 +20,7 @@ export const VirtualScroll = () => {
if (listInited) return;
const size = list.length, defaultHeight = avg.get() || 32;
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
- for (let i = 0; i < size; i += groupItemCount) {
- let sum = 0;
- for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
- groupHeights.set(i, sum);
- }
+ for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
listInited = true; refreshCallback();
},
update: (absoluteIndex, node) => {
@@ -32,7 +28,7 @@ export const VirtualScroll = () => {
const style = window.getComputedStyle(node);
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
}
- if (absoluteIndex === 0) topMargin = itemMarginTop;
+ if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
const oldHeight = itemHeights.get(absoluteIndex);
if (newHeight !== oldHeight) {
@@ -43,31 +39,49 @@ export const VirtualScroll = () => {
},
calc: (container, list) => {
if (!listInited || !list) return null;
- const size = list.length, visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
+ const size = list.length;
+ let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
+ let renderedList = [];
const scrollTop = container.scrollTop;
for (let i = 0; i < size; i++) {
if (status === 0) {
const gh = groupHeights.get(i);
- if (gh && prev + gh <= scrollTop && i + groupItemCount < size) { prev += gh; i += groupItemCount - 1; }
- else {
+ if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
+ prev += gh;
+ i += groupItemCount - 1;
+ } else {
const ih = itemHeights.get(i);
- if (prev + ih <= scrollTop && i < size - 1) prev += ih;
- else {
- status = 1; let visibleStartIndex = i;
+ if (prev + ih <= scrollTop && i < size - 1) {
+ prev += ih;
+ } else {
+ status = 1;
+ let visibleStartIndex = Math.max(0, i);
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
- i = listEndIndex - 1; for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
+ i = listEndIndex - 1;
+ renderedList = list.slice(listStartIndex, listEndIndex);
+ for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
}
}
- } else {
+ } else if (status === 1) {
const gh = groupHeights.get(i);
- if (gh && i + groupItemCount <= size) { post += gh; i += groupItemCount - 1; }
- else post += itemHeights.get(i);
+ if (gh) {
+ post += gh;
+ i += groupItemCount - 1;
+ } else {
+ post += itemHeights.get(i);
+ }
}
}
- return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
+ // Fix for flex gap inflation:
+ // prevHeight block participates in flex gap, so we subtract one rowGap.
+ const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
+ // post accumulated M * (h + gap). Real space is sum(h) + (M-1)*gap = post - gap.
+ // DOM adds one gap before the block, so we need postHeight + gap = post - gap => postHeight = post - 2*gap.
+ const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
+ return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
}
}
}
@@ -130,7 +144,7 @@ Component.register('List', container => {
if (container.fast) {
container.state._listStartIndex = 0
container.state._renderedList = vs.reset(flatList, container) || []
- requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
+ setTimeout(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
} else container.state._renderedList = flatList
})
@@ -146,7 +160,7 @@ Component.register('List', container => {
updateFlatList()
}, Util.makeDom(/*html*/`
-
+
0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0">
diff --git a/test/all.spec.js b/test/all.spec.js
index 1cb844b..d888ac4 100644
--- a/test/all.spec.js
+++ b/test/all.spec.js
@@ -14,6 +14,8 @@ test('base project comprehensive tests and scrolling benchmarks', async ({ page
// Simulate real scrolling and check dynamic rendering for FastList
console.log('Simulating real scroll on FastList...');
const scrollInfo = await page.evaluate(async () => {
+ window.switchTab?.('ll');
+ await new Promise(r => setTimeout(r, 200)); // wait for render
const el = document.getElementById('ll');
const results = [];
diff --git a/test/base.test.js b/test/base.test.js
index 5f2df60..a64d852 100644
--- a/test/base.test.js
+++ b/test/base.test.js
@@ -48,6 +48,8 @@ export async function runTests() {
const listIds = ['ll', 'gl', 'tt', 'ct'];
for (const id of listIds) {
+ window.switchTab?.(id);
+ await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
if (!el) throw new Error(`Component #${id} not found`);
console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName));
@@ -59,6 +61,8 @@ export async function runTests() {
window.benchResults = {};
const measure = async (id, name) => {
+ window.switchTab?.(id);
+ await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
const start = performance.now();
el.scrollTop = 5000;
diff --git a/test/index.html b/test/index.html
index 0531422..5d72210 100644
--- a/test/index.html
+++ b/test/index.html
@@ -27,8 +27,23 @@
group_list.push({ id: i, label: `group ${i}`, summary: `group ${i} summary` })
}
-
-
+
+
+
+
+
+
+
+
+
Fast List (Variable Height)
@@ -36,17 +51,17 @@
-
+
Fast Grouped List
-
+
Fast Tree List
-
+
Normal Tree List (Collapsible)