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.
@@ -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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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: none;
16
- overflow: hidden;
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.0.2",
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 && terser src/browse.js -o dist/browse.js && terser src/browse.js -c -o dist/browse.min.js && lessc src/style.less dist/style.css",
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: none;
17
- overflow: hidden;
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;
@@ -1 +0,0 @@
1
- function svgDataUrlFromSvg(svg){return"data:image/svg+xml;utf8,"+encodeURIComponent(svg)}function escapeText(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}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()}}