custom-file-tree
Version:
Add the custom element to your page context using plain old HTML:
2 lines (1 loc) • 18 kB
JavaScript
var c=n=>document.createElement(n);var f=globalThis.customElements??{define:()=>{}};function T(n){return new Promise((t,e)=>{let i=new FileReader;i.onloadend=({target:s})=>t(s.result),i.onerror=e,i.readAsArrayBuffer(n)})}var v=globalThis.HTMLElement??class{},p=class extends v{state={};eventControllers=[];constructor(){super(),this.addUIElements()}addUIElements(){if(this.icon=this.find("& > .icon"),!this.icon){let t=this.icon=c("span");t.classList.add("icon"),this.appendChild(t)}if(this.heading=this.find("& > entry-heading"),!this.heading){let t=this.heading=c("entry-heading");this.appendChild(t)}if(!this.readonly&&(this.buttons=this.find("& > span.buttons"),!this.buttons)){let t=this.buttons=c("span");t.classList.add("buttons"),this.appendChild(t)}}addExternalListener(t,e,i,s={}){let r=new AbortController;s.signal=r.signal,t.addEventListener(e,i,s),this.addAbortController(r)}addListener(t,e,i={}){this.addExternalListener(this,t,e,i)}addAbortController(t){this.eventControllers.push(t)}disconnectedCallback(){let{eventControllers:t}=this;for(;t.length;)t.shift().abort()}get removeEmptyDir(){return this.root.removeEmptyDir}get name(){return this.getAttribute("name")}set name(t){this.setAttribute("name",t)}get path(){return this.getAttribute("path")}set path(t){if(!t)return;let e=t.endsWith("/")?-2:-1,i=t.split("/"),s=this.name=i.at(e).replace(/#.*/,"");if(!this.name&&t)throw Error(`why? path is ${t}`);if(this.isFile){let o=s.indexOf(".");o>=0&&o<s.length-1&&(this.extension=s.substring(o+1),this.setAttribute("extension",this.extension))}let r=this.find("& > entry-heading");r.textContent=this.name,this.setAttribute("path",t)}updatePath(t,e,i){if(this.path===e)return this.path=i,!0;if(t)return!1;let s=new RegExp(`^${e}`);return this.path=this.path.replace(s,i),!0}get dirPath(){let{path:t,name:e}=this;if(this.isFile)return t.replace(e,"");if(this.isDir)return t.substring(0,t.lastIndexOf(e));throw Error("entry is file nor dir.")}get root(){return this.closest("file-tree")}get parentDir(){let t=this;return t.tagName==="DIR-ENTRY"&&(t=t.parentNode),t.closest("dir-entry")}emit(t,e={},i=()=>{}){e.grant=i,this.root.dispatchEvent(new CustomEvent(t,{detail:e}))}find(t){return this.querySelector(t)}findInTree(t){return this.root.querySelector(t)}findAll(t){return Array.from(this.querySelectorAll(t))}findAllInTree(t){return Array.from(this.root.querySelectorAll(t))}hasButton(t){return this.find(`& > .buttons .${t}`)}select(){this.root.unselect(),this.classList.add("selected"),this.parentNode?.toggle?.(!1)}setState(t){Object.assign(this.state,t)}},_=class extends v{};f.define("entry-heading",_);var g="file-tree:",y=class{bypassSync=["load","read"];waitList={};pending=[];constructor(t,e,i=".",s=6e4){Object.assign(this,{fileTree:t,url:e,basePath:i,keepAliveInterval:s}),this.connect()}async connect(t=this.url,e=this.basePath){if(t=t.replace("https://","wss://"),!t.startsWith("wss://"))throw new Error("Only secure URLs are supported.");let i=this.socket=new WebSocket(t);i.addEventListener("message",({data:o})=>{o=JSON.parse(o);let{type:a,detail:h}=o;if(!a.startsWith(g))return;a=a.replace(g,"");let l=`on${a}`,u=this[l].bind(this);if(!u)throw new Error(`Missing implementation for ${l}.`);this.checkSync(a,h.seqnum)&&u(h)});let s,r=()=>{this.send("keepalive",{basePath:e}),s=setTimeout(r,this.keepAliveInterval)};i.addEventListener("close",()=>{clearTimeout(s)}),i.addEventListener("open",()=>{this.send("load",{basePath:e}),r()})}async markWaiting(t,e){this.waitList[t]=e}async send(t,e={}){let i={type:`${g}${t}`,detail:e};this.pending.push(i),this.socket.send(JSON.stringify(i))}checkSync(t,e){if(this.bypassSync.includes(t))return!0;if(e===this.seqnum+1){let{pending:i}=this;return i.length&&(i[0].type===t?i.shift():this.rollback(i.reverse())),this.seqnum=e}this.send("sync",{seqnum:this.seqnum})}rollback(t){this.pending=[];for(let{type:e,detail:i}of t)e==="create"&&this.fileTree.__delete(i.path),e==="delete"&&(this.fileTree.__create(i.path,i.isFile),this.read(path)),e==="move"&&this.fileTree.__move(i.isFile,i.newPath,i.oldPath),e==="update"&&this.read(path)}async create(t,e,i){this.send("create",{path:t,isFile:e,content:i})}async delete(t){this.send("delete",{path:t})}async move(t,e,i){this.send("move",{isFile:t,oldPath:e,newPath:i})}async read(t){return new Promise(e=>{this.markWaiting(t,e),this.send("read",{path:t})})}async update(t,e,i){this.send("update",{path:t,type:e,update:i})}async onload({id:t,dirs:e,files:i,seqnum:s}){this.id=t,this.seqnum=s,this.fileTree.setContent({dirs:e,files:i},!0)}async onterminate({id:t,reconnect:e}){this.id===t&&(this.socket.close(),e&&this.connect())}async oncreate({path:t,isFile:e,from:i}){let{id:s,fileTree:r}=this;if(i===s)return;let o=r.__create(t,e);r.dispatchEvent(new CustomEvent("ot:created",{detail:{entry:o,path:t,isFile:e}}))}async ondelete({path:t,from:e}){let{id:i,fileTree:s}=this;if(e===i)return;let r=s.__delete(t);s.dispatchEvent(new CustomEvent("ot:deleted",{detail:{entries:r,path:t}}))}async onmove({isFile:t,oldPath:e,newPath:i,from:s}){let{id:r,fileTree:o}=this;if(s===r)return;let a=o.__move(t,e,i);o.dispatchEvent(new CustomEvent("ot:moved",{detail:{entry:a,isFile:t,oldPath:e,newPath:i}}))}async onread({path:t,data:e}){let{waitList:i}=this;i[t]?.({data:e}),delete i[t]}async onupdate({path:t,type:e,update:i,from:s}){let{id:r,fileTree:o}=this;o.__update(t,e,i,s===r)}};var C={"en-GB":{CREATE_FILE:"Create new file",CREATE_FILE_PROMPT:"Please specify a filename.",CREATE_FILE_NO_DIRS:"Just add new files directly to the directory where they should live.",RENAME_FILE:"Rename file",RENAME_FILE_PROMPT:"New file name?",RENAME_FILE_MOVE_INSTEAD:"If you want to relocate a file, just move it.",DELETE_FILE:"Delete file",DELETE_FILE_PROMPT:n=>`Are you sure you want to delete ${n}?`,CREATE_DIRECTORY:"Add new directory",CREATE_DIRECTORY_PROMPT:"Please specify a directory name.",CREATE_DIRECTORY_NO_NESTING:"You'll have to create nested directories one at a time.",RENAME_DIRECTORY:"Rename directory",RENAME_DIRECTORY_PROMPT:"Choose a new directory name",RENAME_DIRECTORY_MOVE_INSTEAD:"If you want to relocate a directory, just move it.",DELETE_DIRECTORY:"Delete directory",DELETE_DIRECTORY_PROMPT:n=>`Are you *sure* you want to delete ${n} and everything in it?`,UPLOAD_FILES:"Upload files from your device",PATH_EXISTS:n=>`${n} already exists.`,PATH_DOES_NOT_EXIST:n=>`${n} does not exist.`,PATH_INSIDE_ITSELF:n=>`Cannot nest ${n} inside its own subdirectory.`,INVALID_UPLOAD_TYPE:n=>`Unfortunately, a ${n} is not a file or folder.`}},O="en-GB",b=globalThis.navigator?.language,d=C[b]||C[O];function I({root:n,path:t}){let e=c("input");e.type="file",e.multiple=!0,confirm('To upload one or more files, press "OK". To upload an entire folder, press "Cancel".')||(e.webkitdirectory=!0),e.addEventListener("change",()=>{let{files:s}=e;s&&D(n,s,t)}),e.click()}async function D(n,t,e=""){let i=t.length>1;async function s(r,o=""){if(r instanceof File&&!r.isDirectory){let a=await T(r),h=o+(r.webkitRelativePath||r.name),l=(e==="."?"":e)+h;n.createEntry(l,!0,a,i)}else if(r.isFile)r.file(async a=>{let h=await T(a),l=o+a.name,u=(e==="."?"":e)+l;n.createEntry(u,!0,h,i)});else if(r.isDirectory){i=!0;let a=o+r.name+"/";n.createEntry(a,!1,!1,i),r.createReader().readEntries(async h=>{for(let l of h)await s(l,a)})}}for(let r of t)try{let o;!o&&r instanceof File&&(o=r),!o&&r.webkitGetAsEntry&&(o=r.webkitGetAsEntry()??o),!o&&r.getAsFile&&(o=r.getAsFile()),await s(o)}catch{return alert(d.INVALID_UPLOAD_TYPE(r.kind))}}function N(n){let{readonly:t}=n.root,e=new AbortController,i=()=>{n.findAllInTree(".drop-target").forEach(s=>s.classList.remove("drop-target"))};if(n.draggable=!0,n.addEventListener("dragstart",s=>{s.stopPropagation(),!n.root.readonly&&(n.classList.add("dragging"),n.dataset.id=`${Date.now()}-${Math.random()}`,s.dataTransfer.setData("id",n.dataset.id))},{signal:e.signal}),!t)return n.addEventListener("dragenter",s=>{s.preventDefault(),i(),n.classList.add("drop-target")},{signal:e.signal}),n.addEventListener("dragover",s=>{let r=s.target;S(n,r)&&(s.preventDefault(),i(),n.classList.add("drop-target"))},{signal:e.signal}),n.addEventListener("dragleave",s=>{s.preventDefault(),i()},{signal:e.signal}),n.addEventListener("drop",async s=>{s.preventDefault(),s.stopPropagation(),i();let r=s.dataTransfer.getData("id");if(r)return F(n,r);await D(n.root,s.dataTransfer.items,n.path)},{signal:e.signal}),n.path==="."?n.draggable=!1:e}function S(n,t){return t===n?!0:t.closest("dir-entry")===n}function F(n,t){let e=n.findInTree(`[data-id="${t}"]`);if(delete e.dataset.id,e.classList.remove("dragging"),e===n)return;let i=n.path,s=(i!=="."?i:"")+e.name;e.isDir&&(s+="/"),n.root.moveEntry(e,s)}var E=class extends p{isDir=!0;constructor(t,e=!1){super(),t.readonly||this.addButtons(e)}get path(){return super.path}set path(t){super.path=t,t==="."&&(this.find("& > .rename-dir")?.remove(),this.find("& > .delete-dir")?.remove())}connectedCallback(){this.addListener("click",e=>this.selectListener(e)),this.addExternalListener(this.icon,"click",e=>this.foldListener(e));let t=N(this);t&&this.addAbortController(t)}selectListener(t){if(t.stopPropagation(),t.preventDefault(),this.path===".")return;let e=t.target.tagName;e!=="DIR-ENTRY"&&e!=="ENTRY-HEADING"||(this.root.selectEntry(this),this.classList.contains("closed")&&this.foldListener(t))}foldListener(t){if(t.stopPropagation(),t.preventDefault(),this.path===".")return;let e=this.classList.contains("closed");this.root.toggleDirectory(this,{currentState:e?"closed":"open"})}addButtons(t){this.createFileButton(),this.createDirButton(),this.addUploadButton(),t||(this.addRenameButton(),this.addDeleteButton())}createFileButton(){if(this.hasButton("create-file"))return;let t=c("button");t.classList.add("create-file"),t.title=d.CREATE_FILE,t.textContent="\u{1F4C4}",t.addEventListener("click",()=>this.#i()),this.buttons.appendChild(t)}#i(){let t=prompt(d.CREATE_FILE_PROMPT)?.trim();if(t){if(t.includes("/"))return alert(d.CREATE_FILE_NO_DIRS);this.path!=="."&&(t=this.path+t),this.root.createEntry(t,!0)}}createDirButton(){if(this.hasButton("create-dir"))return;let t=c("button");t.classList.add("create-dir"),t.title=d.CREATE_DIRECTORY,t.textContent="\u{1F4C1}",t.addEventListener("click",()=>this.#t()),this.buttons.appendChild(t)}#t(){let t=prompt(String.CREATE_DIRECTORY_PROMPT)?.trim();if(t){if(t.includes("/"))return alert(d.CREATE_DIRECTORY_NO_NESTING);let e=(this.path!=="."?this.path:"")+t+"/";this.root.createEntry(e,!1)}}addUploadButton(){if(this.hasButton("upload"))return;let t=c("button");t.classList.add("upload"),t.title=d.UPLOAD_FILES,t.textContent="\u{1F4BB}",t.addEventListener("click",()=>I(this)),this.buttons.appendChild(t)}addRenameButton(){if(this.path==="."||this.hasButton("rename-dir"))return;let t=c("button");t.classList.add("rename-dir"),t.title=d.RENAME_DIRECTORY,t.textContent="\u270F\uFE0F",this.buttons.appendChild(t),t.addEventListener("click",()=>this.#s())}#s(){let t=prompt(d.RENAME_DIRECTORY_PROMPT,this.name)?.trim();if(t){if(t.includes("/"))return alert(d.RENAME_DIRECTORY_MOVE_INSTEAD);this.root.renameEntry(this,t)}}addDeleteButton(){if(this.path==="."||this.hasButton("delete-dir"))return;let t=c("button");t.classList.add("delete-dir"),t.title=d.DELETE_DIRECTORY,t.textContent="\u{1F5D1}\uFE0F",this.buttons.appendChild(t),t.addEventListener("click",()=>this.#e())}#e(){let t=d.DELETE_DIRECTORY_PROMPT(this.path);confirm(t)&&this.root.removeEntry(this)}addEntry(t){this.appendChild(t),this.sort()}checkEmpty(){this.removeEmptyDir&&(this.find("dir-entry, file-entry")||this.root.removeEntry(this))}sort(t=!0,e=!0){let i=[...this.children];i.sort((s,r)=>{if(s.tagName==="SPAN"&&s.classList.contains("icon"))return-1;if(r.tagName==="SPAN"&&r.classList.contains("icon"))return 1;if(s.tagName==="ENTRY-HEADING")return-1;if(r.tagName==="ENTRY-HEADING")return 1;if(s.tagName==="SPAN"&&r.tagName==="SPAN")return 0;if(s.tagName==="SPAN")return-1;if(r.tagName==="SPAN")return 1;if(e){if(s.tagName==="DIR-ENTRY"&&r.tagName==="DIR-ENTRY")return s=s.path,r=r.path,s<r?-1:1;if(s.tagName==="DIR-ENTRY")return-1;if(r.tagName==="DIR-ENTRY")return 1}return s=s.path,r=r.path,s<r?-1:1}),i.forEach(s=>this.appendChild(s)),t&&this.findAll("& > dir-entry").forEach(s=>s.sort(t))}toggle(t){this.classList.toggle("closed",t),this.parentNode?.toggle?.(!1)}toJSON(){return JSON.stringify(this.toValue())}toString(){return this.toJSON()}toValue(){return this.root.toValue().filter(t=>t.startsWith(this.path))}};f.define("dir-entry",E);var m=class extends p{isFile=!0;constructor(t,e,i){super(e,i),t.readonly||this.addButtons(),this.addEventHandling(t.readonly)}addButtons(){this.addRenameButton(),this.addDeleteButton()}addRenameButton(){if(this.hasButton("rename-file"))return;let t=c("button");t.classList.add("rename-file"),t.title=d.RENAME_FILE,t.textContent="\u270F\uFE0F",this.buttons.appendChild(t),t.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation();let i=prompt(d.RENAME_FILE_PROMPT,this.heading.textContent)?.trim();if(i){if(i.includes("/"))return alert(d.RENAME_FILE_MOVE_INSTEAD);this.root.renameEntry(this,i)}})}addDeleteButton(){if(this.hasButton("delete-file"))return;let t=c("button");t.classList.add("delete-file"),t.title=d.DELETE_FILE,t.textContent="\u{1F5D1}\uFE0F",this.buttons.appendChild(t),t.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation(),confirm(d.DELETE_FILE_PROMPT(this.path))&&this.root.removeEntry(this)})}addEventHandling(t){this.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation(),this.root.selectEntry(this)}),this.draggable=!0,this.addEventListener("dragstart",e=>{e.stopPropagation(),!t&&(this.classList.add("dragging"),this.dataset.id=`${Date.now()}-${Math.random()}`,e.dataTransfer.setData("id",this.dataset.id))})}async load(){return this.root.loadEntry(this.path)}async updateContent(t,e){this.root.updateEntry(this.path,t,e)}toJSON(){return JSON.stringify(this.toValue())}toString(){return this.path}toValue(){return[this.toString()]}};f.define("file-entry",m);var R=class extends p{static observedAttributes=["src"];ready=!1;isTree=!0;entries={};constructor(){super(),this.heading.textContent="File tree"}get root(){return this}get parentDir(){return this.rootDir}get readonly(){return this.hasAttribute("readonly")}get removeEmptyDir(){return this.hasAttribute("remove-empty-dir")}clear(){this.ready=!1,this.emit("tree:clear"),Object.keys(this.entries).forEach(e=>delete this.entries[e]),this.rootDir&&this.removeChild(this.rootDir);let t=this.rootDir=new E(this,!0);t.path=".",this.appendChild(t)}connectedCallback(){this.addExternalListener(document,"dragend",()=>this.findAll(".dragging").forEach(t=>t.classList.remove("dragging")))}attributeChangedCallback(t,e,i){t==="src"&&i&&this.#i(i)}async connectViaWebSocket(t,e=".",i=6e4,s=y){return this.OT=new s(this,t,e,i),this.OT}setContent({dirs:t,files:e},i=!1){return this.clear(),t?.forEach(s=>this.#t(`${s}/`,!1,void 0,!0,"tree:add:dir",!0,i)),e?.forEach(s=>this.#t(s,!0,void 0,!0,"tree:add:file",!0,i)),this.ready=!0,this.emit("tree:ready")}createEntry(t,e,i=void 0,s=!1){let r=(e?"file":"dir")+":create";this.#t(t,e,i,s,r)}async loadEntry(t){return this.OT?.read(t)}async updateEntry(t,e,i){return this.OT?.update(t,e,i)}renameEntry(t,e){let i=t.path,s=i.lastIndexOf(t.name),r=i.substring(0,s)+e;t.isDir&&(r+="/");let o=(t.isFile?"file":"dir")+":rename";this.#e(t,i,r,o)}moveEntry(t,e){let i=(t.isFile?"file":"dir")+":move";this.#e(t,t.path,e,i)}removeEntry(t){let{path:e,isFile:i,parentDir:s}=t,r=(i?"file":"dir")+":delete",o={path:e,emptyDir:this.removeEmptyDir};this.emit(r,o,()=>{let a=this.__delete(e,i);return this.OT?.delete(e),o.removed=a,setTimeout(()=>s.checkEmpty(),10),a})}async#i(t){let i=await(await fetch(t)).json();if(i){let{dirs:s,files:r}=i;this.setContent({dirs:s,files:r})}}#t(t,e,i=void 0,s=!1,r,o=!1,a=!1){let{entries:h}=this;if(h[t])return this.emit(`${r}:error`,{error:d.PATH_EXISTS(t)});let l={path:t,content:i,bulk:s},u=(L=i)=>{let A=this.__create(t,e);return a||this.OT?.create(t,e,L),l.entry=A,A};if(o)return u();this.emit(r,l,u)}#s({dirPath:t}){let{entries:e}=this;if(!t)return this.rootDir;let i=this.find(`[path="${t}"`);return i||(i=this.rootDir,t.split("/").forEach(s=>{if(!s)return;let r=(i.path==="."?"":i.path)+s+"/",o=this.find(`[path="${r}"`);o||(o=new E(this),o.path=r,i.addEntry(o),e[r]=o),i=o}),i)}#e(t,e,i,s){let{entries:r}=this;if(e===i)return;if(i.startsWith(e)&&i.replace(e,"").includes("/"))return this.emit(`${s}:error`,{oldPath:e,newPath:i,error:d.PATH_INSIDE_ITSELF(e)});if(r[i])return this.emit(`${s}:error`,{oldPath:e,newPath:i,error:d.PATH_EXISTS(i)});let o={oldPath:e,newPath:i};this.emit(s,o,()=>(this.__move(t.isFile,e,i),this.OT?.move(t.isFile,e,i),o.entry=t,t))}__create(t,e){let{entries:i}=this,s=e?m:E,r=i[t]=new s(this);return r.path=t,this.#s(r).addEntry(r),r}__move(t,e,i,s){let{entries:r}=this,o=r[e];Object.keys(r).forEach(l=>{if(l.startsWith(e)){let u=r[l];u.updatePath(t,e,i)&&(r[u.path]=u,delete r[l])}});let{dirPath:a}=r[i]=o;return(a?r[a]:this.rootDir).addEntry(o),o}__update(t,e,i,s){this.entries[t]?.dispatchEvent(new CustomEvent("content:update",{detail:{type:e,update:i,ours:s}}))}__delete(t,e,i){let{entries:s}=this,r=s[t],o=[r];return e?(r.remove(),delete s[t]):Object.entries(s).forEach(([a,h])=>{a.startsWith(t)&&(o.push(h),h.remove(),delete s[a])}),o}select(t){let e=this.entries[t];if(!e)throw new Error(d.PATH_DOES_NOT_EXIST(t));e.select()}unselect(){this.find(".selected")?.classList.remove("selected")}selectEntry(t,e={}){let i=(t.isFile?"file":"dir")+":click";e.path=t.path,this.emit(i,e,()=>(t.select(),e.entry=t,t))}toggleDirectory(t,e={}){if(t.isFile)return;let i="dir:toggle";e.path=t.path,this.emit(i,e,()=>{e.entry=t,t.toggle()})}sort(){this.rootDir.sort()}toJSON(){return JSON.stringify(Object.keys(this.entries).sort())}toString(){return this.toJSON()}toValue(){return this}};f.define("file-tree",R);export{g as FILE_TREE_PREFIX,y as WebSocketInterface};