UNPKG

js-dos

Version:

Full-featured DOS player with multiple emulator backends

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