browse.js 0.0.2 → 0.1.1
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/.github/workflows/npm-publish.yml +27 -0
- package/dist/browse.d.ts +25 -0
- package/dist/browse.js +542 -1
- package/dist/style.css +56 -2
- package/package.json +2 -4
- package/src/browse.js +67 -0
- package/src/style.less +58 -2
- package/dist/browse.min.js +0 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
|
|
3
|
+
|
|
4
|
+
name: Node.js Package
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [created]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write # Required for OIDC
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: '24'
|
|
23
|
+
registry-url: 'https://registry.npmjs.org'
|
|
24
|
+
- run: npm ci
|
|
25
|
+
- run: npm run build
|
|
26
|
+
- run: npm run test --if-present
|
|
27
|
+
- run: npm publish
|
package/dist/browse.d.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* @property {Object} [meta] - Optional metadata object with additional info (e.g. size, type)
|
|
12
12
|
* @property {FileItem[]} [children] - If present, indicates this item is a folder containing these child items
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} FileAction
|
|
16
|
+
* @property {string} [icon] - Optional icon URL
|
|
17
|
+
* @property {string} [label] - Label for the action
|
|
18
|
+
* @property {function(FileItem):void} action - Callback invoked when the action is selected.
|
|
19
|
+
*/
|
|
14
20
|
/**
|
|
15
21
|
* @typedef {Object} BrowseJSOptions
|
|
16
22
|
* @property {string} [rootName] - The display name for the root folder (default: "Root")
|
|
@@ -23,6 +29,7 @@
|
|
|
23
29
|
* @property {boolean} [multiSelect] - If true, allows selecting multiple files (default: false)
|
|
24
30
|
* @property {function(string):?(FileItem)} [onCreateFolder] - Optional callback invoked when creating a new folder. If it returns a FileItem, it will be added to the current folder.
|
|
25
31
|
* @property {function(File[]):?(FileItem|FileItem[])} [onUpload] - Optional callback invoked when files are uploaded. If it returns FileItem(s), they'll be added to the current folder.
|
|
32
|
+
* @property {function(FileItem):FileAction[]} [onContext] - Optional callback invoked when the context menu is activated for a file. If it returns FileActions, they'll be displayed in the context menu.
|
|
26
33
|
*/
|
|
27
34
|
export class BrowseJS {
|
|
28
35
|
/**
|
|
@@ -101,6 +108,20 @@ export type FileItem = {
|
|
|
101
108
|
*/
|
|
102
109
|
children?: FileItem[];
|
|
103
110
|
};
|
|
111
|
+
export type FileAction = {
|
|
112
|
+
/**
|
|
113
|
+
* - Optional icon URL
|
|
114
|
+
*/
|
|
115
|
+
icon?: string;
|
|
116
|
+
/**
|
|
117
|
+
* - Label for the action
|
|
118
|
+
*/
|
|
119
|
+
label?: string;
|
|
120
|
+
/**
|
|
121
|
+
* - Callback invoked when the action is selected.
|
|
122
|
+
*/
|
|
123
|
+
action: (arg0: FileItem) => void;
|
|
124
|
+
};
|
|
104
125
|
export type BrowseJSOptions = {
|
|
105
126
|
/**
|
|
106
127
|
* - The display name for the root folder (default: "Root")
|
|
@@ -134,4 +155,8 @@ export type BrowseJSOptions = {
|
|
|
134
155
|
* - Optional callback invoked when files are uploaded. If it returns FileItem(s), they'll be added to the current folder.
|
|
135
156
|
*/
|
|
136
157
|
onUpload?: (arg0: File[]) => (FileItem | FileItem[]) | null;
|
|
158
|
+
/**
|
|
159
|
+
* - Optional callback invoked when the context menu is activated for a file. If it returns FileActions, they'll be displayed in the context menu.
|
|
160
|
+
*/
|
|
161
|
+
onContext?: (arg0: FileItem) => FileAction[];
|
|
137
162
|
};
|
package/dist/browse.js
CHANGED
|
@@ -1 +1,542 @@
|
|
|
1
|
-
function svgDataUrlFromSvg(svg){return"data:image/svg+xml;utf8,"+encodeURIComponent(svg)}function escapeText(t){return String(t).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}function svgDocumentIcon(label,bg="#e9eefc",w=240,h=310){const safe=escapeText(label||"");const fold=84;const bodyPath=`M0 0 H${w-fold} L${w} ${fold} V${h} H0 Z`;const foldPath=`M${w-fold} 0 v${fold} h${fold} Z`;const svg=`<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'>`+`<path d='${bodyPath}' fill='${bg}' stroke='#0b12200a' stroke-linejoin='round'/>`+`<path d='${foldPath}' fill='#0000001a' stroke='#0b122005'/>`+`<text x='50%' y='58%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${safe}</text>`+`</svg>`;return svgDataUrlFromSvg(svg)}function svgFolderIcon(label,bg="#ffd7a8",w=420,h=320){const safe=escapeText(label||"");const tabH=44;const tabW=120;const svg=`<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'>`+`<rect x='6' y='34' rx='12' ry='12' width='${w-12}' height='${h-40}' fill='${bg}' stroke='#0b12200a'/>`+`<rect x='18' y='12' rx='8' ry='8' width='${tabW}' height='${tabH}' fill='${bg}'/>`+`<text x='50%' y='62%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${safe}</text>`+`</svg>`;return svgDataUrlFromSvg(svg)}const DEFAULT_ICON_RULES=[{icon:svgDocumentIcon("IMG","#c7ddff"),typeMatch:t=>t.startsWith("image/"),exts:[".png",".jpg",".jpeg",".gif",".webp",".bmp",".svg",".avif"]},{icon:svgDocumentIcon("PDF","#ffd7d7"),typeMatch:t=>t==="application/pdf",exts:[".pdf"]},{icon:svgDocumentIcon("AUD","#e6dcff"),typeMatch:t=>t.startsWith("audio/"),exts:[".mp3",".wav",".m4a",".flac"]},{icon:svgDocumentIcon("VID","#dff7ff"),typeMatch:t=>t.startsWith("video/"),exts:[".mp4",".mov",".webm",".mkv"]},{icon:svgDocumentIcon("DOC","#dfe6ff"),typeMatch:t=>["application/msword","application/vnd.openxmlformats-officedocument.wordprocessingml.document"].includes(t),exts:[".doc",".docx",".odt"]},{icon:svgDocumentIcon("XLS","#e4ffd7"),typeMatch:t=>["text/csv","application/vnd.ms-excel","application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(t),exts:[".xls",".xlsx",".ods",".csv"]},{icon:svgDocumentIcon("ZIP","#fffed7"),typeMatch:t=>t.includes("zip")||t==="application/x-zip-compressed",exts:[".zip",".tar",".gz"]},{icon:svgDocumentIcon("TXT","#eaeaea"),typeMatch:t=>t.startsWith("text/"),exts:[".txt",".md",".log"]}];export class BrowseJS{constructor(container,files=[],opts={}){if(container instanceof Element){this.container=container}else{const containerElement=document.getElementById(container)||document.querySelector(container);if(!containerElement)throw new Error("Container not found: "+container);this.container=containerElement}this.opts=opts;this.iconRules=opts.icons&&opts.icons.rules?opts.icons.rules:DEFAULT_ICON_RULES;this.folderIcon=opts.icons&&opts.icons.folder?opts.icons.folder:svgFolderIcon("");this.defaultIcon=opts.icons&&opts.icons.default?opts.icons.default:svgDocumentIcon("?","#e9eefc");this.multi=Boolean(opts.multiSelect);this.selectedIndices=new Set;this.stack=[{name:opts.rootName||"Root",files:files}];this.breadcrumbEl=document.createElement("nav");this.breadcrumbEl.className="breadcrumb";this.breadcrumbEl.setAttribute("aria-label","Breadcrumb");this.crumbsWrap=document.createElement("div");this.crumbsWrap.className="crumbs-wrap";this.controlsEl=document.createElement("div");this.controlsEl.className="crumb-controls";this.breadcrumbEl.appendChild(this.crumbsWrap);this.breadcrumbEl.appendChild(this.controlsEl);this.detailsEl=document.createElement("aside");this.detailsEl.className="details";this.detailsEl.setAttribute("aria-live","polite");this.detailsEl.textContent=opts.detailsText||"Select a file";this.container.innerHTML="";this.galleryEl=document.createElement("div");this.galleryEl.className="gallery-grid";this.container.appendChild(this.breadcrumbEl);this.container.appendChild(this.galleryEl);this.container.appendChild(this.detailsEl);this._fileInput=document.createElement("input");this._fileInput.type="file";this._fileInput.style.display="none";this.container.appendChild(this._fileInput);this.container.classList.add("browsejs");this.galleryEl.tabIndex=0;document.addEventListener("dragover",e=>{e.preventDefault();this.container.classList.add("dragover")});document.addEventListener("dragleave",e=>{this.container.classList.remove("dragover")});this.container.addEventListener("drop",e=>{e.preventDefault();this.container.classList.remove("dragover");const files=Array.from(e.dataTransfer&&e.dataTransfer.files||[]);if(files.length===0)return;this.handleDropFiles(files)});this.render()}getIconForItem(item){if(item.children)return this.folderIcon;const type=item.meta&&item.meta.type?String(item.meta.type).toLowerCase():"";const name=item.name?String(item.name).toLowerCase():"";if(type){for(const r of this.iconRules){if(r.typeMatch&&r.typeMatch(type))return r.icon}}for(const r of this.iconRules){if(r.exts){for(const e of r.exts){if(name.endsWith(e))return r.icon}}}return this.defaultIcon}render(){this.galleryEl.innerHTML="";const list=this.currentFiles();list.forEach((f,i)=>{const card=document.createElement("button");card.className="card";card.type="button";card.dataset.index=i;card.title=f.name;const img=document.createElement("img");img.alt=f.name||"";img.src=f.thumbnail||this.getIconForItem(f);const meta=document.createElement("div");meta.className="meta";meta.textContent=f.name;card.appendChild(img);card.appendChild(meta);if(f.children&&Array.isArray(f.children)){card.addEventListener("click",()=>this.enterFolder(i));card.addEventListener("keyup",e=>{if(e.key==="Enter")this.enterFolder(i)})}else{card.addEventListener("click",()=>this.select(i));card.addEventListener("keyup",e=>{if(e.key==="Enter")this.select(i)})}this.galleryEl.appendChild(card)});this.updateSelectionUI();this.renderBreadcrumb()}updateSelectionUI(){const cards=this.galleryEl.querySelectorAll(".card");cards.forEach(c=>{const idx=Number(c.dataset.index);if(this.selectedIndices.has(idx))c.classList.add("selected");else c.classList.remove("selected")});this.renderDetails()}renderDetails(){if(!this.detailsEl)return;const activeList=this.currentFiles();if(this.selectedIndices.size===0){this.detailsEl.innerHTML='<p class="small">No selection</p>'}else if(this.selectedIndices.size>1){const indices=Array.from(this.selectedIndices).sort();this.detailsEl.innerHTML="";const header=document.createElement("div");header.innerHTML=`<strong>${indices.length} item${indices.length>1?"s":""} selected</strong>`;this.detailsEl.appendChild(header);const list=document.createElement("div");list.className="small";list.innerHTML=indices.map(i=>`<div>${escapeText(activeList[i]&&activeList[i].name||"Untitled")}</div>`).join("");this.detailsEl.appendChild(list)}else{const index=Array.from(this.selectedIndices)[0];const item=activeList[index];if(!item){this.detailsEl.innerHTML='<p class="small">No selection</p>';return}this.detailsEl.innerHTML="";const img=document.createElement("img");img.className="preview";img.src=item.thumbnail||this.galleryEl.querySelector(`.card[data-index="${index}"] img`)?.src||this.getIconForItem(item);img.alt=item.name||"preview";const name=document.createElement("div");name.innerHTML=`<strong>${item.name||"Untitled"}</strong>`;const meta=document.createElement("div");meta.className="small";if(item.meta&&typeof item.meta==="object"){meta.innerHTML=Object.entries(item.meta).map(([k,v])=>`<div><strong>${k}:</strong> ${v}</div>`).join("")}else if(item.meta){meta.textContent=String(item.meta)}else{meta.textContent=""}this.detailsEl.appendChild(img);this.detailsEl.appendChild(name);this.detailsEl.appendChild(meta)}}renderBreadcrumb(){if(!this.breadcrumbEl)return;this.crumbsWrap.innerHTML="";this.stack.forEach((s,idx)=>{const btn=document.createElement("button");btn.type="button";btn.className="crumb";btn.textContent=s.name;btn.addEventListener("click",()=>this.goToCrumb(idx));this.crumbsWrap.appendChild(btn);if(idx<this.stack.length-1){const sep=document.createElement("span");sep.className="sep";sep.textContent="»";this.crumbsWrap.appendChild(sep)}});this.controlsEl.innerHTML="";const hasCreate=typeof this.opts.onCreateFolder==="function";const hasUpload=typeof this.opts.onUpload==="function";if(hasCreate){const createBtn=document.createElement("button");createBtn.type="button";createBtn.className="crumb-action";createBtn.textContent="New Folder";createBtn.addEventListener("click",()=>this.handleCreateFolder());this.controlsEl.appendChild(createBtn)}if(hasUpload){const uploadBtn=document.createElement("button");uploadBtn.type="button";uploadBtn.className="crumb-action";uploadBtn.textContent="Upload";uploadBtn.addEventListener("click",()=>this.handleUpload());this.controlsEl.appendChild(uploadBtn)}}currentFolder(){return this.stack.map(s=>s.name+"/").slice(1).join()}currentFiles(){return this.stack[this.stack.length-1].files}addFiles(files){if(!Array.isArray(files))files=[files];const current=this.currentFiles();current.push(...files);this.render()}getSelectedPaths(){const activeList=this.currentFiles();const basePath=this.currentFolder();const paths=[];Array.from(this.selectedIndices).forEach(i=>{const item=activeList[i];if(item){paths.push(basePath+item.name)}});return paths}async handleCreateFolder(){if(this._creatingFolder)return;this._creatingFolder=true;const current=this.currentFiles();const card=document.createElement("div");card.className="card creating";card.tabIndex=0;const img=document.createElement("img");img.alt="Folder";img.src=this.folderIcon;const meta=document.createElement("div");meta.className="meta";const nameEl=document.createElement("div");nameEl.className="editable-name";nameEl.contentEditable="true";nameEl.spellcheck=false;nameEl.textContent="";meta.appendChild(nameEl);card.appendChild(img);card.appendChild(meta);this.galleryEl.insertBefore(card,this.galleryEl.firstChild);nameEl.focus();try{const sel=window.getSelection();const range=document.createRange();range.selectNodeContents(nameEl);sel.removeAllRanges();sel.addRange(range)}catch(e){}const cleanup=()=>{this._creatingFolder=false;if(card.parentNode)card.parentNode.removeChild(card)};const commit=async()=>{const name=nameEl.textContent.trim();if(!name){cleanup();return}if(typeof this.opts.onCreateFolder==="function"){const res=await this.opts.onCreateFolder(name);if(res){current.splice(0,0,res)}else{cleanup();return}}else{current.splice(0,0,{name:name,children:[]})}this._creatingFolder=false;this.render()};const onKey=e=>{if(e.key==="Enter"){e.preventDefault();nameEl.blur()}else if(e.key==="Escape"){e.preventDefault();cleanup()}};nameEl.addEventListener("keydown",onKey);nameEl.addEventListener("blur",()=>{setTimeout(()=>{if(!this._creatingFolder)return;commit()},0)},{once:true})}async handleUpload(){if(typeof this.opts.onUpload!=="function")return;this._fileInput.value="";this._fileInput.multiple=true;this._fileInput.onchange=async()=>{const files=Array.from(this._fileInput.files||[]);if(files.length===0)return;try{const res=await this.opts.onUpload(files);if(res){const current=this.currentFiles();if(Array.isArray(res))current.push(...res);else current.push(res);this.render()}}catch(err){console.error("onUpload error",err)}};this._fileInput.click()}async handleDropFiles(files){if(!files||files.length===0)return;if(typeof this.opts.onUpload!=="function")return;try{const res=await this.opts.onUpload(files);if(res){const current=this.currentFiles();if(Array.isArray(res))current.push(...res);else current.push(res);this.render()}}catch(err){console.error("onUpload error",err)}}select(i){const activeList=this.currentFiles();if(this.multi){if(this.selectedIndices.has(i)){this.selectedIndices.delete(i)}else{this.selectedIndices.add(i)}}else{if(this.selectedIndices.has(i)){this.selectedIndices.clear()}else{this.selectedIndices.clear();this.selectedIndices.add(i)}}this.updateSelectionUI();if(typeof this.opts.onSelect==="function"){const indices=Array.from(this.selectedIndices).sort((a,b)=>a-b);const items=indices.map(idx=>activeList[idx]);this.opts.onSelect(items,indices)}}enterFolder(i){const f=this.currentFiles()[i];if(!f||!Array.isArray(f.children))return;this.stack.push({name:f.name||"Folder",files:f.children});this.selectedIndices.clear();this.render()}goToCrumb(idx){this.stack=this.stack.slice(0,idx+1);this.selectedIndices.clear();this.render()}}
|
|
1
|
+
// Create a data URL from raw SVG markup
|
|
2
|
+
function svgDataUrlFromSvg(svg) {
|
|
3
|
+
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function escapeText(t) {
|
|
7
|
+
return String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function svgDocumentIcon(label, bg = '#e9eefc', w = 240, h = 310) {
|
|
11
|
+
const safe = escapeText(label || '');
|
|
12
|
+
const fold = 84;
|
|
13
|
+
|
|
14
|
+
// Draw page body leaving out the folded corner area
|
|
15
|
+
const bodyPath = `M0 0 H${w - fold} L${w} ${fold} V${h} H0 Z`;
|
|
16
|
+
// Draw the folded corner as a darker layer on top
|
|
17
|
+
const foldPath = `M${w - fold} 0 v${fold} h${fold} Z`;
|
|
18
|
+
|
|
19
|
+
const svg = `<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'>` +
|
|
20
|
+
`<path d='${bodyPath}' fill='${bg}' stroke='#0b12200a' stroke-linejoin='round'/>` +
|
|
21
|
+
`<path d='${foldPath}' fill='#0000001a' stroke='#0b122005'/>` +
|
|
22
|
+
`<text x='50%' y='58%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${safe}</text>` +
|
|
23
|
+
`</svg>`;
|
|
24
|
+
return svgDataUrlFromSvg(svg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function svgFolderIcon(label, bg = '#ffd7a8', w = 420, h = 320) {
|
|
28
|
+
const safe = escapeText(label || '');
|
|
29
|
+
const tabH = 44;
|
|
30
|
+
const tabW = 120;
|
|
31
|
+
const svg = `<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'>` +
|
|
32
|
+
`<rect x='6' y='34' rx='12' ry='12' width='${w - 12}' height='${h - 40}' fill='${bg}' stroke='#0b12200a'/>` +
|
|
33
|
+
`<rect x='18' y='12' rx='8' ry='8' width='${tabW}' height='${tabH}' fill='${bg}'/>` +
|
|
34
|
+
`<text x='50%' y='62%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${safe}</text>` +
|
|
35
|
+
`</svg>`;
|
|
36
|
+
return svgDataUrlFromSvg(svg);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_ICON_RULES = [
|
|
40
|
+
{ icon: svgDocumentIcon('IMG', '#c7ddff'), typeMatch: t => t.startsWith('image/'), exts: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif'] },
|
|
41
|
+
{ icon: svgDocumentIcon('PDF', '#ffd7d7'), typeMatch: t => t === 'application/pdf', exts: ['.pdf'] },
|
|
42
|
+
{ icon: svgDocumentIcon('AUD', '#e6dcff'), typeMatch: t => t.startsWith('audio/'), exts: ['.mp3', '.wav', '.m4a', '.flac'] },
|
|
43
|
+
{ icon: svgDocumentIcon('VID', '#dff7ff'), typeMatch: t => t.startsWith('video/'), exts: ['.mp4', '.mov', '.webm', '.mkv'] },
|
|
44
|
+
{ icon: svgDocumentIcon('DOC', '#dfe6ff'), typeMatch: t => ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(t), exts: ['.doc', '.docx', '.odt'] },
|
|
45
|
+
{ icon: svgDocumentIcon('XLS', '#e4ffd7'), typeMatch: t => ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(t), exts: ['.xls', '.xlsx', '.ods', '.csv'] },
|
|
46
|
+
{ icon: svgDocumentIcon('ZIP', '#fffed7'), typeMatch: t => t.includes('zip') || t === 'application/x-zip-compressed', exts: ['.zip', '.tar', '.gz'] },
|
|
47
|
+
{ icon: svgDocumentIcon('TXT', '#eaeaea'), typeMatch: t => t.startsWith('text/'), exts: ['.txt', '.md', '.log'] },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} IconRule
|
|
53
|
+
* @property {string} icon - The icon URL
|
|
54
|
+
* @property {function(string):boolean} [typeMatch] - Optional function to match against the MIME type of a file
|
|
55
|
+
* @property {string[]} [exts] - Optional array of file extensions (with dot) to match against the file name
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} FileItem
|
|
60
|
+
* @property {string} name - The display name of the file or folder
|
|
61
|
+
* @property {string} [thumbnail] - Optional data URL for the thumbnail image
|
|
62
|
+
* @property {Object} [meta] - Optional metadata object with additional info (e.g. size, type)
|
|
63
|
+
* @property {FileItem[]} [children] - If present, indicates this item is a folder containing these child items
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} FileAction
|
|
68
|
+
* @property {string} [icon] - Optional icon URL
|
|
69
|
+
* @property {string} [label] - Label for the action
|
|
70
|
+
* @property {function(FileItem):void} action - Callback invoked when the action is selected.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} BrowseJSOptions
|
|
75
|
+
* @property {string} [rootName] - The display name for the root folder (default: "Root")
|
|
76
|
+
* @property {function(FileItem, number):void} [onSelect] - Callback when a file is selected, receives the file item and its index
|
|
77
|
+
* @property {string} [detailsText] - Default text to show in the details pane when no selection is made (default: "Select a file")
|
|
78
|
+
* @property {Object} [icons] - Custom icons configuration
|
|
79
|
+
* @property {IconRule[]} [icons.rules] - Custom rules for determining icons based on MIME type or file extension
|
|
80
|
+
* @property {string} [icons.folder] - Custom icon URL for folders
|
|
81
|
+
* @property {string} [icons.default] - Custom icon URL for files that don't match any rule
|
|
82
|
+
* @property {boolean} [multiSelect] - If true, allows selecting multiple files (default: false)
|
|
83
|
+
* @property {function(string):?(FileItem)} [onCreateFolder] - Optional callback invoked when creating a new folder. If it returns a FileItem, it will be added to the current folder.
|
|
84
|
+
* @property {function(File[]):?(FileItem|FileItem[])} [onUpload] - Optional callback invoked when files are uploaded. If it returns FileItem(s), they'll be added to the current folder.
|
|
85
|
+
* @property {function(FileItem):FileAction[]} [onContext] - Optional callback invoked when the context menu is activated for a file. If it returns FileActions, they'll be displayed in the context menu.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
export class BrowseJS {
|
|
89
|
+
/**
|
|
90
|
+
* @param {string|Element} container - The the container element to render the gallery into. Can be a DOM element, ID, or selector string.
|
|
91
|
+
* @param {FileItem[]} files - An array of file items to display in the root folder
|
|
92
|
+
* @param {BrowseJSOptions} opts - Configuration options (see BrowseJSOptions typedef for details)
|
|
93
|
+
*/
|
|
94
|
+
constructor(container, files = [], opts = {}) {
|
|
95
|
+
if (container instanceof Element) { this.container = container; }
|
|
96
|
+
else {
|
|
97
|
+
const containerElement = document.getElementById(container) || document.querySelector(container);
|
|
98
|
+
if (!containerElement) throw new Error('Container not found: ' + container);
|
|
99
|
+
this.container = containerElement;
|
|
100
|
+
}
|
|
101
|
+
this.opts = opts;
|
|
102
|
+
this.iconRules = opts.icons && opts.icons.rules ? opts.icons.rules : DEFAULT_ICON_RULES;
|
|
103
|
+
this.folderIcon = opts.icons && opts.icons.folder ? opts.icons.folder : svgFolderIcon('');
|
|
104
|
+
this.defaultIcon = opts.icons && opts.icons.default ? opts.icons.default : svgDocumentIcon('?', '#e9eefc');
|
|
105
|
+
this.multi = Boolean(opts.multiSelect);
|
|
106
|
+
this.selectedIndices = new Set();
|
|
107
|
+
this.stack = [{ name: opts.rootName || 'Root', files }];
|
|
108
|
+
|
|
109
|
+
// create breadcrumb and details elements and insert around the gallery container
|
|
110
|
+
this.breadcrumbEl = document.createElement('nav');
|
|
111
|
+
this.breadcrumbEl.className = 'breadcrumb';
|
|
112
|
+
this.breadcrumbEl.setAttribute('aria-label', 'Breadcrumb');
|
|
113
|
+
|
|
114
|
+
// crumbs wrapper and controls container
|
|
115
|
+
this.crumbsWrap = document.createElement('div');
|
|
116
|
+
this.crumbsWrap.className = 'crumbs-wrap';
|
|
117
|
+
this.controlsEl = document.createElement('div');
|
|
118
|
+
this.controlsEl.className = 'crumb-controls';
|
|
119
|
+
this.breadcrumbEl.appendChild(this.crumbsWrap);
|
|
120
|
+
this.breadcrumbEl.appendChild(this.controlsEl);
|
|
121
|
+
|
|
122
|
+
this.detailsEl = document.createElement('aside');
|
|
123
|
+
this.detailsEl.className = 'details';
|
|
124
|
+
this.detailsEl.setAttribute('aria-live', 'polite');
|
|
125
|
+
this.detailsEl.textContent = opts.detailsText || 'Select a file';
|
|
126
|
+
|
|
127
|
+
// Insert breadcrumb, gallery grid and details inside the gallery container
|
|
128
|
+
this.container.innerHTML = '';
|
|
129
|
+
this.galleryEl = document.createElement('div');
|
|
130
|
+
this.galleryEl.className = 'gallery-grid';
|
|
131
|
+
this.container.appendChild(this.breadcrumbEl);
|
|
132
|
+
this.container.appendChild(this.galleryEl);
|
|
133
|
+
this.container.appendChild(this.detailsEl);
|
|
134
|
+
|
|
135
|
+
this._fileInput = document.createElement('input');
|
|
136
|
+
this._fileInput.type = 'file';
|
|
137
|
+
this._fileInput.style.display = 'none';
|
|
138
|
+
this.container.appendChild(this._fileInput);
|
|
139
|
+
|
|
140
|
+
// bind events to the inner gallery grid
|
|
141
|
+
this.container.classList.add('browsejs');
|
|
142
|
+
this.galleryEl.tabIndex = 0;
|
|
143
|
+
|
|
144
|
+
// drag & drop support: highlight container on dragover, accept drops
|
|
145
|
+
document.addEventListener('dragover', (e) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
this.container.classList.add('dragover');
|
|
148
|
+
});
|
|
149
|
+
document.addEventListener('dragleave', (e) => {
|
|
150
|
+
this.container.classList.remove('dragover');
|
|
151
|
+
});
|
|
152
|
+
this.container.addEventListener('drop', (e) => {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
this.container.classList.remove('dragover');
|
|
155
|
+
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []);
|
|
156
|
+
if (files.length === 0) return;
|
|
157
|
+
this.handleDropFiles(files);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
this.render();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getIconForItem(item) {
|
|
164
|
+
if (item.children) return this.folderIcon;
|
|
165
|
+
const type = item.meta && item.meta.type ? String(item.meta.type).toLowerCase() : '';
|
|
166
|
+
const name = item.name ? String(item.name).toLowerCase() : '';
|
|
167
|
+
|
|
168
|
+
if (type) {
|
|
169
|
+
for (const r of this.iconRules) {
|
|
170
|
+
if (r.typeMatch && r.typeMatch(type)) return r.icon;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const r of this.iconRules) {
|
|
174
|
+
if (r.exts) {
|
|
175
|
+
for (const e of r.exts) {
|
|
176
|
+
if (name.endsWith(e)) return r.icon;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return this.defaultIcon;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
render() {
|
|
184
|
+
this.galleryEl.innerHTML = '';
|
|
185
|
+
const list = this.currentFiles();
|
|
186
|
+
list.forEach((f, i) => {
|
|
187
|
+
const card = document.createElement('button');
|
|
188
|
+
card.className = 'card';
|
|
189
|
+
card.type = 'button';
|
|
190
|
+
card.dataset.index = i;
|
|
191
|
+
card.title = f.name;
|
|
192
|
+
|
|
193
|
+
const img = document.createElement('img');
|
|
194
|
+
img.alt = f.name || '';
|
|
195
|
+
img.src = f.thumbnail || this.getIconForItem(f);
|
|
196
|
+
|
|
197
|
+
const meta = document.createElement('div');
|
|
198
|
+
meta.className = 'meta';
|
|
199
|
+
meta.textContent = f.name;
|
|
200
|
+
|
|
201
|
+
card.appendChild(img);
|
|
202
|
+
card.appendChild(meta);
|
|
203
|
+
|
|
204
|
+
if (this.opts.onContext) {
|
|
205
|
+
const menu = document.createElement('div');
|
|
206
|
+
menu.className = 'ctx-menu';
|
|
207
|
+
menu.textContent = '…';
|
|
208
|
+
menu.addEventListener('click', (e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
const prevSel = this.galleryEl.getElementsByClassName('ctx-menu selected').item(0);
|
|
211
|
+
if (prevSel) {
|
|
212
|
+
prevSel.removeChild(prevSel.getElementsByClassName('ctx-popup').item(0));
|
|
213
|
+
prevSel.classList.remove('selected');
|
|
214
|
+
}
|
|
215
|
+
menu.classList.add('selected');
|
|
216
|
+
const ctxItems = this.opts.onContext(f);
|
|
217
|
+
|
|
218
|
+
const ctxPop = document.createElement('div');
|
|
219
|
+
ctxPop.className = 'ctx-popup';
|
|
220
|
+
|
|
221
|
+
// Avoid menu clipping
|
|
222
|
+
const gridWidth = window.getComputedStyle(this.galleryEl).getPropertyValue('grid-template-columns').split(' ').length;
|
|
223
|
+
if (i % gridWidth === 0 || gridWidth - (i % gridWidth) > 2) {
|
|
224
|
+
ctxPop.classList.add('ctx-right');
|
|
225
|
+
} else {
|
|
226
|
+
ctxPop.classList.add('ctx-left');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ctxItems.forEach((item) => {
|
|
230
|
+
const ctxEntry = document.createElement('div');
|
|
231
|
+
ctxEntry.className = 'ctx-item';
|
|
232
|
+
if (item.icon) {
|
|
233
|
+
const ctxIcon = document.createElement('img');
|
|
234
|
+
ctxIcon.src = item.icon;
|
|
235
|
+
ctxEntry.appendChild(ctxIcon);
|
|
236
|
+
}
|
|
237
|
+
if (item.label) {
|
|
238
|
+
ctxEntry.appendChild(document.createTextNode(item.label));
|
|
239
|
+
}
|
|
240
|
+
ctxEntry.addEventListener('click', (e) => {
|
|
241
|
+
item.action(f);
|
|
242
|
+
console.log('entry', e, menu, ctxPop);
|
|
243
|
+
menu.removeChild(ctxPop);
|
|
244
|
+
menu.classList.remove('selected');
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
}, {once: true});
|
|
247
|
+
ctxPop.appendChild(ctxEntry);
|
|
248
|
+
});
|
|
249
|
+
this.container.addEventListener('click', () => {
|
|
250
|
+
// May have already been triggered by another handler
|
|
251
|
+
// above.
|
|
252
|
+
try {
|
|
253
|
+
menu.removeChild(ctxPop);
|
|
254
|
+
menu.classList.remove('selected');
|
|
255
|
+
}
|
|
256
|
+
catch {}
|
|
257
|
+
}, {once: true});
|
|
258
|
+
menu.appendChild(ctxPop);
|
|
259
|
+
});
|
|
260
|
+
card.appendChild(menu);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (f.children && Array.isArray(f.children)) {
|
|
264
|
+
card.addEventListener('click', () => this.enterFolder(i));
|
|
265
|
+
card.addEventListener('keyup', (e) => { if (e.key === 'Enter') this.enterFolder(i); });
|
|
266
|
+
} else {
|
|
267
|
+
card.addEventListener('click', () => this.select(i));
|
|
268
|
+
card.addEventListener('keyup', (e) => { if (e.key === 'Enter') this.select(i); });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.galleryEl.appendChild(card);
|
|
272
|
+
});
|
|
273
|
+
this.updateSelectionUI();
|
|
274
|
+
this.renderBreadcrumb();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateSelectionUI() {
|
|
278
|
+
const cards = this.galleryEl.querySelectorAll('.card');
|
|
279
|
+
cards.forEach(c => {
|
|
280
|
+
const idx = Number(c.dataset.index);
|
|
281
|
+
if (this.selectedIndices.has(idx)) c.classList.add('selected');
|
|
282
|
+
else c.classList.remove('selected');
|
|
283
|
+
});
|
|
284
|
+
this.renderDetails();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
renderDetails() {
|
|
288
|
+
if (!this.detailsEl) return;
|
|
289
|
+
const activeList = this.currentFiles();
|
|
290
|
+
if (this.selectedIndices.size === 0) { this.detailsEl.innerHTML = '<p class="small">No selection</p>'; }
|
|
291
|
+
// If multiple selected, show a simple list and count
|
|
292
|
+
else if (this.selectedIndices.size > 1) {
|
|
293
|
+
const indices = Array.from(this.selectedIndices).sort();
|
|
294
|
+
this.detailsEl.innerHTML = '';
|
|
295
|
+
const header = document.createElement('div');
|
|
296
|
+
header.innerHTML = `<strong>${indices.length} item${indices.length > 1 ? 's' : ''} selected</strong>`;
|
|
297
|
+
this.detailsEl.appendChild(header);
|
|
298
|
+
const list = document.createElement('div');
|
|
299
|
+
list.className = 'small';
|
|
300
|
+
list.innerHTML = indices.map(i => `<div>${escapeText((activeList[i] && activeList[i].name) || 'Untitled')}</div>`).join('');
|
|
301
|
+
this.detailsEl.appendChild(list);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const index = Array.from(this.selectedIndices)[0];
|
|
305
|
+
const item = activeList[index];
|
|
306
|
+
if (!item) { this.detailsEl.innerHTML = '<p class="small">No selection</p>'; return; }
|
|
307
|
+
this.detailsEl.innerHTML = '';
|
|
308
|
+
const img = document.createElement('img');
|
|
309
|
+
img.className = 'preview';
|
|
310
|
+
img.src = item.thumbnail || this.galleryEl.querySelector(`.card[data-index="${index}"] img`)?.src || this.getIconForItem(item);
|
|
311
|
+
img.alt = item.name || 'preview';
|
|
312
|
+
const name = document.createElement('div');
|
|
313
|
+
name.innerHTML = `<strong>${item.name || 'Untitled'}</strong>`;
|
|
314
|
+
const meta = document.createElement('div');
|
|
315
|
+
meta.className = 'small';
|
|
316
|
+
|
|
317
|
+
if (item.meta && typeof item.meta === 'object') {
|
|
318
|
+
meta.innerHTML = Object.entries(item.meta).map(([k, v]) => `<div><strong>${k}:</strong> ${v}</div>`).join('');
|
|
319
|
+
} else if (item.meta) {
|
|
320
|
+
meta.textContent = String(item.meta);
|
|
321
|
+
} else {
|
|
322
|
+
meta.textContent = '';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.detailsEl.appendChild(img);
|
|
326
|
+
this.detailsEl.appendChild(name);
|
|
327
|
+
this.detailsEl.appendChild(meta);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
renderBreadcrumb() {
|
|
332
|
+
if (!this.breadcrumbEl) return;
|
|
333
|
+
this.crumbsWrap.innerHTML = '';
|
|
334
|
+
this.stack.forEach((s, idx) => {
|
|
335
|
+
const btn = document.createElement('button');
|
|
336
|
+
btn.type = 'button';
|
|
337
|
+
btn.className = 'crumb';
|
|
338
|
+
btn.textContent = s.name;
|
|
339
|
+
btn.addEventListener('click', () => this.goToCrumb(idx));
|
|
340
|
+
this.crumbsWrap.appendChild(btn);
|
|
341
|
+
if (idx < this.stack.length - 1) {
|
|
342
|
+
const sep = document.createElement('span'); sep.className = 'sep'; sep.textContent = '»';
|
|
343
|
+
this.crumbsWrap.appendChild(sep);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
this.controlsEl.innerHTML = '';
|
|
348
|
+
const hasCreate = typeof this.opts.onCreateFolder === 'function';
|
|
349
|
+
const hasUpload = typeof this.opts.onUpload === 'function';
|
|
350
|
+
if (hasCreate) {
|
|
351
|
+
const createBtn = document.createElement('button');
|
|
352
|
+
createBtn.type = 'button';
|
|
353
|
+
createBtn.className = 'crumb-action';
|
|
354
|
+
createBtn.textContent = 'New Folder';
|
|
355
|
+
createBtn.addEventListener('click', () => this.handleCreateFolder());
|
|
356
|
+
this.controlsEl.appendChild(createBtn);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (hasUpload) {
|
|
360
|
+
const uploadBtn = document.createElement('button');
|
|
361
|
+
uploadBtn.type = 'button';
|
|
362
|
+
uploadBtn.className = 'crumb-action';
|
|
363
|
+
uploadBtn.textContent = 'Upload';
|
|
364
|
+
uploadBtn.addEventListener('click', () => this.handleUpload());
|
|
365
|
+
this.controlsEl.appendChild(uploadBtn);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
currentFolder() {return this.stack.map(s => s.name + '/').slice(1).join();}
|
|
370
|
+
|
|
371
|
+
currentFiles() { return this.stack[this.stack.length - 1].files; }
|
|
372
|
+
|
|
373
|
+
addFiles(files) {
|
|
374
|
+
if (!Array.isArray(files)) files = [files];
|
|
375
|
+
const current = this.currentFiles();
|
|
376
|
+
current.push(...files);
|
|
377
|
+
this.render();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
getSelectedPaths() {
|
|
381
|
+
const activeList = this.currentFiles();
|
|
382
|
+
const basePath = this.currentFolder();
|
|
383
|
+
const paths = [];
|
|
384
|
+
Array.from(this.selectedIndices).forEach(i => {
|
|
385
|
+
const item = activeList[i];
|
|
386
|
+
if (item) {
|
|
387
|
+
paths.push(basePath + item.name);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return paths;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async handleCreateFolder() {
|
|
394
|
+
if (this._creatingFolder) return;
|
|
395
|
+
this._creatingFolder = true;
|
|
396
|
+
|
|
397
|
+
const current = this.currentFiles();
|
|
398
|
+
|
|
399
|
+
const card = document.createElement('div');
|
|
400
|
+
card.className = 'card creating';
|
|
401
|
+
card.tabIndex = 0;
|
|
402
|
+
|
|
403
|
+
const img = document.createElement('img');
|
|
404
|
+
img.alt = 'Folder';
|
|
405
|
+
img.src = this.folderIcon;
|
|
406
|
+
|
|
407
|
+
const meta = document.createElement('div');
|
|
408
|
+
meta.className = 'meta';
|
|
409
|
+
|
|
410
|
+
const nameEl = document.createElement('div');
|
|
411
|
+
nameEl.className = 'editable-name';
|
|
412
|
+
nameEl.contentEditable = 'true';
|
|
413
|
+
nameEl.spellcheck = false;
|
|
414
|
+
nameEl.textContent = '';
|
|
415
|
+
|
|
416
|
+
meta.appendChild(nameEl);
|
|
417
|
+
card.appendChild(img);
|
|
418
|
+
card.appendChild(meta);
|
|
419
|
+
|
|
420
|
+
this.galleryEl.insertBefore(card, this.galleryEl.firstChild);
|
|
421
|
+
|
|
422
|
+
// focus and select text
|
|
423
|
+
nameEl.focus();
|
|
424
|
+
try {
|
|
425
|
+
const sel = window.getSelection();
|
|
426
|
+
const range = document.createRange();
|
|
427
|
+
range.selectNodeContents(nameEl);
|
|
428
|
+
sel.removeAllRanges();
|
|
429
|
+
sel.addRange(range);
|
|
430
|
+
} catch (e) { /* ignore selection errors */ }
|
|
431
|
+
|
|
432
|
+
const cleanup = () => {
|
|
433
|
+
this._creatingFolder = false;
|
|
434
|
+
if (card.parentNode) card.parentNode.removeChild(card);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const commit = async () => {
|
|
438
|
+
const name = nameEl.textContent.trim();
|
|
439
|
+
if (!name) { cleanup(); return; }
|
|
440
|
+
if (typeof this.opts.onCreateFolder === 'function') {
|
|
441
|
+
const res = await this.opts.onCreateFolder(name);
|
|
442
|
+
if (res) { current.splice(0, 0, res); }
|
|
443
|
+
else { cleanup(); return; }
|
|
444
|
+
} else {
|
|
445
|
+
current.splice(0, 0, { name, children: [] });
|
|
446
|
+
}
|
|
447
|
+
this._creatingFolder = false;
|
|
448
|
+
this.render();
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const onKey = (e) => {
|
|
452
|
+
if (e.key === 'Enter') {
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
nameEl.blur();
|
|
455
|
+
} else if (e.key === 'Escape') {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
cleanup();
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
nameEl.addEventListener('keydown', onKey);
|
|
462
|
+
nameEl.addEventListener('blur', () => {
|
|
463
|
+
// commit on blur (allow Enter's blur to fire first)
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
if (!this._creatingFolder) return;
|
|
466
|
+
commit();
|
|
467
|
+
}, 0);
|
|
468
|
+
}, { once: true });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async handleUpload() {
|
|
472
|
+
if (typeof this.opts.onUpload !== 'function') return;
|
|
473
|
+
this._fileInput.value = '';
|
|
474
|
+
this._fileInput.multiple = true;
|
|
475
|
+
this._fileInput.onchange = async () => {
|
|
476
|
+
const files = Array.from(this._fileInput.files || []);
|
|
477
|
+
if (files.length === 0) return;
|
|
478
|
+
try {
|
|
479
|
+
const res = await this.opts.onUpload(files);
|
|
480
|
+
if (res) {
|
|
481
|
+
const current = this.currentFiles();
|
|
482
|
+
if (Array.isArray(res)) current.push(...res); else current.push(res);
|
|
483
|
+
this.render();
|
|
484
|
+
}
|
|
485
|
+
} catch (err) { console.error('onUpload error', err); }
|
|
486
|
+
};
|
|
487
|
+
this._fileInput.click();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async handleDropFiles(files) {
|
|
491
|
+
if (!files || files.length === 0) return;
|
|
492
|
+
if (typeof this.opts.onUpload !== 'function') return;
|
|
493
|
+
try {
|
|
494
|
+
const res = await this.opts.onUpload(files);
|
|
495
|
+
if (res) {
|
|
496
|
+
const current = this.currentFiles();
|
|
497
|
+
if (Array.isArray(res)) current.push(...res); else current.push(res);
|
|
498
|
+
this.render();
|
|
499
|
+
}
|
|
500
|
+
} catch (err) { console.error('onUpload error', err); }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
select(i) {
|
|
504
|
+
const activeList = this.currentFiles();
|
|
505
|
+
if (this.multi) {
|
|
506
|
+
if (this.selectedIndices.has(i)) {
|
|
507
|
+
this.selectedIndices.delete(i);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
this.selectedIndices.add(i);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
if (this.selectedIndices.has(i)) {
|
|
514
|
+
this.selectedIndices.clear();
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
this.selectedIndices.clear();
|
|
518
|
+
this.selectedIndices.add(i);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
this.updateSelectionUI();
|
|
522
|
+
if (typeof this.opts.onSelect === 'function') {
|
|
523
|
+
const indices = Array.from(this.selectedIndices).sort((a, b) => a - b);
|
|
524
|
+
const items = indices.map(idx => activeList[idx]);
|
|
525
|
+
this.opts.onSelect(items, indices);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
enterFolder(i) {
|
|
530
|
+
const f = this.currentFiles()[i];
|
|
531
|
+
if (!f || !Array.isArray(f.children)) return;
|
|
532
|
+
this.stack.push({ name: f.name || 'Folder', files: f.children });
|
|
533
|
+
this.selectedIndices.clear();
|
|
534
|
+
this.render();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
goToCrumb(idx) {
|
|
538
|
+
this.stack = this.stack.slice(0, idx + 1);
|
|
539
|
+
this.selectedIndices.clear();
|
|
540
|
+
this.render();
|
|
541
|
+
}
|
|
542
|
+
}
|
package/dist/style.css
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
}
|
|
13
13
|
.browsejs .card {
|
|
14
14
|
background: transparent;
|
|
15
|
-
border:
|
|
16
|
-
|
|
15
|
+
border: 2px solid transparent;
|
|
16
|
+
border-radius: 8px;
|
|
17
17
|
cursor: pointer;
|
|
18
18
|
display: flex;
|
|
19
19
|
flex-direction: column;
|
|
20
20
|
align-items: stretch;
|
|
21
|
+
position: relative;
|
|
21
22
|
}
|
|
22
23
|
.browsejs .card img {
|
|
23
24
|
width: 100%;
|
|
@@ -36,6 +37,59 @@
|
|
|
36
37
|
.browsejs .card .editable-name {
|
|
37
38
|
padding: 3px;
|
|
38
39
|
}
|
|
40
|
+
.browsejs .card .ctx-menu {
|
|
41
|
+
position: absolute;
|
|
42
|
+
right: 2px;
|
|
43
|
+
top: 2px;
|
|
44
|
+
aspect-ratio: 1;
|
|
45
|
+
min-width: 20px;
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
background-color: var(--selection-color);
|
|
48
|
+
color: #fff;
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
.browsejs .card .ctx-menu .ctx-popup {
|
|
52
|
+
position: absolute;
|
|
53
|
+
z-index: 1;
|
|
54
|
+
top: 50%;
|
|
55
|
+
background-color: #fff;
|
|
56
|
+
color: #203636;
|
|
57
|
+
border-radius: 5px;
|
|
58
|
+
border: 1px solid #203636;
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
min-width: 100px;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
}
|
|
64
|
+
.browsejs .card .ctx-menu .ctx-popup.ctx-right {
|
|
65
|
+
left: 50%;
|
|
66
|
+
}
|
|
67
|
+
.browsejs .card .ctx-menu .ctx-popup.ctx-left {
|
|
68
|
+
right: 50%;
|
|
69
|
+
}
|
|
70
|
+
.browsejs .card .ctx-menu .ctx-popup .ctx-item {
|
|
71
|
+
display: flex;
|
|
72
|
+
min-width: 100%;
|
|
73
|
+
width: max-content;
|
|
74
|
+
max-width: 250px;
|
|
75
|
+
padding: 3px;
|
|
76
|
+
border-width: 1px 0;
|
|
77
|
+
border-style: solid;
|
|
78
|
+
border-color: transparent;
|
|
79
|
+
}
|
|
80
|
+
.browsejs .card .ctx-menu .ctx-popup .ctx-item:hover {
|
|
81
|
+
background-color: #e1e9f4;
|
|
82
|
+
}
|
|
83
|
+
.browsejs .card .ctx-menu .ctx-popup .ctx-item:hover:not(:first-child) {
|
|
84
|
+
border-top-color: #20363672;
|
|
85
|
+
}
|
|
86
|
+
.browsejs .card .ctx-menu .ctx-popup .ctx-item:hover:not(:last-child) {
|
|
87
|
+
border-bottom-color: #20363672;
|
|
88
|
+
}
|
|
89
|
+
.browsejs .card:hover .ctx-menu,
|
|
90
|
+
.browsejs .card .ctx-menu.selected {
|
|
91
|
+
display: block;
|
|
92
|
+
}
|
|
39
93
|
.browsejs .card.selected {
|
|
40
94
|
border-color: var(--selection-color);
|
|
41
95
|
box-shadow: 0 6px 18px #2463ff1f;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browse.js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A lightweight yet versatile file browser written in vanilla JS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"file",
|
|
@@ -21,12 +21,10 @@
|
|
|
21
21
|
"type": "commonjs",
|
|
22
22
|
"main": "dist/browse.js",
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "tsc &&
|
|
25
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
24
|
+
"build": "tsc && cp src/browse.js dist/browse.js && lessc src/style.less dist/style.css"
|
|
26
25
|
},
|
|
27
26
|
"devDependencies": {
|
|
28
27
|
"less": "^4.5.1",
|
|
29
|
-
"terser": "^5.46.0",
|
|
30
28
|
"typescript": "^5.9.3"
|
|
31
29
|
}
|
|
32
30
|
}
|
package/src/browse.js
CHANGED
|
@@ -63,6 +63,13 @@ const DEFAULT_ICON_RULES = [
|
|
|
63
63
|
* @property {FileItem[]} [children] - If present, indicates this item is a folder containing these child items
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} FileAction
|
|
68
|
+
* @property {string} [icon] - Optional icon URL
|
|
69
|
+
* @property {string} [label] - Label for the action
|
|
70
|
+
* @property {function(FileItem):void} action - Callback invoked when the action is selected.
|
|
71
|
+
*/
|
|
72
|
+
|
|
66
73
|
/**
|
|
67
74
|
* @typedef {Object} BrowseJSOptions
|
|
68
75
|
* @property {string} [rootName] - The display name for the root folder (default: "Root")
|
|
@@ -75,6 +82,7 @@ const DEFAULT_ICON_RULES = [
|
|
|
75
82
|
* @property {boolean} [multiSelect] - If true, allows selecting multiple files (default: false)
|
|
76
83
|
* @property {function(string):?(FileItem)} [onCreateFolder] - Optional callback invoked when creating a new folder. If it returns a FileItem, it will be added to the current folder.
|
|
77
84
|
* @property {function(File[]):?(FileItem|FileItem[])} [onUpload] - Optional callback invoked when files are uploaded. If it returns FileItem(s), they'll be added to the current folder.
|
|
85
|
+
* @property {function(FileItem):FileAction[]} [onContext] - Optional callback invoked when the context menu is activated for a file. If it returns FileActions, they'll be displayed in the context menu.
|
|
78
86
|
*/
|
|
79
87
|
|
|
80
88
|
export class BrowseJS {
|
|
@@ -193,6 +201,65 @@ export class BrowseJS {
|
|
|
193
201
|
card.appendChild(img);
|
|
194
202
|
card.appendChild(meta);
|
|
195
203
|
|
|
204
|
+
if (this.opts.onContext) {
|
|
205
|
+
const menu = document.createElement('div');
|
|
206
|
+
menu.className = 'ctx-menu';
|
|
207
|
+
menu.textContent = '…';
|
|
208
|
+
menu.addEventListener('click', (e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
const prevSel = this.galleryEl.getElementsByClassName('ctx-menu selected').item(0);
|
|
211
|
+
if (prevSel) {
|
|
212
|
+
prevSel.removeChild(prevSel.getElementsByClassName('ctx-popup').item(0));
|
|
213
|
+
prevSel.classList.remove('selected');
|
|
214
|
+
}
|
|
215
|
+
menu.classList.add('selected');
|
|
216
|
+
const ctxItems = this.opts.onContext(f);
|
|
217
|
+
|
|
218
|
+
const ctxPop = document.createElement('div');
|
|
219
|
+
ctxPop.className = 'ctx-popup';
|
|
220
|
+
|
|
221
|
+
// Avoid menu clipping
|
|
222
|
+
const gridWidth = window.getComputedStyle(this.galleryEl).getPropertyValue('grid-template-columns').split(' ').length;
|
|
223
|
+
if (i % gridWidth === 0 || gridWidth - (i % gridWidth) > 2) {
|
|
224
|
+
ctxPop.classList.add('ctx-right');
|
|
225
|
+
} else {
|
|
226
|
+
ctxPop.classList.add('ctx-left');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ctxItems.forEach((item) => {
|
|
230
|
+
const ctxEntry = document.createElement('div');
|
|
231
|
+
ctxEntry.className = 'ctx-item';
|
|
232
|
+
if (item.icon) {
|
|
233
|
+
const ctxIcon = document.createElement('img');
|
|
234
|
+
ctxIcon.src = item.icon;
|
|
235
|
+
ctxEntry.appendChild(ctxIcon);
|
|
236
|
+
}
|
|
237
|
+
if (item.label) {
|
|
238
|
+
ctxEntry.appendChild(document.createTextNode(item.label));
|
|
239
|
+
}
|
|
240
|
+
ctxEntry.addEventListener('click', (e) => {
|
|
241
|
+
item.action(f);
|
|
242
|
+
console.log('entry', e, menu, ctxPop);
|
|
243
|
+
menu.removeChild(ctxPop);
|
|
244
|
+
menu.classList.remove('selected');
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
}, {once: true});
|
|
247
|
+
ctxPop.appendChild(ctxEntry);
|
|
248
|
+
});
|
|
249
|
+
this.container.addEventListener('click', () => {
|
|
250
|
+
// May have already been triggered by another handler
|
|
251
|
+
// above.
|
|
252
|
+
try {
|
|
253
|
+
menu.removeChild(ctxPop);
|
|
254
|
+
menu.classList.remove('selected');
|
|
255
|
+
}
|
|
256
|
+
catch {}
|
|
257
|
+
}, {once: true});
|
|
258
|
+
menu.appendChild(ctxPop);
|
|
259
|
+
});
|
|
260
|
+
card.appendChild(menu);
|
|
261
|
+
}
|
|
262
|
+
|
|
196
263
|
if (f.children && Array.isArray(f.children)) {
|
|
197
264
|
card.addEventListener('click', () => this.enterFolder(i));
|
|
198
265
|
card.addEventListener('keyup', (e) => { if (e.key === 'Enter') this.enterFolder(i); });
|
package/src/style.less
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
.card {
|
|
15
15
|
background: transparent;
|
|
16
|
-
border:
|
|
17
|
-
|
|
16
|
+
border: 2px solid transparent;
|
|
17
|
+
border-radius: 8px;
|
|
18
18
|
cursor: pointer;
|
|
19
19
|
display: flex;
|
|
20
20
|
flex-direction: column;
|
|
21
21
|
align-items: stretch;
|
|
22
|
+
position: relative;
|
|
22
23
|
|
|
23
24
|
img {
|
|
24
25
|
width: 100%;
|
|
@@ -39,6 +40,61 @@
|
|
|
39
40
|
padding: 3px;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
.ctx-menu {
|
|
44
|
+
position: absolute;
|
|
45
|
+
right: 2px;
|
|
46
|
+
top: 2px;
|
|
47
|
+
aspect-ratio: 1;
|
|
48
|
+
min-width: 20px;
|
|
49
|
+
border-radius: 50%;
|
|
50
|
+
background-color: var(--selection-color);
|
|
51
|
+
color: #fff;
|
|
52
|
+
display: none;
|
|
53
|
+
.ctx-popup {
|
|
54
|
+
position: absolute;
|
|
55
|
+
z-index: 1;
|
|
56
|
+
top: 50%;
|
|
57
|
+
background-color: #fff;
|
|
58
|
+
color: #203636;
|
|
59
|
+
border-radius: 5px;
|
|
60
|
+
border: 1px solid #203636;
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
min-width: 100px;
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
|
|
66
|
+
&.ctx-right {
|
|
67
|
+
left: 50%;
|
|
68
|
+
}
|
|
69
|
+
&.ctx-left {
|
|
70
|
+
right: 50%;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ctx-item {
|
|
74
|
+
display: flex;
|
|
75
|
+
min-width: 100%;
|
|
76
|
+
width: max-content;
|
|
77
|
+
max-width: 250px;
|
|
78
|
+
padding: 3px;
|
|
79
|
+
border-width: 1px 0;
|
|
80
|
+
border-style: solid;
|
|
81
|
+
border-color: transparent;
|
|
82
|
+
&:hover {
|
|
83
|
+
background-color: #e1e9f4;
|
|
84
|
+
&:not(:first-child) {
|
|
85
|
+
border-top-color: #20363672;
|
|
86
|
+
}
|
|
87
|
+
&:not(:last-child) {
|
|
88
|
+
border-bottom-color: #20363672;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
&:hover .ctx-menu, .ctx-menu.selected {
|
|
95
|
+
display: block;
|
|
96
|
+
}
|
|
97
|
+
|
|
42
98
|
&.selected {
|
|
43
99
|
border-color: var(--selection-color);
|
|
44
100
|
box-shadow: 0 6px 18px #2463ff1f;
|
package/dist/browse.min.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
function svgDataUrlFromSvg(svg){return"data:image/svg+xml;utf8,"+encodeURIComponent(svg)}function escapeText(t){return String(t).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}function svgDocumentIcon(label,bg="#e9eefc",w=240,h=310){return svgDataUrlFromSvg(`<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'><path d='${`M0 0 H${w-84} L${w} 84 V${h} H0 Z`}' fill='${bg}' stroke='#0b12200a' stroke-linejoin='round'/><path d='${`M${w-84} 0 v84 h84 Z`}' fill='#0000001a' stroke='#0b122005'/><text x='50%' y='58%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${escapeText(label||"")}</text></svg>`)}function svgFolderIcon(label,bg="#ffd7a8",w=420,h=320){return svgDataUrlFromSvg(`<?xml version='1.0' encoding='utf-8'?><svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'><rect x='6' y='34' rx='12' ry='12' width='${w-12}' height='${h-40}' fill='${bg}' stroke='#0b12200a'/><rect x='18' y='12' rx='8' ry='8' width='120' height='44' fill='${bg}'/><text x='50%' y='62%' dominant-baseline='middle' text-anchor='middle' font-family='Inter,Arial,Helvetica,sans-serif' font-size='36' fill='#0b1220'>${escapeText(label||"")}</text></svg>`)}const DEFAULT_ICON_RULES=[{icon:svgDocumentIcon("IMG","#c7ddff"),typeMatch:t=>t.startsWith("image/"),exts:[".png",".jpg",".jpeg",".gif",".webp",".bmp",".svg",".avif"]},{icon:svgDocumentIcon("PDF","#ffd7d7"),typeMatch:t=>"application/pdf"===t,exts:[".pdf"]},{icon:svgDocumentIcon("AUD","#e6dcff"),typeMatch:t=>t.startsWith("audio/"),exts:[".mp3",".wav",".m4a",".flac"]},{icon:svgDocumentIcon("VID","#dff7ff"),typeMatch:t=>t.startsWith("video/"),exts:[".mp4",".mov",".webm",".mkv"]},{icon:svgDocumentIcon("DOC","#dfe6ff"),typeMatch:t=>["application/msword","application/vnd.openxmlformats-officedocument.wordprocessingml.document"].includes(t),exts:[".doc",".docx",".odt"]},{icon:svgDocumentIcon("XLS","#e4ffd7"),typeMatch:t=>["text/csv","application/vnd.ms-excel","application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(t),exts:[".xls",".xlsx",".ods",".csv"]},{icon:svgDocumentIcon("ZIP","#fffed7"),typeMatch:t=>t.includes("zip")||"application/x-zip-compressed"===t,exts:[".zip",".tar",".gz"]},{icon:svgDocumentIcon("TXT","#eaeaea"),typeMatch:t=>t.startsWith("text/"),exts:[".txt",".md",".log"]}];export class BrowseJS{constructor(container,files=[],opts={}){if(container instanceof Element)this.container=container;else{const containerElement=document.getElementById(container)||document.querySelector(container);if(!containerElement)throw new Error("Container not found: "+container);this.container=containerElement}this.opts=opts,this.iconRules=opts.icons&&opts.icons.rules?opts.icons.rules:DEFAULT_ICON_RULES,this.folderIcon=opts.icons&&opts.icons.folder?opts.icons.folder:svgFolderIcon(""),this.defaultIcon=opts.icons&&opts.icons.default?opts.icons.default:svgDocumentIcon("?","#e9eefc"),this.multi=Boolean(opts.multiSelect),this.selectedIndices=new Set,this.stack=[{name:opts.rootName||"Root",files:files}],this.breadcrumbEl=document.createElement("nav"),this.breadcrumbEl.className="breadcrumb",this.breadcrumbEl.setAttribute("aria-label","Breadcrumb"),this.crumbsWrap=document.createElement("div"),this.crumbsWrap.className="crumbs-wrap",this.controlsEl=document.createElement("div"),this.controlsEl.className="crumb-controls",this.breadcrumbEl.appendChild(this.crumbsWrap),this.breadcrumbEl.appendChild(this.controlsEl),this.detailsEl=document.createElement("aside"),this.detailsEl.className="details",this.detailsEl.setAttribute("aria-live","polite"),this.detailsEl.textContent=opts.detailsText||"Select a file",this.container.innerHTML="",this.galleryEl=document.createElement("div"),this.galleryEl.className="gallery-grid",this.container.appendChild(this.breadcrumbEl),this.container.appendChild(this.galleryEl),this.container.appendChild(this.detailsEl),this._fileInput=document.createElement("input"),this._fileInput.type="file",this._fileInput.style.display="none",this.container.appendChild(this._fileInput),this.container.classList.add("browsejs"),this.galleryEl.tabIndex=0,document.addEventListener("dragover",e=>{e.preventDefault(),this.container.classList.add("dragover")}),document.addEventListener("dragleave",e=>{this.container.classList.remove("dragover")}),this.container.addEventListener("drop",e=>{e.preventDefault(),this.container.classList.remove("dragover");const files=Array.from(e.dataTransfer&&e.dataTransfer.files||[]);0!==files.length&&this.handleDropFiles(files)}),this.render()}getIconForItem(item){if(item.children)return this.folderIcon;const type=item.meta&&item.meta.type?String(item.meta.type).toLowerCase():"",name=item.name?String(item.name).toLowerCase():"";if(type)for(const r of this.iconRules)if(r.typeMatch&&r.typeMatch(type))return r.icon;for(const r of this.iconRules)if(r.exts)for(const e of r.exts)if(name.endsWith(e))return r.icon;return this.defaultIcon}render(){this.galleryEl.innerHTML="";this.currentFiles().forEach((f,i)=>{const card=document.createElement("button");card.className="card",card.type="button",card.dataset.index=i,card.title=f.name;const img=document.createElement("img");img.alt=f.name||"",img.src=f.thumbnail||this.getIconForItem(f);const meta=document.createElement("div");meta.className="meta",meta.textContent=f.name,card.appendChild(img),card.appendChild(meta),f.children&&Array.isArray(f.children)?(card.addEventListener("click",()=>this.enterFolder(i)),card.addEventListener("keyup",e=>{"Enter"===e.key&&this.enterFolder(i)})):(card.addEventListener("click",()=>this.select(i)),card.addEventListener("keyup",e=>{"Enter"===e.key&&this.select(i)})),this.galleryEl.appendChild(card)}),this.updateSelectionUI(),this.renderBreadcrumb()}updateSelectionUI(){this.galleryEl.querySelectorAll(".card").forEach(c=>{const idx=Number(c.dataset.index);this.selectedIndices.has(idx)?c.classList.add("selected"):c.classList.remove("selected")}),this.renderDetails()}renderDetails(){if(!this.detailsEl)return;const activeList=this.currentFiles();if(0===this.selectedIndices.size)this.detailsEl.innerHTML='<p class="small">No selection</p>';else if(this.selectedIndices.size>1){const indices=Array.from(this.selectedIndices).sort();this.detailsEl.innerHTML="";const header=document.createElement("div");header.innerHTML=`<strong>${indices.length} item${indices.length>1?"s":""} selected</strong>`,this.detailsEl.appendChild(header);const list=document.createElement("div");list.className="small",list.innerHTML=indices.map(i=>`<div>${escapeText(activeList[i]&&activeList[i].name||"Untitled")}</div>`).join(""),this.detailsEl.appendChild(list)}else{const index=Array.from(this.selectedIndices)[0],item=activeList[index];if(!item)return void(this.detailsEl.innerHTML='<p class="small">No selection</p>');this.detailsEl.innerHTML="";const img=document.createElement("img");img.className="preview",img.src=item.thumbnail||this.galleryEl.querySelector(`.card[data-index="${index}"] img`)?.src||this.getIconForItem(item),img.alt=item.name||"preview";const name=document.createElement("div");name.innerHTML=`<strong>${item.name||"Untitled"}</strong>`;const meta=document.createElement("div");meta.className="small",item.meta&&"object"==typeof item.meta?meta.innerHTML=Object.entries(item.meta).map(([k,v])=>`<div><strong>${k}:</strong> ${v}</div>`).join(""):item.meta?meta.textContent=String(item.meta):meta.textContent="",this.detailsEl.appendChild(img),this.detailsEl.appendChild(name),this.detailsEl.appendChild(meta)}}renderBreadcrumb(){if(!this.breadcrumbEl)return;this.crumbsWrap.innerHTML="",this.stack.forEach((s,idx)=>{const btn=document.createElement("button");if(btn.type="button",btn.className="crumb",btn.textContent=s.name,btn.addEventListener("click",()=>this.goToCrumb(idx)),this.crumbsWrap.appendChild(btn),idx<this.stack.length-1){const sep=document.createElement("span");sep.className="sep",sep.textContent="»",this.crumbsWrap.appendChild(sep)}}),this.controlsEl.innerHTML="";const hasCreate="function"==typeof this.opts.onCreateFolder,hasUpload="function"==typeof this.opts.onUpload;if(hasCreate){const createBtn=document.createElement("button");createBtn.type="button",createBtn.className="crumb-action",createBtn.textContent="New Folder",createBtn.addEventListener("click",()=>this.handleCreateFolder()),this.controlsEl.appendChild(createBtn)}if(hasUpload){const uploadBtn=document.createElement("button");uploadBtn.type="button",uploadBtn.className="crumb-action",uploadBtn.textContent="Upload",uploadBtn.addEventListener("click",()=>this.handleUpload()),this.controlsEl.appendChild(uploadBtn)}}currentFolder(){return this.stack.map(s=>s.name+"/").slice(1).join()}currentFiles(){return this.stack[this.stack.length-1].files}addFiles(files){Array.isArray(files)||(files=[files]);this.currentFiles().push(...files),this.render()}getSelectedPaths(){const activeList=this.currentFiles(),basePath=this.currentFolder(),paths=[];return Array.from(this.selectedIndices).forEach(i=>{const item=activeList[i];item&&paths.push(basePath+item.name)}),paths}async handleCreateFolder(){if(this._creatingFolder)return;this._creatingFolder=!0;const current=this.currentFiles(),card=document.createElement("div");card.className="card creating",card.tabIndex=0;const img=document.createElement("img");img.alt="Folder",img.src=this.folderIcon;const meta=document.createElement("div");meta.className="meta";const nameEl=document.createElement("div");nameEl.className="editable-name",nameEl.contentEditable="true",nameEl.spellcheck=!1,nameEl.textContent="",meta.appendChild(nameEl),card.appendChild(img),card.appendChild(meta),this.galleryEl.insertBefore(card,this.galleryEl.firstChild),nameEl.focus();try{const sel=window.getSelection(),range=document.createRange();range.selectNodeContents(nameEl),sel.removeAllRanges(),sel.addRange(range)}catch(e){}const cleanup=()=>{this._creatingFolder=!1,card.parentNode&&card.parentNode.removeChild(card)},commit=async()=>{const name=nameEl.textContent.trim();if(name){if("function"==typeof this.opts.onCreateFolder){const res=await this.opts.onCreateFolder(name);if(!res)return void cleanup();current.splice(0,0,res)}else current.splice(0,0,{name:name,children:[]});this._creatingFolder=!1,this.render()}else cleanup()};nameEl.addEventListener("keydown",e=>{"Enter"===e.key?(e.preventDefault(),nameEl.blur()):"Escape"===e.key&&(e.preventDefault(),cleanup())}),nameEl.addEventListener("blur",()=>{setTimeout(()=>{this._creatingFolder&&commit()},0)},{once:!0})}async handleUpload(){"function"==typeof this.opts.onUpload&&(this._fileInput.value="",this._fileInput.multiple=!0,this._fileInput.onchange=async()=>{const files=Array.from(this._fileInput.files||[]);if(0!==files.length)try{const res=await this.opts.onUpload(files);if(res){const current=this.currentFiles();Array.isArray(res)?current.push(...res):current.push(res),this.render()}}catch(err){console.error("onUpload error",err)}},this._fileInput.click())}async handleDropFiles(files){if(files&&0!==files.length&&"function"==typeof this.opts.onUpload)try{const res=await this.opts.onUpload(files);if(res){const current=this.currentFiles();Array.isArray(res)?current.push(...res):current.push(res),this.render()}}catch(err){console.error("onUpload error",err)}}select(i){const activeList=this.currentFiles();if(this.multi?this.selectedIndices.has(i)?this.selectedIndices.delete(i):this.selectedIndices.add(i):this.selectedIndices.has(i)?this.selectedIndices.clear():(this.selectedIndices.clear(),this.selectedIndices.add(i)),this.updateSelectionUI(),"function"==typeof this.opts.onSelect){const indices=Array.from(this.selectedIndices).sort((a,b)=>a-b),items=indices.map(idx=>activeList[idx]);this.opts.onSelect(items,indices)}}enterFolder(i){const f=this.currentFiles()[i];f&&Array.isArray(f.children)&&(this.stack.push({name:f.name||"Folder",files:f.children}),this.selectedIndices.clear(),this.render())}goToCrumb(idx){this.stack=this.stack.slice(0,idx+1),this.selectedIndices.clear(),this.render()}}
|