UNPKG

js-fileexplorer

Version:

A zero dependencies, customizable, pure Javascript widget for navigating, managing (move, copy, delete), uploading, and downloading files and folders or other hierarchical object structures on any modern web browser.

1,764 lines (1,322 loc) 240 kB
// Folder and File Explorer. A pure, zero-dependencies Javascript widget. // (C) 2020 CubicleSoft. All Rights Reserved. (function() { // Prevent multiple instances. if (window.hasOwnProperty('FileExplorer')) return; var EscapeHTML = function(text) { var map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; return text.replace(/[&<>"']/g, function(m) { return map[m]; }); } var FormatStr = function(format) { var args = Array.prototype.slice.call(arguments, 1); return format.replace(/{(\d+)}/g, function(match, number) { return (typeof args[number] != 'undefined' ? args[number] : match); }); }; var GetDisplayFilesize = function(numbytes, adjustprecision, units) { if (numbytes == 0) return '0 Bytes'; if (numbytes == 1) return '1 Byte'; numbytes = Math.abs(numbytes); var magnitude, abbreviations; if (units && units.toLowerCase() === 'iec_formal') { magnitude = Math.pow(2, 10); abbreviations = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; } else if (units && units.toLowerCase() === 'si') { magnitude = Math.pow(10, 3); abbreviations = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; } else { magnitude = Math.pow(2, 10); abbreviations = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; } var pos = Math.floor(Math.log(numbytes) / Math.log(magnitude)); var result = (numbytes / Math.pow(magnitude, pos)); return (pos == 0 || (adjustprecision && result >= 99.995) ? result.toFixed(0) : result.toFixed(2)) + ' ' + abbreviations[pos]; }; var CreateNode = function(tag, classes, attrs, styles) { var elem = document.createElement(tag); if (classes) { if (typeof classes === 'string') elem.className = classes; else elem.className = classes.join(' '); } if (attrs) Object.assign(elem, attrs); if (styles) Object.assign(elem.style, styles); return elem; }; var DebounceAttributes = function(options) { if (!(this instanceof DebounceAttributes)) return new DebounceAttributes(options); var intervaltimer = null, numsame; var $this = this; var defaults = { watchers: [], interval: 50, stopsame: 1, callback: null, intervalcallback: null }; $this.settings = Object.assign({}, defaults, options); var MainIntervalHandler = function() { var nummatches = 0; for (var x = 0; x < $this.settings.watchers.length; x++) { var watcher = $this.settings.watchers[x]; if (watcher.val === watcher.elem[watcher.attr]) nummatches++; else watcher.val = watcher.elem[watcher.attr]; } if (nummatches < $this.settings.watchers.length) { numsame = 0; if ($this.settings.intervalcallback) $this.settings.intervalcallback.call($this); } else { numsame++; if (numsame >= $this.settings.stopsame) { $this.Stop(); if ($this.settings.intervalcallback) $this.settings.intervalcallback.call($this); if ($this.settings.callback) $this.settings.callback.call($this); } } }; // Public functions. $this.Start = function() { if (!intervaltimer) { numsame = 0; intervaltimer = setInterval(MainIntervalHandler, $this.settings.interval); } }; $this.Stop = function() { if (intervaltimer) { clearInterval(intervaltimer); intervaltimer = null; } }; $this.Destroy = function() { $this.Stop(); $this = null; } }; function GetScrollLineHeight() { var iframe = document.createElement('iframe'); iframe.src = '#'; document.body.appendChild(iframe); var iwin = iframe.contentWindow; var idoc = iwin.document; idoc.open(); idoc.write('<!DOCTYPE html><html><head></head><body><span>a</span></body></html>'); idoc.close(); var span = idoc.body.firstElementChild; var r = span.offsetHeight; document.body.removeChild(iframe); return r; } var scrolllineheight = GetScrollLineHeight(); // Clean up history stack. var capturingrefs = 0, prevscrollrestore; function HistoryPopStateHandler(e) { if (!capturingrefs) { if (e.state && e.state._fileexplorer) { var prevscrollstatecopy = e.state._fileexplorerprevscroll; window.history.back(); if (prevscrollstatecopy) prevscrollrestore = prevscrollstatecopy; } else if (prevscrollrestore) { setTimeout(function() { window.history.scrollRestoration = prevscrollrestore; prevscrollrestore = null; }, 20); } } } window.addEventListener('popstate', HistoryPopStateHandler, true); if (window.history.state && window.history.state._fileexplorer) { var prevscrollstatecopy = window.history.state._fileexplorerprevscroll; window.history.back(); if (prevscrollstatecopy) prevscrollrestore = prevscrollstatecopy; } // Extracts and returns global drag group icon data for drag-and-drop operations. function GetFileExplorerDragIconData(e, group) { if (!e.dataTransfer) return false; for (var x = 0; x < e.dataTransfer.types.length; x++) { if (e.dataTransfer.types[x].startsWith('application/file-explorer-icon;')) { try { var icondata = JSON.parse(e.dataTransfer.types[x].substring(31)); return (!group || group === icondata.group ? icondata : false); } catch (e) { } } } return false; } // Creates a custom overlay that tracks with the position when entering the window. var fe_numdragenters = 0, fe_dragiconoverlay, fe_dragiconoverlaypos = {}; function OverlayDragEnterHandler(e) { if (!fe_numdragenters) { var icondata = GetFileExplorerDragIconData(e); if (icondata !== false) { // Create the floating icon tracking overlay. fe_dragiconoverlay = CreateNode('div', ['fe_fileexplorer_floating_drag_icon_wrap']); var innernode = CreateNode('div', ['fe_fileexplorer_floating_drag_icon_wrap_inner']); var iconnode = CreateNode('div', ['fe_fileexplorer_item_icon']); if (icondata.numitems > 1) innernode.dataset.numitems = icondata.numitems; iconnode.classList.add('fe_fileexplorer_item_icon_' + icondata.type); innernode.appendChild(iconnode); fe_dragiconoverlay.appendChild(innernode); document.body.appendChild(fe_dragiconoverlay); fe_dragiconoverlaypos = { lastx: -9999, lasty: 0, xdiff: Math.floor(fe_dragiconoverlay.offsetWidth / 2), ydiff: Math.floor(fe_dragiconoverlay.offsetHeight - 8) }; } } fe_numdragenters++; } // Moves the overlay to the drag position. function OverlayDragOverHandler(e) { if (fe_dragiconoverlay && (fe_dragiconoverlaypos.lastx !== e.clientX || fe_dragiconoverlaypos.lasty !== e.clientY)) { fe_dragiconoverlaypos.lastx = e.clientX; fe_dragiconoverlaypos.lasty = e.clientY; // Update the overlay position. fe_dragiconoverlay.style.left = (e.clientX - fe_dragiconoverlaypos.xdiff) + 'px'; fe_dragiconoverlay.style.top = (e.clientY - fe_dragiconoverlaypos.ydiff) + 'px'; } } // Removes the custom overlay when leaving the window. function OverlayDragLeaveHandler(e) { fe_numdragenters--; if (fe_numdragenters < 1) { fe_numdragenters = 0; if (fe_dragiconoverlay) { fe_dragiconoverlay.parentNode.removeChild(fe_dragiconoverlay); fe_dragiconoverlay = null; } fe_dragiconoverlaypos = {}; } } window.addEventListener('dragenter', OverlayDragEnterHandler, true); window.addEventListener('dragover', OverlayDragOverHandler, true); window.addEventListener('dragleave', OverlayDragLeaveHandler, true); window.addEventListener('drop', OverlayDragLeaveHandler, true); // Basic XMLHttpRequest (XHR) wrapper. var PrepareXHR = function(options) { if (!(this instanceof PrepareXHR)) return new PrepareXHR(options); var sent = false; var $this = this; $this.xhr = new XMLHttpRequest(); var RequestEndedHandler = function(e) { if ($this) $this.xhr = null; }; $this.xhr.addEventListener('loadend', RequestEndedHandler); if (options.onsuccess || options.onload) $this.xhr.addEventListener('load', options.onsuccess || options.onload); if (options.onerror) { $this.xhr.addEventListener('error', options.onerror); if (!options.onabort) $this.xhr.addEventListener('abort', options.onerror); if (!options.ontimeout) $this.xhr.addEventListener('timeout', options.onerror); } if (options.onabort) $this.xhr.addEventListener('abort', options.onabort); if (options.onloadstart) $this.xhr.addEventListener('loadstart', options.onloadstart); if (options.onprogress) $this.xhr.addEventListener('progress', options.onprogress); if (options.ontimeout) $this.xhr.addEventListener('timeout', options.ontimeout); if (options.onloadend) $this.xhr.addEventListener('loadend', options.onloadend); // Public functions. // Transparently route event listener registration/removals. $this.upload = {}; $this.upload.addEventListener = function(type, listener, options) { if (!sent && $this && $this.xhr) $this.xhr.upload.addEventListener(type, listener, options); }; $this.upload.removeEventListener = function(type, listener, options) { if ($this && $this.xhr) $this.xhr.upload.removeEventListener(type, listener, options); }; $this.addEventListener = function(type, listener, options) { if (!sent && $this && $this.xhr) $this.xhr.addEventListener(type, listener, options); }; $this.removeEventListener = function(type, listener, options) { if ($this && $this.xhr) $this.xhr.removeEventListener(type, listener, options); }; // Returns the calculated method. $this.GetMethod = function() { return (options.method || (options.params || options.body ? 'POST' : 'GET')); }; $this.PrepareBody = function() { if (options.body) return options.body; var method = $this.GetMethod(); // Build a FormData object. var xhrbody = (options.params || method === 'POST' ? new FormData() : null); if (options.params) { if (options.params instanceof FormData) { xhrbody = options.params; } else if (Array.isArray(options.params)) { for (var x = 0; x < options.params.length; x++) xhrbody.append(options.params[x].name, options.params[x].value); } else { for (var x in options.params) { if (options.params.hasOwnProperty(x)) { if (typeof options.params[x] === 'string' || typeof options.params[x] === 'number') xhrbody.append(x, options.params[x]); } } } } return xhrbody; }; $this.Send = function(xhrbody) { if (sent || !$this || !$this.xhr) return; sent = true; $this.xhr.open($this.GetMethod(), options.url); // Set request headers. if (options.headers) { for (var x in options.headers) { if (options.headers.hasOwnProperty(x) && typeof options.headers[x] === 'string') $this.xhr.setRequestHeader(x, options.headers[x]); } } if (!xhrbody) xhrbody = $this.PrepareBody(); // Send the XHR request. $this.xhr.send(xhrbody); }; $this.Abort = function() { if (!$this || !$this.xhr) return; var tempxhr = $this.xhr; $this.xhr = null; if (sent) tempxhr.abort(); }; $this.Destroy = function() { $this.Abort(); $this = null; }; }; // Image loader. Items can be cancelled. var ImageLoader = function(options) { if (!(this instanceof ImageLoader)) return new ImageLoader(options); var activequeue = {}, numactive = 0, queue = {}, numqueued = 0, minqueueid = 1, nextid = 1; var $this = this; var defaults = { maxactive: 10 }; $this.settings = Object.assign({}, defaults, options); var ImgLoadHandler = function(e) { var id = e.target._ilid; var opts = activequeue[id]; delete activequeue[id]; numactive--; opts.img.onload = null; opts.img.onerror = null; if (opts.callback) opts.callback.call($this, opts, true, e); $this.ProcessQueue(); }; var ImgErrorHandler = function(e) { var id = e.target._ilid; var opts = activequeue[id]; delete activequeue[id]; numactive--; opts.img.onload = null; opts.img.onerror = null; if (opts.callback) opts.callback.call($this, opts, false, e); $this.ProcessQueue(); }; // Public functions. // Adds an image request to the queue. // Required opts keys: src (image URL). // Optional opts keys: width, height, callback. // Reserved opts keys: id, started, img. $this.AddToQueue = function(opts) { // Ignore if an existing ID is already in a queue. if (opts.id && ((opts.id in activequeue) || (opts.id in queue))) return; opts.id = nextid; queue[nextid] = opts; numqueued++; nextid++; }; // Starts the next images in the queue up to maxactive. $this.ProcessQueue = function() { while (numactive < $this.settings.maxactive && numqueued) { while (minqueueid < nextid && !queue.hasOwnProperty(minqueueid)) minqueueid++; // Move an item from the waiting queue to the active queue. var opts = queue[minqueueid]; delete queue[minqueueid]; numqueued--; activequeue[opts.id] = opts; numactive++; // Create the image. var imgnode = (opts.width && opts.height ? new Image(opts.width, opts.height) : new Image()); imgnode._ilid = opts.id; opts.started = Date.now(); opts.img = imgnode; imgnode.onload = ImgLoadHandler; imgnode.onerror = ImgErrorHandler; imgnode.src = opts.src; } }; // Checks to see if the supplied ID is in the active queue. $this.IsActive = function(id) { return (id in activequeue); }; // Remove an item from the queue it is in. $this.RemoveFromQueue = function(id) { if (id in queue) { delete queue[id]; numqueued--; } else if (id in activequeue) { var opts = activequeue[id]; // Cancel an active image download. opts.img.onload = null; opts.img.onerror = null; opts.img.src = ''; delete opts.img; delete activequeue[id]; numactive--; } }; $this.Destroy = function() { for (var x in activequeue) { if (activequeue.hasOwnProperty(x)) { activequeue[x].img.onload = null; activequeue[x].img.onerror = null; activequeue[x].img.src = ''; delete activequeue[x].img; } } activequeue = {}; numactive = 0; queue = {}; numqueued = 0; $this = null; }; }; // Single instance of ImageLoader for loading thumbnails. var fe_thumbnailloader = new ImageLoader(); // Folder tracking. Manages information related to folders and files in the defined folder. // Pass in an array of path segments to define the path. Each path segment is an array consisting of [id, value, attrs]. var Folder = function(path) { if (!(this instanceof Folder)) return new Folder(path); var triggers = {}, entries = [], entryidmap = {}, busyref = 0, busyqueue = [], autosort = true; var $this = this; if (!path[path.length - 1][2]) path[path.length - 1][2] = {}; // Internal functions. var DispatchEvent = function(eventname, params) { if (!triggers[eventname]) return; triggers[eventname].forEach(function(callback) { if (Array.isArray(params)) callback.apply($this, params); else callback.call($this, params); }); }; // Public DOM-style functions. $this.addEventListener = function(eventname, callback) { if (!triggers[eventname]) triggers[eventname] = []; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) return; } triggers[eventname].push(callback); }; $this.removeEventListener = function(eventname, callback) { if (!triggers[eventname]) return; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) { triggers[eventname].splice(x, 1); return; } } }; $this.hasEventListener = function(eventname) { return (triggers[eventname] && triggers[eventname].length); }; // Internal variables. $this.lastrefresh = 0; $this.waiting = true; $this.refs = 0; // Public functions. // Add the value of newval to the folder busy state. Any queued changes will be applied when cleared. $this.SetBusyRef = function(newval) { busyref += newval; if (busyref < 0) busyref = 0; while (!busyref && busyqueue.length) { var item = busyqueue.shift(); item.callback.apply($this, item.callbackopts); } }; $this.IsBusy = function() { return (busyref > 0); }; $this.AddBusyQueueCallback = function(callback, callbackopts) { busyqueue.push({ callback: callback, callbackopts: callbackopts }); $this.SetBusyRef(0); }; // Internal function to only be used by FileExplorer. $this.ClearBusyQueueCallbacks = function() { busyqueue = []; }; $this.GetPath = function() { return path; }; $this.GetPathIDs = function() { var result = []; for (var x = 0; x < path.length; x++) result.push(path[x][0]); return result; }; // Sets an object containing optional attributes for the path. // Used primarily to disable some/all tools from functioning for specific folders. $this.SetAttributes = function(newattrs) { path[path.length - 1][2] = newattrs; DispatchEvent('set_attributes'); }; $this.SetAttribute = function(key, value) { path[path.length - 1][2][key] = value; DispatchEvent('set_attributes', key); }; $this.GetAttributes = function() { return path[path.length - 1][2]; }; $this.SetAutoSort = function(newautosort) { autosort = (newautosort ? true : false); }; $this.SortEntries = function() { if ($this.busy) { $this.busyqueue.push({ callback: $this.SortEntries, callbackopts: [] }); return; } var localeopts = { numeric: true, sensitivity: 'base' }; entries.sort(function(a, b) { if (a.type !== b.type) return (a.type === 'folder' ? -1 : 1); return a.name.localeCompare(b.name, undefined, localeopts); }); }; // Sets an array of objects containing the folder entries. // Required per-item object keys: id (unique string), name, type ('folder' or 'file'), hash (unique string). // Optional per-item object keys: attrs, size, tooltip (tooltip string), thumb (thumbnail image URL), overlay (class name). $this.SetEntries = function(newentries) { if ($this.busy) { $this.busyqueue.push({ callback: $this.SetEntries, callbackopts: [newentries] }); return; } entries = newentries; if (autosort) $this.SortEntries(); entryidmap = {}; for (var x = 0; x < entries.length; x++) { entryidmap[entries[x].id] = x; } $this.waiting = false; DispatchEvent('set_entries'); }; // Creates/Updates multiple entries. $this.UpdateEntries = function(updatedentries) { if ($this.busy) { $this.busyqueue.push({ callback: $this.UpdateEntries, callbackopts: [updatedentries] }); return; } for (var x = 0; x < updatedentries.length; x++) { var entry = updatedentries[x]; if (!(entry.id in entryidmap)) entries.push(entry); else entries[entryidmap[entry.id]] = entry; } if (autosort) $this.SortEntries(); entryidmap = {}; for (var x = 0; x < entries.length; x++) { entryidmap[entries[x].id] = x; } $this.waiting = false; DispatchEvent('set_entries'); }; // Sets a single entry and triggers a full refresh. $this.SetEntry = function(entry) { if ($this.busy) { $this.busyqueue.push({ callback: $this.SetEntry, callbackopts: [entry] }); return; } if (!(entry.id in entryidmap)) entries.push(entry); else entries[entryidmap[entry.id]] = entry; if (autosort) $this.SortEntries(); entryidmap = {}; for (var x = 0; x < entries.length; x++) { entryidmap[entries[x].id] = x; } $this.waiting = false; DispatchEvent('set_entries'); }; $this.RemoveEntry = function(id) { if ($this.busy) { $this.busyqueue.push({ callback: $this.RemoveEntry, callbackopts: [id] }); return; } if (!(id in entryidmap)) return; var pos = entryidmap[id]; delete entryidmap[id]; entries.splice(pos, 1); for (var x = pos; x < entries.length; x++) { entryidmap[entries[x].id] = x; } DispatchEvent('remove_entry', pos); }; $this.GetEntries = function() { return entries; }; $this.GetEntryIDMap = function() { return entryidmap; }; $this.Destroy = function() { DispatchEvent('destroy'); triggers = {}; entries = []; entryidmap = {}; busyref = 0 busyqueue = []; $this.lastrefresh = 0; $this.waiting = true; $this.refs = 0; $this = null; path = null; }; }; // Attaches a popup menu to the DOM. var PopupMenu = function(parentelem, options) { if (!(this instanceof PopupMenu)) return new PopupMenu(parentelem, options); var triggers = {}; var $this = this; var defaults = { items: [], resizewatchers: null, onposition: null, onselchanged: null, onselected: null, oncancel: null, onleft: null, onright: null, ondestroy: null }; $this.settings = Object.assign({}, defaults, options); // Initialize the UI elements. var elems = { popupwrap: CreateNode('div', ['fe_fileexplorer_popup_wrap'], { tabIndex: 0 }), innerwrap: CreateNode('div', ['fe_fileexplorer_popup_inner_wrap']) }; // Track the last hovered/focused item. var lastitem = false, itemidmap = {}; // Attach elements to DOM. for (var x = 0; x < $this.settings.items.length; x++) { var item = $this.settings.items[x]; if (item === 'split') { var itemnode = CreateNode('div', ['fe_fileexplorer_popup_item_split']); elems.innerwrap.appendChild(itemnode); } else { var itemnode = CreateNode('div', ['fe_fileexplorer_popup_item_wrap'], { tabIndex: -1 }); var itemicon = CreateNode('div', ['fe_fileexplorer_popup_item_icon']); var itemiconinner = CreateNode('div', ['fe_fileexplorer_popup_item_icon_inner']); var itemtext = CreateNode('div', ['fe_fileexplorer_popup_item_text']); var enabled = (!('enabled' in item) || item.enabled); if (!enabled) itemnode.classList.add('fe_fileexplorer_popup_item_disabled'); if ('icon' in item) itemiconinner.classList.add(item.icon); itemtext.innerHTML = item.name; itemicon.appendChild(itemiconinner); itemnode.appendChild(itemicon); itemnode.appendChild(itemtext); itemnode.dataset.itemid = item.id; itemidmap[item.id] = item; elems.innerwrap.appendChild(itemnode); } } elems.popupwrap.appendChild(elems.innerwrap); parentelem.appendChild(elems.popupwrap); // Internal functions. var DispatchEvent = function(eventname, params) { if (!triggers[eventname]) return; triggers[eventname].forEach(function(callback) { if (Array.isArray(params)) callback.apply($this, params); else callback.call($this, params); }); }; // Public DOM-style functions. $this.addEventListener = function(eventname, callback) { if (!triggers[eventname]) triggers[eventname] = []; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) return; } triggers[eventname].push(callback); }; $this.removeEventListener = function(eventname, callback) { if (!triggers[eventname]) return; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) { triggers[eventname].splice(x, 1); return; } } }; $this.hasEventListener = function(eventname) { return (triggers[eventname] && triggers[eventname].length); }; // Register settings callbacks. if ($this.settings.onposition) $this.addEventListener('position', $this.settings.onposition); if ($this.settings.onselchanged) $this.addEventListener('selection_changed', $this.settings.onselchanged); if ($this.settings.onselected) $this.addEventListener('selected', $this.settings.onselected); if ($this.settings.oncancel) $this.addEventListener('cancelled', $this.settings.oncancel); if ($this.settings.onleft) $this.addEventListener('left', $this.settings.onleft); if ($this.settings.onright) $this.addEventListener('right', $this.settings.onright); if ($this.settings.ondestroy) $this.addEventListener('destroy', $this.settings.ondestroy); // Set up focus changing closing rules. var MainFocusHandler = function(e) { if (!e.isTrusted) return; var node = e.target; while (node && node !== elems.popupwrap) node = node.parentNode; if (node !== elems.popupwrap && allowcancel) { lastactiveelem = e.target; $this.Cancel(e.type === 'focus' ? 'focus' : 'mouse'); } }; window.addEventListener('mousedown', MainFocusHandler, true); window.addEventListener('focus', MainFocusHandler, true); var MainWindowBlurHander = function(e) { if (e.target === window || e.target === document) $this.Cancel('blur'); }; window.addEventListener('blur', MainWindowBlurHander, true); // Track mouse movement to update the last hovered/focused item. var InnerWrapMoveHandler = function(e) { if (!e.isTrusted) return; e.preventDefault(); var node = e.target; while (node && node.parentNode !== elems.innerwrap) node = node.parentNode; if (node && (lastitem !== node || lastitem !== document.activeElement)) { if (node.classList.contains('fe_fileexplorer_popup_item_wrap')) { node.tabIndex = 0; node.focus(); if (lastitem !== false) lastitem.tabIndex = -1; if (lastitem !== node) DispatchEvent('selection_changed', [node.dataset.itemid, itemidmap[node.dataset.itemid]]); lastitem = node; } else if (elems.popupwrap !== document.activeElement) { elems.popupwrap.focus(); } } }; elems.innerwrap.addEventListener('mousemove', InnerWrapMoveHandler); var InnerWrapLeaveHandler = function(e) { if (!e.isTrusted) return; elems.popupwrap.focus(); }; elems.innerwrap.addEventListener('mouseleave', InnerWrapLeaveHandler); // Notify listeners that the last item was selected. var lastactiveelem = document.activeElement; var NotifySelected = function(etype) { allowcancel = false; DispatchEvent('selected', [itemidmap[lastitem.dataset.itemid].id, itemidmap[lastitem.dataset.itemid], lastactiveelem, etype]); }; // Handle clicks. var MainClickHandler = function(e) { if (!e.isTrusted) return; e.preventDefault(); if (e.button == 0 && lastitem !== false && lastitem === document.activeElement && !lastitem.classList.contains('fe_fileexplorer_popup_item_disabled')) NotifySelected('mouse'); }; elems.innerwrap.addEventListener('mouseup', MainClickHandler); var StopContextMenu = function(e) { if (!e.isTrusted) return; e.preventDefault(); }; elems.innerwrap.addEventListener('contextmenu', StopContextMenu); // Handle keyboard navigation. var MainKeyHandler = function(e) { // The keyboard is modal while the mouse is not. Stop propagation of all keyboard actions. e.stopPropagation(); if (!e.isTrusted) return; if (e.keyCode == 37) { // Left Arrow. Send event to registered caller (if any). e.preventDefault(); DispatchEvent('left', lastactiveelem); } else if (e.keyCode == 39) { // Right Arrow. Send event to registered caller (if any). e.preventDefault(); DispatchEvent('right', lastactiveelem); } else if (e.keyCode == 38) { // Up Arrow. Move to previous or last item. e.preventDefault(); var node = (lastitem === false ? elems.innerwrap.lastChild : lastitem.previousSibling); while (node && !node.classList.contains('fe_fileexplorer_popup_item_wrap')) node = node.previousSibling; if (!node) node = elems.innerwrap.lastChild; if (node) { node.tabIndex = 0; node.focus(); if (lastitem !== false) lastitem.tabIndex = -1; if (lastitem !== node) DispatchEvent('selection_changed', [node.dataset.itemid, itemidmap[node.dataset.itemid]]); lastitem = node; } if (lastitem !== false) lastitem.focus(); } else if (e.keyCode == 40) { // Down Arrow. Move to next or first item. e.preventDefault(); var node = (lastitem === false ? elems.innerwrap.firstChild : lastitem.nextSibling); while (node && !node.classList.contains('fe_fileexplorer_popup_item_wrap')) node = node.nextSibling; if (!node) node = elems.innerwrap.firstChild; if (node) { node.tabIndex = 0; node.focus(); if (lastitem !== false) lastitem.tabIndex = -1; if (lastitem !== node) DispatchEvent('selection_changed', [node.dataset.itemid, itemidmap[node.dataset.itemid]]); lastitem = node; } if (lastitem !== false) lastitem.focus(); } else if (e.keyCode == 13) { // Enter. Select item or cancel the popup if the item is disabled. e.preventDefault(); if (lastitem === false || lastitem !== document.activeElement || lastitem.classList.contains('fe_fileexplorer_popup_item_disabled')) $this.Cancel('key'); else NotifySelected('key'); } else if (e.keyCode == 27 || e.keyCode == 9 || e.altKey) { // Escape, Tab, or Alt. Cancel the popup. e.preventDefault(); $this.Cancel('key'); } }; elems.popupwrap.addEventListener('keydown', MainKeyHandler); var IgnoreKeyHandler = function(e) { e.stopPropagation(); if (!e.isTrusted) return; }; elems.popupwrap.addEventListener('keyup', IgnoreKeyHandler); elems.popupwrap.addEventListener('keypress', IgnoreKeyHandler); // Public functions. // Updates the position of the popup menu. $this.UpdatePosition = function() { elems.popupwrap.style.left = '-9999px'; DispatchEvent('position', elems.popupwrap); }; $this.UpdatePosition(); // Set up a debounced element attribute watcher on window resize. var updatepositionwatcher; if (Array.isArray($this.settings.resizewatchers) && $this.settings.resizewatchers.length) { updatepositionwatcher = new DebounceAttributes({ watchers: $this.settings.resizewatchers, interval: 100, stopsame: 5, callback: $this.UpdatePosition, intervalcallback: $this.UpdatePosition }); window.addEventListener('resize', updatepositionwatcher.Start, true); } // Dispatches the cancelled event. var allowcancel = false; $this.Cancel = function(etype) { if (allowcancel) { allowcancel = false; DispatchEvent('cancelled', [lastactiveelem, etype]); } }; // Prevents Cancel() from having any effect. $this.PreventCancel = function() { allowcancel = false; }; // Destroys the popup menu. $this.Destroy = function() { DispatchEvent('destroy'); window.removeEventListener('mousedown', MainFocusHandler, true); window.removeEventListener('focus', MainFocusHandler, true); window.removeEventListener('blur', MainWindowBlurHander, true); elems.innerwrap.removeEventListener('mousemove', InnerWrapMoveHandler); elems.innerwrap.removeEventListener('mouseleave', InnerWrapLeaveHandler); elems.innerwrap.removeEventListener('mouseup', MainClickHandler); elems.innerwrap.removeEventListener('contextmenu', StopContextMenu); elems.popupwrap.removeEventListener('keydown', MainKeyHandler); elems.popupwrap.removeEventListener('keyup', IgnoreKeyHandler); elems.popupwrap.removeEventListener('keypress', IgnoreKeyHandler); if (Array.isArray($this.settings.resizewatchers) && $this.settings.resizewatchers.length) { window.removeEventListener('resize', updatepositionwatcher.Start, true); updatepositionwatcher.Destroy(); } for (var node in elems) { if (elems[node].parentNode) elems[node].parentNode.removeChild(elems[node]); } // Remaining cleanup. elems = null; lastactiveelem = null; $this.settings = Object.assign({}, defaults); $this = null; parentelem = null; options = null; }; // Focus on the popup menu but do not select anything. elems.popupwrap.focus(); allowcancel = true; }; // Overlays a textarea placed into a parent element. var TextareaOverlay = function(parentelem, options) { if (!(this instanceof TextareaOverlay)) return new TextareaOverlay(parentelem, options); var triggers = {}; var $this = this; var defaults = { capturetab: false, multiline: false, initvalue: '', initselstart: -1, initselend: -1, resizewatchers: null, onposition: null, ondone: null, oncancel: null, ondestroy: null }; $this.settings = Object.assign({}, defaults, options); // Initialize the UI elements. var elems = { maintext: CreateNode('textarea', ['fe_fileexplorer_textarea']) }; // Attach elements to DOM. elems.maintext.value = $this.settings.initvalue; parentelem.appendChild(elems.maintext); // Internal functions. var DispatchEvent = function(eventname, params) { if (!triggers[eventname]) return; triggers[eventname].forEach(function(callback) { if (Array.isArray(params)) callback.apply($this, params); else callback.call($this, params); }); }; // Public DOM-style functions. $this.addEventListener = function(eventname, callback) { if (!triggers[eventname]) triggers[eventname] = []; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) return; } triggers[eventname].push(callback); }; $this.removeEventListener = function(eventname, callback) { if (!triggers[eventname]) return; for (var x in triggers[eventname]) { if (triggers[eventname][x] === callback) { triggers[eventname].splice(x, 1); return; } } }; $this.hasEventListener = function(eventname) { return (triggers[eventname] && triggers[eventname].length); }; // Register settings callbacks. if ($this.settings.onposition) $this.addEventListener('position', $this.settings.onposition); if ($this.settings.ondone) $this.addEventListener('done', $this.settings.ondone); if ($this.settings.oncancel) $this.addEventListener('cancelled', $this.settings.oncancel); if ($this.settings.ondestroy) $this.addEventListener('destroy', $this.settings.ondestroy); // Set up focus changing closing rules. var MainFocusHandler = function(e) { if (!e.isTrusted) return; var node = e.target; while (node && node !== elems.maintext) node = node.parentNode; if (node !== elems.maintext && allowcanceldone) { lastactiveelem = e.target; $this.Done(e.type === 'focus' ? 'focus' : 'mouse'); } }; window.addEventListener('mousedown', MainFocusHandler, true); window.addEventListener('focus', MainFocusHandler, true); var MainWindowBlurHander = function(e) { if (e.target === window || e.target === document) $this.Done('blur'); }; window.addEventListener('blur', MainWindowBlurHander, true); var lastactiveelem = document.activeElement; // Handle keyboard navigation. var MainKeyHandler = function(e) { // The keyboard is modal while the mouse is not. Stop propagation of all keyboard actions. e.stopPropagation(); if (!e.isTrusted) return; if (e.keyCode == 8) { // Backspace. $this.UpdatePosition(); setTimeout($this.UpdatePosition, 0); } if (e.keyCode == 46) { // Delete. $this.UpdatePosition(); setTimeout($this.UpdatePosition, 0); } else if (e.keyCode == 9) { // Tab. e.preventDefault(); if (!$this.settings.capturetab || e.shiftKey) $this.Done('key'); else { var pos = elems.maintext.selectionStart; elems.maintext.value = elems.maintext.value.substring(0, pos) + '\t' + elems.maintext.value.substring(elems.maintext.selectionEnd); elems.maintext.selectionEnd = pos + 1; $this.UpdatePosition(); setTimeout($this.UpdatePosition, 0); } } else if (e.keyCode == 13) { // Enter. Complete the entry if not multiline. if (!$this.settings.multiline) { e.preventDefault(); $this.Done('key'); } } else if (e.keyCode == 27) { // Escape. Cancel the textarea. e.preventDefault(); $this.Cancel('key'); } }; elems.maintext.addEventListener('keydown', MainKeyHandler); var MainKeypressHandler = function(e) { e.stopPropagation(); $this.UpdatePosition(); }; elems.maintext.addEventListener('keyup', MainKeypressHandler); elems.maintext.addEventListener('keypress', MainKeypressHandler); // Public functions. // Updates the position of the textarea. $this.UpdatePosition = function() { elems.maintext.style.height = '1px'; DispatchEvent('position', elems.maintext); }; $this.UpdatePosition(); // Set up a debounced element attribute watcher on window resize. var updatepositionwatcher; if (Array.isArray($this.settings.resizewatchers) && $this.settings.resizewatchers.length) { updatepositionwatcher = new DebounceAttributes({ watchers: $this.settings.resizewatchers, interval: 100, stopsame: 5, callback: $this.UpdatePosition, intervalcallback: $this.UpdatePosition }); window.addEventListener('resize', updatepositionwatcher.Start, true); } // Dispatches the done event if the content has changed. var allowcanceldone = false; $this.Done = function(etype) { if ($this.settings.initvalue === elems.maintext.value) $this.Cancel(etype); else if (allowcanceldone) { allowcanceldone = false; elems.maintext.readOnly = true; DispatchEvent('done', [elems.maintext.value, lastactiveelem, etype]); } }; // Dispatches the cancelled event. $this.Cancel = function(etype) { if (allowcanceldone) { allowcanceldone = false; elems.maintext.readOnly = true; DispatchEvent('cancelled', [lastactiveelem, etype]); } }; // Resets the cancel/done status to true so another event can be dispatched. $this.ResetAllowCancelDone = function() { setTimeout(function() { elems.maintext.readOnly = false; elems.maintext.focus(); allowcanceldone = true; }, 0); }; // Destroys the textarea. $this.Destroy = function() { DispatchEvent('destroy'); window.removeEventListener('mousedown', MainFocusHandler, true); window.removeEventListener('focus', MainFocusHandler, true); window.removeEventListener('blur', MainWindowBlurHander, true); elems.maintext.removeEventListener('keydown', MainKeyHandler); elems.maintext.removeEventListener('keyup', MainKeypressHandler); elems.maintext.removeEventListener('keypress', MainKeypressHandler); if (Array.isArray($this.settings.resizewatchers) && $this.settings.resizewatchers.length) { window.removeEventListener('resize', updatepositionwatcher.Start, true); updatepositionwatcher.Destroy(); } for (var node in elems) { if (elems[node].parentNode) elems[node].parentNode.removeChild(elems[node]); } // Remaining cleanup. elems = null; lastactiveelem = null; $this.settings = Object.assign({}, defaults); $this = null; parentelem = null; options = null; }; // Focus on the textarea. elems.maintext.focus(); elems.maintext.setSelectionRange(($this.settings.initselstart > -1 ? $this.settings.initselstart : elems.maintext.value.length), ($this.settings.initselend > -1 ? $this.settings.initselend : elems.maintext.value.length)); allowcanceldone = true; }; // File Explorer. var nextmain_id = 1, coretools = []; window.FileExplorer = function(parentelem, options) { if (!(this instanceof FileExplorer)) return new FileExplorer(parentelem, options); var triggers = {}, historystack = [], currhistory = -1, foldermap = {}, currfolder = false, destroyinprogress = false; var $this = this; // The internal ID needs to be fairly unique to identify source for clipboard paste and drag/drop targets. var main_id = nextmain_id + '_fileexplorer_js_' + Date.now() + '_' + (window.crypto && window.crypto.getRandomValues ? window.crypto.getRandomValues(new Uint32Array(1))[0] : Math.random()); nextmain_id++; var defaults = { group: null, alwaysfocused: false, capturebrowser: false, messagetimeout: 2000, displayunits: 'iec_windows', adjustprecision: true, initpath: null, onfocus: null, onblur: null, onrefresh: null, onselchanged: null, onrename: null, onopenfile: null, oninitupload: null, onfinishedupload: null, onuploaderror: null, concurrentuploads: 4, tools: {}, onnewfolder: null, onnewfile: null, oninitdownload: null, ondownloadstarted: null, ondownloaderror: null, ondownloadurl: null, oncopy: null, onmove: null, ondelete: null, langmap: {} }; $this.settings = Object.assign({}, defaults, options); // If the group is not specified, set it to the unique main ID + the current time. var main_group = (typeof $this.settings.group === 'string' ? $this.settings.group : main_id); // Multilingual translation. $this.Translate = function(str) { return ($this.settings.langmap[str] ? $this.settings.langmap[str] : str); }; // Initialize the UI elements. var elems = { mainwrap: CreateNode('div', ['fe_fileexplorer_wrap']), dropzonewrap: CreateNode('div', ['fe_fileexplorer_dropzone_wrap']), innerwrap: CreateNode('div', ['fe_fileexplorer_inner_wrap']), toolbar: CreateNode('div', ['fe_fileexplorer_toolbar']), navtools: CreateNode('div', ['fe_fileexplorer_navtools']), navtool_back: CreateNode('button', ['fe_fileexplorer_navtool_back', 'fe_fileexplorer_disabled'], { title: $this.Translate('Back (Alt + Left Arrow)'), tabIndex: -1 }), navtool_forward: CreateNode('button', ['fe_fileexplorer_navtool_forward', 'fe_fileexplorer_disabled'], { title: $this.Translate('Forward (Alt + Right Arrow)'), tabIndex: -1 }), navtool_history: CreateNode('button', ['fe_fileexplorer_navtool_history'], { title: $this.Translate('Recent locations') }), navtool_up: CreateNode('button', ['fe_fileexplorer_navtool_up', 'fe_fileexplorer_disabled'], { title: $this.Translate('Up (Alt + Up Arrow)'), tabIndex: -1 }), pathwrap: CreateNode('div', ['fe_fileexplorer_path_wrap']), pathicon: CreateNode('div', ['fe_fileexplorer_path_icon']), pathiconinner: CreateNode('div', ['fe_fileexplorer_path_icon_inner']), pathsegmentsscrollwrap: CreateNode('div', ['fe_fileexplorer_path_segments_scroll_wrap']), pathsegmentswrap: CreateNode('div', ['fe_fileexplorer_path_segments_wrap']), bodywrapouter: CreateNode('div', ['fe_fileexplorer_body_wrap_outer']), bodywrap: CreateNode('div', ['fe_fileexplorer_body_wrap']), bodytoolsscrollwrap: CreateNode('div', ['fe_fileexplorer_folder_tools_scroll_wrap', 'fe_fileexplorer_hidden']), bodytoolbar: CreateNode('div', ['fe_fileexplorer_folder_tools']), bodytools: [], itemsscrollwrap: CreateNode('div', ['fe_fileexplorer_items_scroll_wrap'], { tabIndex: 0 }), itemsscrollwrapinner: CreateNode('div', ['fe_fileexplorer_items_scroll_wrap_inner']), itemsmessagewrap: CreateNode('div', ['fe_fileexplorer_items_message_wrap']), itemswrap: CreateNode('div', ['fe_fileexplorer_items_wrap', 'fe_fileexplorer_hidden']), itemsclipboardoverlaypastewrap: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_wrap', 'fe_fileexplorer_hidden']), itemsclipboardoverlaypasteinnerwrap: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_inner_wrap']), itemsclipboardoverlaypastetextwrap: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_text_wrap']), itemsclipboardoverlaypastetext: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_text']), itemsclipboardoverlaypastetextline: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_text_big'], { innerHTML: $this.Translate('Paste here') }), itemsclipboardoverlaypastetexthint: CreateNode('div', ['fe_fileexplorer_items_clipboard_overlay_paste_text_small']), itemsclipboardoverlay: CreateNode('textarea', ['fe_fileexplorer_items_clipboard_overlay'], { tabIndex: -1, inputMode: 'none', autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false }), statusbar: CreateNode('div', ['fe_fileexplorer_statusbar_wrap']), statusbarmeasuresize: CreateNode('div', ['fe_fileexplorer_statusbar_measure_em_size']), statusbartextwrap: CreateNode('div', ['fe_fileexplorer_statusbar_text_wrap']), statusbartextsegments: [], statusbartextsegmentmap: {}, statusbaractionwrap: CreateNode('div', ['fe_fileexplorer_action_wrap']), statusbaractionprogresswrap: CreateNode('div', ['fe_fileexplorer_action_progress_wrap', 'fe_fileexplorer_hidden']), statusbaractionprogressmessagewrap: CreateNode('div', ['fe_fileexplorer_action_progress_msg_wrap']), statusbaractionprogressmessagewrap2: CreateNode('div', ['fe_fileexplorer_action_progress_msg_wrap', 'fe_fileexplorer_action_progress_msg_wrap_last']), statusbaractionprogresscancelwrap: CreateNode('div', ['fe_fileexplorer_action_progress_cancel_wrap'], { title: $this.Translate('Cancel all'), tabIndex: 0 }), }; // Sets a text segment's displayed text in the status bar. $this.SetNamedStatusBarText = function(name, text, timeout) { if (destroyinprogress) return; if (!(name in elems.statusbartextsegmentmap)) { elems.statusbartextsegmentmap[name] = { pos: elems.statusbartextsegments.length, timeout: null }; var node = CreateNode('div', ['fe_fileexplorer_statusbar_text_segment_wrap']); elems.statusbartextsegments.push(node); elems.statusbartextwrap.appendChild(node); } var currsegment = elems.statusbartextsegmentmap[name]; if (currsegment.timeout) { clearTimeout(currsegment.timeout); currsegment.timeout = null; } var elem = elems.statusbartextsegments[currsegment.pos]; if (text === '') { elem.innerHTML = ''; elem.classList.add('fe_fileexplorer_hidden'); } else { elem.innerHTML = text; elem.classList.remove('fe_fileexplorer_hidden'); if (timeout) { elems.statusbartextsegmentmap[name].timeout = setTimeout(function() { $this.SetNamedStatusBarText(name, ''); }, timeout); // Recalculate widths. var widthmap = [], totalwidth = 1.5 * elems.statusbarmeasuresize.offsetWidth; for (var x = 0; x < elems.statusbartextsegments.length; x++) { var elem2 = elems.statusbartextsegments[x]; if (elem2.classList.contains('fe_fileexplorer_hidden')) widthmap.push(0); else { var currstyle = elem2.currentStyle || window.getComputedStyle(elem2); var elemwidth = elem2.offsetWidth + parseFloat(currstyle.marginLeft) + parseFloat(currstyle.marginRight); widthmap.push(elemwidth); totalwidth += elemwidth; } } for (var x = elems.statusbartextsegments.length; totalwidth >= elems.statusbartextwrap.offsetWidth && x; x--) { if (widthmap[x - 1] && elem !== elems.statusbartextsegments[x - 1]) { elems.statusbartextsegments[x - 1].classList.add('fe_fileexplorer_hidden'); totalwidth -= widthmap[x - 1]; } } } } // Adjust the last visible class. elem = null; elems.statusbartextsegments.forEach(function(elem2) { if (!timeout && elem2.innerHTML !== '') elem2.classList.remove('fe_fileexplorer_hidden'); if (!elem2.classList.contains('fe_fileexplorer_hidden')) { elem2.classList.remove('fe_fileexplorer_statusbar_text_segment_wrap_last'); elem = elem2; } }); if (elem) elem.classList.add('fe_fileexplorer_statusbar_text_segment_wrap_last'); }; $this.SetNamedStatusBarText('folder', ''); $this.SetNamedStatusBarText('selected', ''); $this.SetNamedStatusBarText('message', ''); elems.itemsmessagewrap.innerHTML = EscapeHTML($this.Translate('Loading...')); // Determine what text should show to the user when displaying the paste box. if (matchMedia('(pointer: coarse)').matches) elems.itemsclipboardoverlaypastetexthint.innerHTML = EscapeHTML($this.Translate('Long-press + paste')); else if (navigator.platform.indexOf('Mac') > -1) elems.itemsclipboardoverlaypastetexthint.innerHTML = EscapeHTML($this.Translate('\u2318 + V\u00A0\u00A0\u00A0/\u00A0\u00A0\u00A0Right-click + Paste')); else elems.itemsclipboardoverlaypastetexthint.innerHTML = EscapeHTML($this.Translate('Ctrl + V\u00A0\u00A0\u00A0/\u00A0\u00A0\u00A0Right-click + Paste')); // Attach elements to DOM. elems.navtools.appendChild(elems.navtool_back); elems.navtools.appendChild(elems.navtool_forward); elems.navtools.appendChild(elems.navtool_history); elems.navtools.appendChild(elems.navtool_up); elems.pathicon.appendChild(elems.pathiconinner); elems.pathwrap.appendChild(elems.pathicon); elems.pathsegmentsscrollwrap.appendChild(elems.pathsegmentswrap); elems.pathwrap.appendChild(elems.pathsegmentsscrollwrap); elems.toolbar.appendChild(elems.navtools); elems.toolbar.appendChild(elems.pathwrap); elems.bodytoolsscrollwrap.appendChild(elems.bodytoolbar); elems.itemsscrollwrapinner.appendChild(elems.itemsmessagewrap); elems.itemsscrollwrapinner.appendChild(elems.itemswrap); elems.itemsscrollwrap.appendChild(elems.itemsscrollwrapinner); elems.itemsclipboardoverlaypastetext.appendChild(elems.itemsclipboardoverlaypastetextline); elems.itemsclipboardoverlaypastetext.appendChild(elems.itemsclipboardoverlaypastetexthint); elems.itemsclipboardoverlaypastetextwrap.appendChild(elems.itemsclipboardoverlaypastetext); elems.itemsclipboardoverlaypasteinnerwrap.appendChild(elems.itemsclipboardoverlaypastetextwrap); elems.itemsclipboardoverlaypasteinnerwrap.appendChild(elems.itemsclipboardoverlay); elems.itemsclipboardoverlaypastewrap.appendChild(elems.itemsclipboardoverlaypasteinnerwrap); elems.bodywrap.appendChild(elems.bodytoolsscrollwrap); elems.bodywrap.appendChild(elems.itemsscrollwrap); elems.bodywrapouter.appendChild(elems.bodywrap); elems.bodywrapouter.appendChild(elems.itemsclipboardoverlaypastewrap); elems.statusbaractionprogresswrap.appendChi