UNPKG

droppy

Version:
1,532 lines (1,373 loc) 103 kB
"use strict"; function promisify(fn) { return function() { return new Promise(resolve => { fn(result => resolve(result)); }); }; } const droppy = Object.create(null); /* {{ templates }} */ initVariables(); // ============================================================================ // Feature Detects // ============================================================================ droppy.detects = { directoryUpload: (function() { const el = document.createElement("input"); return droppy.dir.some((prop) => { return prop in el; }); })(), audioTypes: (function() { const types = {}, el = document.createElement("audio"); Object.keys(droppy.audioTypes).forEach((type) => { types[droppy.audioTypes[type]] = Boolean(el.canPlayType(droppy.audioTypes[type]).replace(/no/, "")); }); return types; })(), videoTypes: (function() { const types = {}, el = document.createElement("video"); Object.keys(droppy.videoTypes).forEach((type) => { types[droppy.videoTypes[type]] = Boolean(el.canPlayType(droppy.videoTypes[type]).replace(/no/, "")); }); return types; })(), webp: document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp") === 0, notification: "Notification" in window, mobile: /Mobi/.test(navigator.userAgent), safari: /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent), }; // Transition of freshly inserted elements $.fn.transition = function(oldClass, newClass) { if (!newClass) { newClass = oldClass; oldClass = null; } // Force a reflow // https://gist.github.com/paulirish/5d52fb081b3570c81e3a this.r = this[0].offsetTop; delete this.r; if (oldClass) { this.replaceClass(oldClass, newClass); } else { this.addClass(newClass); } return this; }; // transitionend helper, makes sure the callback gets fired regardless if the transition gets cancelled $.fn.transitionend = function(callback) { if (!this.length) return; let duration, called = false; const el = this[0]; function doCallback(event) { if (called) return; called = true; callback.apply(el, event); } duration = getComputedStyle(el).transitionDuration; duration = (duration.includes("ms")) ? parseFloat(duration) : parseFloat(duration) * 1000; setTimeout(() => { // Call back if "transitionend" hasn't fired in duration + 30 doCallback({target: el}); // Just mimic the event.target property on our fake event }, duration + 30); return this.one("transitionend", doCallback); }; // Class swapping helper $.fn.replaceClass = function(search, replacement) { let el, classes, matches, i = this.length, hasClass = false; while (--i >= 0) { el = this[i]; if (el === undefined) return false; classes = el.className.split(" ").filter((className) => { if (className === search) return false; if (className === replacement) hasClass = true; matches = search instanceof RegExp ? search.exec(className) : className.match(search); // filter out if the entire capture matches the entire className if (matches) return matches[0] !== className || matches[0] === replacement; else return true; }); if (!hasClass) classes.push(replacement); if (classes.length === 0 || (classes.length === 1 && classes[0] === "")) { el.removeAttribute("class"); } else { el.className = classes.join(" "); } } return this; }; Handlebars.registerHelper("select", function(sel, opts) { return opts.fn(this).replace(new RegExp(` value="${sel}"`), "$& selected="); }); Handlebars.registerHelper("is", function(a, b, opts) { return a === b ? opts.fn(this) : opts.inverse(this); }); function svg(which) { // Manually clone instead of <use> because of a weird bug with media arrows in Firefox const svg = document.getElementById(`i-${which}`).cloneNode(true); svg.setAttribute("class", svg.id.replace("i-", "")); svg.removeAttribute("id"); // Edge doesn't support outerHTML on SVG const html = svg.outerHTML || document.createElement("div").appendChild(svg).parentNode.innerHTML; return html.replace(/(?!<\/)?symbol/g, "svg"); } Handlebars.registerHelper("svg", svg); if (droppy.detects.mobile) { document.documentElement.classList.add("mobile"); } if (droppy.detects.webp) { droppy.imageTypes.webp = "image/webp"; } // ============================================================================ // localStorage wrapper functions // ============================================================================ let prefs, doSave; const defaults = { volume: .5, theme: "droppy", editorFontSize: droppy.detects.mobile ? 12 : 16, indentWithTabs: false, indentUnit: 4, lineWrapping: false, loop: true, autonext: false, sharelinkDownload: true, sortings: {}, }; function savePrefs(prefs) { try { localStorage.setItem("prefs", JSON.stringify(prefs)); } catch (err) { console.error(err); } } function loadPrefs() { let prefs; try { prefs = JSON.parse(localStorage.getItem("prefs")); } catch {} return prefs || defaults; } // Load prefs and set missing ones to their default prefs = loadPrefs(); Object.keys(defaults).forEach((pref) => { if (prefs[pref] === undefined) { doSave = true; prefs[pref] = defaults[pref]; } }); if (doSave) savePrefs(prefs); droppy.get = function(pref) { prefs = loadPrefs(); return prefs[pref]; }; droppy.set = function(pref, value) { prefs[pref] = value; savePrefs(prefs); }; droppy.del = function(pref) { delete prefs[pref]; savePrefs(prefs); }; // ============================================================================ // Entry point // ============================================================================ const type = document.body.dataset.type; if (type === "m") { render("main"); initMainPage(); } else { render("login", {first: type === "f"}); initAuthPage(type === "f"); } // ============================================================================ // <main> renderer // ============================================================================ function render(page, args) { $("main").replaceWith(Handlebars.templates[page](args)); } // ============================================================================ // View handling // ============================================================================ function getView(id) { return $(droppy.views[id]); } function getOtherViews(id) { return $(droppy.views.filter((_, i) => { return i !== id; })); } function getActiveView() { return $(droppy.views[droppy.activeView]); } function newView(dest, vId) { const view = $(Handlebars.templates.view()); getView(vId).remove(); droppy.views[vId] = view[0]; if (droppy.views.length > 1) { droppy.views.forEach((view) => { $(view).addClass(view.vId === 0 ? "left" : "right"); $(view).find(".newview svg").replaceWith(svg("window-cross")); $(view).find(".newview")[0].setAttribute("aria-label", "Close this view"); }); } view.appendTo("main"); view[0].vId = vId; view[0].uploadId = 0; if (dest) updateLocation(view, dest); initButtons(view); bindDropEvents(view); bindHoverEvents(view); allowDrop(view); checkClipboard(); droppy.views.forEach((view) => { checkPathOverflow($(view)); if (view.ps) view.ps.updateSize(true); }); return getView(vId); } function destroyView(vId) { getView(vId).remove(); droppy.views = droppy.views.filter((_, i) => { return i !== vId; }); droppy.views.forEach((view) => { $(view).removeClass("left right"); $(view).find(".newview svg").replaceWith(svg("window")); $(view).find(".newview")[0].setAttribute("aria-label", "Create new view"); checkPathOverflow($(view)); view.vId = 0; if (view.ps) view.ps.updateSize(true); }); sendMessage(vId, "DESTROY_VIEW"); } // ============================================================================ // WebSocket handling // ============================================================================ function init() { droppy.wsRetries = 5; // reset retries on connection loss if (!droppy.initialized) sendMessage(null, "REQUEST_SETTINGS"); if (droppy.queuedData) { sendMessage(); } else { // Create new view with initializing getLocationsFromHash().forEach((string, index) => { const dest = join(decodeURIComponent(string)); newView(dest, index); }); } } function openSocket() { droppy.socket = new WebSocket( `${window.location.origin.replace(/^http/, "ws") + window.location.pathname}!/socket` ); droppy.socket.addEventListener("open", (_event) => { if (droppy.token) { init(); } else { ajax({url: "!/token", headers: {"x-app": "droppy"}}).then((res) => { return res.text(); }).then((text) => { droppy.token = text; init(); }); } }); // https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Close_codes droppy.socket.addEventListener("close", (event) => { if (event.code === 4000) return; if (event.code === 1011) { droppy.token = null; openSocket(); } else if (event.code >= 1001 && event.code < 3999) { if (droppy.wsRetries > 0) { // Gracefully reconnect on abnormal closure of the socket, 1 retry every 4 seconds, 20 seconds total. // TODO: Indicate connection drop in the UI, especially on close code 1006 setTimeout(() => { openSocket(); droppy.wsRetries--; }, droppy.wsRetryTimeout); } } else if (droppy.reopen) { droppy.reopen = false; openSocket(); } }); droppy.socket.addEventListener("message", (event) => { const msg = JSON.parse(event.data); const vId = msg.vId; const view = getView(vId); droppy.socketWait = false; switch (msg.type) { case "UPDATE_DIRECTORY": { if (typeof view[0].dataset.type === "undefined" || view[0].switchRequest) { view[0].dataset.type = "directory"; // For initial loading } if (!view.length) return; if (view[0].dataset.type === "directory") { if (msg.folder !== getViewLocation(view)) { view[0].currentFile = null; view[0].currentFolder = msg.folder; if (view[0].vId === 0) setTitle(basename(msg.folder)); replaceHistory(view, join(view[0].currentFolder, view[0].currentFile)); updatePath(view); } view[0].switchRequest = false; view[0].currentData = msg.data; openDirectory(view, view[0].currentData); } else if (view[0].dataset.type === "media") { view[0].currentData = msg.data; // TODO: Update media array } break; } case "UPDATE_BE_FILE": { openFile(getView(vId), msg.folder, msg.file); break; } case "RELOAD": { if (msg.css) { $("#css").remove(); $(`<style id='css'>${msg.css}</style>`).appendTo($("head")); } else window.location.reload(true); break; } case "SHARELINK": { hideSpinner(view); if (view.find(".info-box.link.in").length) { view.find(".link-out")[0].textContent = getFullLink(msg.link); } else { showLink(view, msg.link, msg.attachement); } break; } case "USER_LIST": { updateUsers(msg.users); break; } case "SAVE_STATUS": { hideSpinner(view); const file = view.find(".path li:last-child"); file.removeClass("dirty").addClass(msg.status === 0 ? "saved" : "save-failed"); setTimeout(() => { file.removeClass("saved save-failed"); }, 3000); break; } case "SETTINGS": { Object.keys(msg.settings).forEach((setting) => { droppy[setting] = msg.settings[setting]; }); $("#about-title")[0].textContent = `droppy ${droppy.version}`; $("#about-engine")[0].textContent = droppy.engine; droppy.themes = droppy.themes.split("|"); droppy.modes = droppy.modes.split("|"); // Move own theme to top of theme list droppy.themes.pop(); droppy.themes.unshift("droppy"); // Insert plain mode on the top droppy.modes.unshift("plain"); if (droppy.dev) { window.droppy = droppy; } if (droppy.readOnly) { document.documentElement.classList.add("readonly"); } if (droppy.public) { document.documentElement.classList.add("public"); } if (!droppy.watch) { document.documentElement.classList.add("nowatch"); } break; } case "MEDIA_FILES": { loadMedia(view, msg.files); break; } case "SEARCH_RESULTS": { openDirectory(view, msg.results, true); break; } case "ERROR": { showError(view, msg.text); hideSpinner(view); break; } } }); } function sendMessage(vId, type, data) { const sendObject = {vId, type, data, token: droppy.token}; if (typeof sendObject.data === "string") { sendObject.data = normalize(sendObject.data); } else if (typeof sendObject.data === "object") { Object.keys(sendObject.data).forEach((key) => { if (typeof sendObject.data[key] === "string") { sendObject.data[key] = normalize(sendObject.data[key]); } }); } const json = JSON.stringify(sendObject); if (droppy.socket.readyState === 1) { // open // Lock the UI while we wait for a socket response droppy.socketWait = true; // Unlock the UI in case we get no socket resonse after waiting for 1 second setTimeout(() => { droppy.socketWait = false; }, 1000); if (droppy.queuedData) { droppy.socket.send(droppy.queuedData); droppy.queuedData = null; } droppy.socket.send(json); } else { // We can't send right now, so queue up the last added message to be sent later droppy.queuedData = json; if (droppy.socket.readyState === 2) { // closing // Socket is closing, queue a re-opening droppy.reopen = true; } else if (droppy.socket.readyState === 3) { // closed // Socket is closed, we can re-open it right now openSocket(); } } } // ============================================================================ // Authentication page // ============================================================================ function initAuthPage(firstrun) { $("#remember").off("click").on("click", function() { $(this).toggleClass("checked"); }); $("#form").off("submit").on("submit", (e) => { e.preventDefault(); ajax({ method: "POST", url: firstrun ? "!/adduser" : "!/login", data: { username: $("#user")[0].value, password: $("#pass")[0].value, remember: $("#remember").hasClass("checked"), path: getRootPath(), } }).then((res) => { if (res.status === 200) { render("main"); initMainPage(); } else { const info = $("#login-info-box"); info.textContent = firstrun ? "Please fill both fields." : "Wrong login!"; if (info.hasClass("error")) { info.addClass("shake"); setTimeout(() => { info.removeClass("shake"); }, 500); } else info[0].className = "error"; if (!firstrun) $("#pass")[0].focus(); } }); }); } // ============================================================================ // Main page // ============================================================================ function initMainPage() { droppy.initialized = false; // Open the WebSocket openSocket(); // Re-fit path line after 25ms of no resizing $(window).off("resize").on("resize", () => { clearTimeout(droppy.resizeTimer); droppy.resizeTimer = setTimeout(() => { $(".view").each(function() { checkPathOverflow($(this)); }); }, 25); }); Mousetrap.bind("escape", () => { // escape hides modals toggleCatcher(false); }).bind("mod+s", (e) => { // stop default browser save behaviour e.preventDefault(); }).bind(["space", "down", "right", "return"], () => { const view = getActiveView(); if (view[0].dataset.type === "media") view[0].ps.next(); }).bind(["shift+space", "up", "left", "backspace"], () => { const view = getActiveView(); if (view[0].dataset.type === "media") view[0].ps.prev(); }).bind(["alt+enter", "f"], () => { const view = getActiveView(); if (!view || view[0].dataset.type !== "media") return; screenfull.toggle(view.find(".content")[0]); }); // track active view $(window).on("click dblclick contextmenu", (e) => { const view = $(e.target).parents(".view"); if (!view.length) return; droppy.activeView = view[0].vId; toggleButtons(view, view[0].dataset.type); }); // handle pasting text and images in directory view window.addEventListener("paste", async e => { const view = getActiveView(); if (view[0].dataset.type !== "directory") return; if (e.clipboardData && e.clipboardData.items) { // modern browsers const texts = []; const images = []; for (const item of e.clipboardData.items) { if (item.kind === "file" || item.type.includes("image")) { images.push(item.getAsFile()); } } // this API is weirdly implemented in Chrome. A pasted image consists of two items, // if the text item is read first, the image item will not be available. for (const item of e.clipboardData.items) { if (item.kind === "string") { const text = await promisify(item.getAsString.bind(item))(); texts.push(new Blob([text], {type: "text/plain"})); } } // if a image is found, don't upload additional text blobs if (images.length) { images.forEach(image => uploadBlob(view, image)); } else { texts.forEach((text) => uploadBlob(view, text)); } } else if (e.clipboardData.types) { // Safari specific if (e.clipboardData.types.includes("text/plain")) { const blob = new Blob([e.clipboardData.getData("Text")], {type: "text/plain"}); uploadBlob(view, blob); $(".ce").empty(); if (droppy.savedFocus) droppy.savedFocus.focus(); } else { const start = performance.now(); (function findImages() { const images = $(".ce img"); if (!images.length && performance.now() - start < 5000) { return setTimeout(findImages, 25); } images.each(function() { urlToPngBlob(this.src, (blob) => { uploadBlob(view, blob); $(".ce").empty(); if (droppy.savedFocus) droppy.savedFocus.focus(); }); }); })(); } } }); // Hacks for Safari to be able to paste if (droppy.detects.safari) { $("body").append('<div class="ce" contenteditable>'); window.addEventListener("keydown", (e) => { if (e.metaKey && e.key === "v") { if (e.target.nodeName.toLowerCase() !== "input") { droppy.savedFocus = document.activeElement; $(".ce")[0].focus(); } } }); } screenfull.on("change", () => { // unfocus the fullscreen button so the space key won't un-toggle fullscreen document.activeElement.blur(); $("svg.fullscreen, svg.unfullscreen").replaceWith( svg(screenfull.isFullscreen ? "unfullscreen" : "fullscreen") ); }); initEntryMenu(); } // ============================================================================ // Upload functions // ============================================================================ function uploadBlob(view, blob) { const fd = new FormData(); let name = "pasted-"; if (blob.type.startsWith("image")) { name += `image-${dateFilename()}.${imgExtFromMime(blob.type)}`; } else { name += `text-${dateFilename()}.txt`; } fd.append("files[]", blob, name); upload(view, fd, [name]); } function upload(view, fd, files) { let rename = false; if (view[0].currentData && Object.keys(view[0].currentData).length) { let conflict = false; const existingFiles = Object.keys(view[0].currentData); files.some((file) => { if (existingFiles.includes(file)) { conflict = true; return true; } }); if (conflict) { rename = !window.confirm("Some of the uploaded files already exist. Overwrite them?"); } } if (!files || !files.length) return showError(view, "Unable to upload."); const id = view[0].uploadId += 1; const xhr = new XMLHttpRequest(); // Render upload bar $(Handlebars.templates["upload-info"]({ id, title: files.length === 1 ? basename(files[0]) : `${files.length} files`, })).appendTo(view).transition("in").find(".upload-cancel").off("click").on("click", () => { xhr.abort(); uploadCancel(view, id); }); // Create the XHR2 and bind the progress events xhr.upload.addEventListener("progress", throttle(e => { if (e && e.lengthComputable) uploadProgress(view, id, e.loaded, e.total); }, 100)); xhr.upload.addEventListener("error", () => { showError(view, "An error occurred during upload."); uploadCancel(view, id); }); xhr.addEventListener("readystatechange", () => { if (xhr.readyState !== 4) return; if (xhr.status === 200) { uploadSuccess(id); } else if (xhr.status === 400) { // generic client error uploadCancel(view, id); } else { if (xhr.status === 0) return; // cancelled by user showError(view, `Server responded with HTTP ${xhr.status}`); uploadCancel(view, id); } uploadFinish(view, id); }); view[0].isUploading = true; view[0].uploadStart = performance.now(); xhr.open("POST", `${getRootPath()}!/upload?vId=${view[0].vId }&to=${encodeURIComponent(view[0].currentFolder) }&rename=${rename ? "1" : "0"}` ); xhr.responseType = "text"; xhr.send(fd); } function uploadSuccess(id) { const info = $(`.upload-info[data-id="${id}"]`); info.find(".upload-bar")[0].style.width = "100%"; info.find(".upload-percentage")[0].textContent = "100%"; info.find(".upload-title")[0].textContent = "Processing ..."; } function uploadCancel(view, id) { uploadFinish(view, id, true); } function uploadFinish(view, id, cancelled) { view[0].isUploading = false; setTitle(basename(view[0].currentFolder)); $(`.upload-info[data-id="${id}"]`).removeClass("in").transitionend(function() { $(this).remove(); }); if (!cancelled) { showNotification("Upload finished", `Upload to ${view[0].currentFolder} has finished!`); } } function uploadProgress(view, id, sent, total) { if (!view[0].isUploading) return; const info = $(`.upload-info[data-id="${id}"]`); const progress = `${(Math.round((sent / total) * 1000) / 10).toFixed(0)}%`; const now = performance.now(); const speed = sent / ((now - view[0].uploadStart) / 1e3); const elapsed = now - view[0].uploadStart; const secs = ((total / (sent / elapsed)) - elapsed) / 1000; if (Number(view.find(".upload-info")[0].dataset.id) === id) setTitle(progress); info.find(".upload-bar")[0].style.width = progress; info.find(".upload-percentage")[0].textContent = progress; info.find(".upload-time")[0].textContent = [ secs > 60 ? `${Math.ceil(secs / 60)} mins` : `${Math.ceil(secs)} secs`, `${formatBytes(Math.round(speed / 1e3) * 1e3)}/s`, ].join(" @ "); } // ============================================================================ // General helpers // ============================================================================ function entryRename(view, entry, wasEmpty, callback) { // Populate active files list const activeFiles = []; // TODO: Update when files change entry.siblings(".data-row").each(function() { // exclude existing entry for case-only rename $(this).removeClass("editing invalid"); const name = droppy.caseSensitive ? this.dataset.name : this.dataset.name.toLowerCase(); if (name) activeFiles.push(name); }); // Hide menu, overlay and the original link, stop any previous edits toggleCatcher(false); const link = entry.find(".entry-link"); const linkText = link[0].textContent; let canSubmit = validFilename(linkText, droppy.platform); entry.addClass("editing"); // Add inline element const renamer = $(`<input type="text" class="inline-namer" value="${linkText }" placeholder="${linkText}">`).insertAfter(link); renamer.off("input").on("input", function() { const input = this.value; const valid = validFilename(input, droppy.platform); const exists = activeFiles.some((file) => { return file === (droppy.caseSensitive ? input : input.toLowerCase()); }); canSubmit = valid && !exists; entry[canSubmit ? "removeClass" : "addClass"]("invalid"); }).off("blur focusout").on("blur focusout", submitEdit.bind(null, view, true, callback)); const nameLength = linkText.lastIndexOf("."); renamer[0].setSelectionRange(0, nameLength > -1 ? nameLength : linkText.length); renamer[0].focus(); Mousetrap(renamer[0]) .bind("escape", stopEdit.bind(null, view, entry, wasEmpty)) .bind("return", submitEdit.bind(null, view, false, callback)); function submitEdit(view, skipInvalid, callback) { let success; const oldVal = renamer[0].getAttribute("placeholder"); const newVal = renamer[0].value; if (canSubmit) { success = true; stopEdit(view, entry, wasEmpty); } else if (!skipInvalid) { renamer.addClass("shake"); setTimeout(() => { renamer.removeClass("shake"); }, 500); } else { success = false; stopEdit(view, entry, wasEmpty); } if (typeof success === "boolean" && typeof callback === "function") { callback(success, join(view[0].currentFolder, oldVal), join(view[0].currentFolder, newVal)); } } } function stopEdit(view, entry, wasEmpty) { entry.removeClass("editing invalid"); view.find(".inline-namer, .data-row.new-file, .data-row.new-folder").remove(); if (wasEmpty) view.find(".content").html(Handlebars.templates.directory({entries: []})); } function toggleCatcher(show) { const cc = $("#overlay"), modals = ["#prefs-box", "#about-box", "#entry-menu", "#drop-select", ".info-box"]; if (show === undefined) { show = modals.some((selector) => { return $(selector).hasClass("in"); }); } if (!show) { modals.forEach((selector) => { $(selector)[show ? "addClass" : "removeClass"]("in"); }); $(".data-row.active").removeClass("active"); } cc.off("click").on("click", toggleCatcher.bind(null, false)); cc[show ? "addClass" : "removeClass"]("in"); } // Update the page title function setTitle(text) { document.title = `${text || "/"} - droppy`; } // Listen for popstate events, which indicate the user navigated back $(window).off("popstate").on("popstate", () => { if (!droppy.socket) return; const locs = getLocationsFromHash(); droppy.views.forEach((view) => { const dest = locs[view.vId]; view.switchRequest = true; setTimeout(() => { view.switchRequest = false; }, 1000); if (dest) updateLocation($(view), dest, true); }); }); function getViewLocation(view) { if (view[0].currentFolder === undefined) { return ""; // return an empty string so animDirection gets always set to 'forward' on launch } else { return join(view[0].currentFolder, view[0].currentFile); } } function getLocationsFromHash() { const locations = window.location.hash.split("#"); locations.shift(); if (locations.length === 0) { locations.push(""); } locations.forEach((part, i) => { locations[i] = part.replace(/\/*$/g, ""); if (locations[i] === "") locations[i] = "/"; }); return locations; } function getHashPaths(modview, dest) { let path = window.location.pathname; droppy.views.forEach((view) => { view = $(view); if (modview && modview.is(view)) { path += `/#${dest}`; } else { path += `/#${getViewLocation(view)}`; } }); return path.replace(/\/+/g, "/"); } function pushHistory(view, dest) { window.history.pushState(null, null, getHashPaths(view, dest)); } function replaceHistory(view, dest) { window.history.replaceState(null, null, getHashPaths(view, dest)); } // Update our current location and change the URL to it function updateLocation(view, destination, skipPush) { if (typeof destination.length !== "number") throw new Error("Destination needs to be string or array"); // Queue the folder switching if we are mid-animation or waiting for the server function sendReq(view, viewDest, time) { (function queue(time) { if ((!droppy.socketWait && !view[0].isAnimating) || time > 2000) { const viewLoc = getViewLocation(view); showSpinner(view); // Find the direction in which we should animate if (!viewLoc || viewDest.length === viewLoc.length) { view[0].animDirection = "center"; } else if (viewDest.length > viewLoc.length) { view[0].animDirection = "forward"; } else { view[0].animDirection = "back"; } sendMessage(view[0].vId, "REQUEST_UPDATE", viewDest); // Skip the push if we're already navigating through history if (!skipPush) pushHistory(view, viewDest); } else setTimeout(queue, 50, time + 50); })(time); } if (view === null) { // Only when navigating backwards for (let i = destination.length - 1; i >= 0; i--) { if (destination[i].length && getViewLocation(getView(i)) !== destination[i]) { sendReq(getView(i), destination[i], 0); } } } else if (droppy.views[view[0].vId]) sendReq(view, destination, 0); } // Update the path indicator function updatePath(view) { let oldParts, pathStr = ""; let i = 1; // Skip the first element as it's always the same const parts = join(view[0].currentFolder).split("/"); if (parts[parts.length - 1] === "") parts.pop(); if (view[0].currentFile !== null) parts.push(view[0].currentFile); parts[0] = svg("home"); // Replace empty string with our home icon if (view[0].savedParts) { oldParts = view[0].savedParts; while (parts[i] || oldParts[i]) { pathStr += `/${parts[i]}`; if (parts[i] !== oldParts[i]) { if (!parts[i] && oldParts[i] !== parts[i]) { // remove this part removePart(i); } else if (!oldParts[i] && oldParts[i] !== parts[i]) { // Add a part addPart(parts[i], pathStr); } else { // rename part const part = $(view.find(".path li")[i]); part.html(`<a>${parts[i]}</a>${svg("triangle")}`); part[0].dataset.destination = pathStr; } } i++; } } else { addPart(parts[0], "/"); for (let len = parts.length; i < len; i++) { pathStr += `/${parts[i]}`; addPart(parts[i], pathStr); } } view.find(".path li:not(.gone)").transition("in"); setTimeout(() => {checkPathOverflow(view); }, 400); view[0].savedParts = parts; function addPart(name, path) { const li = $(`<li><a>${name}</a></li>`); li[0].dataset.destination = path; li.off("click").on("click", function(event) { const view = $(event.target).parents(".view"); if (droppy.socketWait) return; if ($(this).is(":last-child")) { if ($(this).parents(".view")[0].dataset.type === "directory") { updateLocation(view, this.dataset.destination); } } else { view[0].switchRequest = true; // This is set so we can switch out of a editor view updateLocation(view, this.dataset.destination); } setTimeout(() => {checkPathOverflow(view); }, 400); }); view.find(".path").append(li); li.append(svg("triangle")); } function removePart(i) { view.find(".path li").slice(i).replaceClass("in", "gone").transitionend(function() { $(this).remove(); }); } } // Check if the path indicator overflows and scroll it if necessary function checkPathOverflow(view) { let width = 40; const space = view[0].clientWidth; view.find(".path li.in").each(function() { width += $(this)[0].clientWidth; }); view.find(".path li").each(function() { this.style.left = width > space ? `${space - width}px` : 0; }); } function getTemplateEntries(view, data) { const entries = []; Object.keys(data).forEach((name) => { const split = data[name].split("|"); const type = split[0]; const mtime = Number(split[1]) * 1e3; const size = Number(split[2]); name = normalize(name); const entry = { name, sortname: name.replace(/['"]/g, "_").toLowerCase(), type, mtime, age: timeDifference(mtime), size, psize: formatBytes(size), id: ((view[0].currentFolder === "/") ? "/" : `${view[0].currentFolder}/`) + name, sprite: getSpriteClass(fileExtension(name)), classes: "", }; if (Object.keys(droppy.audioTypes).includes(fileExtension(name))) { entry.classes = "playable"; entry.playable = true; } else if (Object.keys(droppy.videoTypes).includes(fileExtension(name))) { entry.classes = "viewable viewable-video"; entry.viewableVideo = true; } else if (Object.keys(droppy.imageTypes).includes(fileExtension(name))) { entry.classes = "viewable viewable-image"; entry.viewableImage = true; } else if (fileExtension(name) === "pdf") { entry.classes = "viewable viewable-pdf"; entry.viewablePdf = true; } entries.push(entry); }); return entries; } // Convert the received data into HTML function openDirectory(view, data, isSearch) { let entries = view[0].templateEntries = getTemplateEntries(view, data || []); clearSearch(view); // sorting const sortings = droppy.get("sortings"); const savedSorting = sortings[view[0].currentFolder]; view[0].sortBy = savedSorting ? savedSorting.sortBy : "name"; view[0].sortAsc = savedSorting ? savedSorting.sortAsc : false; const sortBy = view[0].sortBy === "name" ? "type" : view[0].sortBy; entries = sortArrayByProp(entries, sortBy); if (view[0].sortAsc) entries.reverse(); const sort = {type: "", mtime: "", size: ""}; sort[sortBy] = `active ${view[0].sortAsc ? "up" : "down"}`; const html = Handlebars.templates.directory({entries, sort, isSearch}); loadContent(view, "directory", null, html).then(() => { // Upload button on empty page view.find(".empty").off("click").on("click", (e) => { const view = $(e.target).parents(".view"); const inp = view.find(".file"); if (droppy.detects.directoryUpload) { droppy.dir.forEach((attr) => { inp[0].removeAttribute(attr); }); } inp[0].click(); }); // Switch into a folder view.find(".folder-link").off("click").on("click", function(e) { if (droppy.socketWait) return; updateLocation(view, $(this).parents(".data-row")[0].dataset.id); e.preventDefault(); }); // Click on a file link view.find(".file-link").off("click").on("click", function(e) { if (droppy.socketWait) return; const view = $(e.target).parents(".view"); openFile(view, view[0].currentFolder, e.target.textContent.trim(), {ref: this}); e.preventDefault(); }); view.find(".data-row").each(function(index) { this.setAttribute("order", index); }); view.find(".data-row").off("contextmenu").on("contextmenu", (e) => { const target = $(e.currentTarget); if (target[0].dataset.type === "error") return; showEntryMenu(target, e.clientX, e.clientY); e.preventDefault(); }); view.find(".data-row .entry-menu").off("click").on("click", (e) => { showEntryMenu($(e.target).parents(".data-row"), e.clientX, e.clientY); }); // Stop navigation when clicking on an <a> view.find(".data-row .zip, .data-row .download, .entry-link.file").off("click").on("click", (e) => { e.stopPropagation(); if (droppy.socketWait) return; // Some browsers (like IE) think that clicking on an <a> is real navigation // and will close the WebSocket in turn. We'll reconnect if necessary. // Firefox is not affected as long as the <a> bears a `download` attribute, // if it's missing it will disconnect a WebSocket as long as // https://bugzilla.mozilla.org/show_bug.cgi?id=896666 is not fixed. droppy.reopen = true; setTimeout(() => { droppy.reopen = false; }, 2000); }); view.find(".share-file").off("click").on("click", function() { if (droppy.socketWait) return; requestLink( $(this).parents(".view"), $(this).parents(".data-row")[0].dataset.id, droppy.get("sharelinkDownload") ); }); view.find(".delete-file").off("click").on("click", function() { if (droppy.socketWait) return; showSpinner(view); sendMessage(view[0].vId, "DELETE_FILE", $(this).parents(".data-row")[0].dataset.id); }); view.find(".icon-play, .icon-view").off("click").on("click", function() { $(this).parents(".data-row").find(".file-link")[0].click(); }); view.find(".header-name, .header-mtime, .header-size").off("click").on("click", function() { sortByHeader(view, $(this)); }); hideSpinner(view); }); } // Load new view content function loadContent(view, type, mediaType, content) { return new Promise(((resolve) => { if (view[0].isAnimating) return; // Ignore mid-animation updates. TODO: queue and update on animation-end view[0].dataset.type = type; mediaType = mediaType ? ` type-${mediaType}` : ""; content = `<div class="new content ${type}${mediaType} ${view[0].animDirection}">${content}</div>`; const navRegex = /(forward|back|center)/; if (view[0].animDirection === "center") { view.find(".content").replaceClass(navRegex, "center").before(content); view.find(".new").addClass(type); finish(); } else { view.children(".content-container").append(content); view[0].isAnimating = true; view.find(".data-row").addClass("animating"); view.find(".content:not(.new)").replaceClass(navRegex, (view[0].animDirection === "forward") ? "back" : (view[0].animDirection === "back") ? "forward" : "center"); getOtherViews(view[0].vId).each(function() { this.style.zIndex = "1"; }); view.find(".new").addClass(type).transition(navRegex, "center").transitionend(finish); } view[0].animDirection = "center"; function finish() { view[0].isAnimating = false; getOtherViews(view[0].vId).each(function() { this.style.zIndex = "auto"; }); view.find(".content:not(.new)").remove(); view.find(".new").removeClass("new"); view.find(".data-row").removeClass("animating"); if (view[0].dataset.type === "directory") { bindDragEvents(view); } toggleButtons(view, type); resolve(); } })); } function toggleButtons(view, type) { view.find(".af, .ad, .cf, .cd")[type === "directory" ? "removeClass" : "addClass"]("disabled"); } function handleDrop(view, event, src, dst, spinner) { const dropSelect = $("#drop-select"), dragAction = view[0].dragAction; droppy.dragTimer.clear(); delete view[0].dragAction; $(".dropzone").removeClass("in"); if (dragAction === "copy" || event.ctrlKey || event.metaKey || event.altKey) { sendDrop(view, "copy", src, dst, spinner); } else if (dragAction === "cut" || event.shiftKey) { sendDrop(view, "cut", src, dst, spinner); } else { const x = event.originalEvent.clientX, y = event.originalEvent.clientY; // Keep the drop-select in view const limit = dropSelect[0].offsetWidth / 2 - 20; let left; if (x < limit) { left = x + limit; } else if (x + limit > window.innerWidth) { left = x - limit; } else { left = x; } dropSelect[0].style.left = `${left}px`; dropSelect[0].style.top = `${event.originalEvent.clientY}px`; dropSelect.addClass("in"); $(document.elementFromPoint(x, y)).addClass("active").one("mouseleave", function() { $(this).removeClass("active"); }); toggleCatcher(true); dropSelect.children(".movefile").off("click").one("click", () => { sendDrop(view, "cut", src, dst, spinner); toggleCatcher(false); }); dropSelect.children(".copyfile").off("click").one("click", () => { sendDrop(view, "copy", src, dst, spinner); toggleCatcher(false); }); dropSelect.children(".viewfile").off("click").one("click", () => { updateLocation(view, src); toggleCatcher(false); }); return; } } function sendDrop(view, type, src, dst, spinner) { if (src !== dst || type === "copy") { if (spinner) showSpinner(view); sendMessage(view[0].vId, "CLIPBOARD", { type, src, dst }); } } // Set drag properties for internal drag sources function bindDragEvents(view) { view.find(".data-row .entry-link").each(function() { this.setAttribute("draggable", "true"); }); view.off("dragstart").on("dragstart", (event) => { const row = $(event.target).hasClass("data-row") ? $(event.target) : $(event.target).parents(".data-row"); if (event.ctrlKey || event.metaKey || event.altKey) { view[0].dragAction = "copy"; } else if (event.shiftKey) { view[0].dragAction = "cut"; } droppy.dragTimer.refresh(row[0].dataset.id); event.originalEvent.dataTransfer.setData("text", JSON.stringify({ type: row[0].dataset.type, path: row[0].dataset.id, })); event.originalEvent.dataTransfer.effectAllowed = "copyMove"; if ("setDragImage" in event.originalEvent.dataTransfer) { event.originalEvent.dataTransfer.setDragImage(row.find(".sprite")[0], 0, 0); } }); } function DragTimer() { this.timer = null; this.data = ""; this.isInternal = false; this.refresh = function(data) { if (typeof data === "string") { this.data = data; this.isInternal = true; } clearTimeout(this.timer); this.timer = setTimeout(this.clear, 1000); }; this.clear = function() { if (!this.isInternal) { $(".dropzone").removeClass("in"); } clearTimeout(this.timer); this.isInternal = false; this.data = ""; }; } droppy.dragTimer = new DragTimer(); function allowDrop(el) { el.off("dragover").on("dragover", (e) => { e.preventDefault(); droppy.dragTimer.refresh(); }); } function bindHoverEvents(view) { const dropZone = view.find(".dropzone"); view.off("dragenter").on("dragenter", (event) => { event.stopPropagation(); droppy.activeView = view[0].vId; const isInternal = event.originalEvent.dataTransfer.effectAllowed === "copyMove"; let icon; if (view[0].dataset.type === "directory" && isInternal) { icon = "menu"; } else if (!isInternal) { icon = "upload-cloud"; } else { icon = "open"; } view.find(".dropzone svg").replaceWith(svg(icon)); if (!dropZone.hasClass("in")) dropZone.addClass("in"); getOtherViews($(event.target).parents(".view")[0].vId).find(".dropzone").removeClass("in"); }); } function bindDropEvents(view) { // file drop (new Uppie())(view[0], (e, fd, files) => { if (!files.length) return; if (droppy.readOnly) return showError(view, "Files are read-only."); e.stopPropagation(); if (!validateFiles(files, view)) return; upload(view, fd, files); }); // drag between views view.off("drop").on("drop", (e) => { const view = $(e.target).parents(".view"); let dragData = e.originalEvent.dataTransfer.getData("text"); e.preventDefault(); $(".dropzone").removeClass("in"); if (!dragData) return; e.stopPropagation(); dragData = JSON.parse(dragData); if (view[0].dataset.type === "directory") { // dropping into a directory view handleDrop(view, e, dragData.path, join(view[0].currentFolder, basename(dragData.path)), true); } else { // dropping into a document/media view if (dragData.type === "folder") { view[0].dataset.type = "directory"; updateLocation(view, dragData.path); } else { if (join(view[0].currentFolder, view[0].currentFile) !== dragData.path) { openFile(view, dirname(dragData.path), basename(dragData.path)); } } } }); } function initButtons(view) { // Init upload <input> view[0].fileInput = view.find(".file")[0]; (new Uppie())(view[0].fileInput, (e, fd, files) => { const view = $(e.target).parents(".view"); e.preventDefault(); e.stopPropagation(); if (!validateFiles(files, view)) return; upload(view, fd, files); view[0].fileInput.value = ""; }); // File upload button view.off("click", ".af").on("click", ".af", function(e) { if ($(this).hasClass("disabled")) return; const view = $(e.target).parents(".view"); // Remove the directory attributes so we get a file picker dialog if (droppy.detects.directoryUpload) { droppy.dir.forEach((attr) => { view[0].fileInput.removeAttribute(attr); }); } view[0].fileInput.click(); }); // Disable the button when no directory upload is supported if (droppy.detects.directoryUpload) { view.find(".ad").addClass("disabled"); } // Directory upload button view.off("click", ".ad").on("click", ".ad", function(e) { const view = $(e.target).parents(".view"); if ($(this).hasClass("disabled")) { showError(getView(0), "Your browser doesn't support directory uploading"); } else { // Set the directory attribute so we get a directory picker dialog droppy.dir.forEach((attr) => { view[0].fileInput.setAttribute(attr, attr); }); // Click the button to trigger a dialog if (view[0].fileInput.isFilesAndDirectoriesSupported) { view[0].fileInput.click(); } else if (view[0].fileInput.chooseDirectory) { view[0].fileInput.chooseDirectory(); } else { view[0].fileInput.click(); } } }); view.off("click", ".cf, .cd").on("click", ".cf, .cd", function(e) { if ($(this).hasClass("disabled")) return; const view = $(e.target).parents(".view"); const content = view.find(".content"); const isFile = this.classList.contains("cf"); const isEmpty = Boolean(view.find(".empty").length); const html = Handlebars.templates[isFile ? "new-file" : "new-folder"](); stopEdit(view, view.find(".editing"), isEmpty); if (isEmpty) content.html(Handlebars.templates["file-header"]()); content.prepend(html); content[0].scrollTop = 0; const dummy = $(`.data-row.new-${isFile ? "file" : "folder"}`); entryRename(view, dummy, isEmpty, (success, _oldVal, newVal) => { if (!success) return; if (view[0].dataset.type === "directory") showSpinner(view); sendMessage(view[0].vId, `CREATE_${isFile ? "FILE" : "FOLDER"}`, newVal); }); }); view.off("click", ".newview").on("click", ".newview", () => { if (droppy.views.length === 1) { const dest = join(view[0].currentFolder, view[0].currentFile); replaceHistory(newView(dest, 1), dest); } else { destroyView(view[0].vId); replaceHistory(view, join(view[0].currentFolder, view[0].currentFile)); } }); view.off("click", ".about").on("click", ".about", () => { $("#about-box").addClass("in"); toggleCatcher(); }); view.off("click", ".prefs").on("click", ".prefs", () => { showPrefs(); if (droppy.priv) sendMessage(null, "GET_USERS"); }); view.off("click", ".reload").on("click", ".reload", () => { if (droppy.socketWait) return; showSpinner(view); sendMessage(view[0].vId, "RELOAD_DIRECTORY", { dir: view[0].currentFolder }); }); view.off("click", ".logout").on("click", ".logout", () => { ajax({ method: "POST", url: "!/logout", data: { path: getRootPath(), }, }).then(() => { droppy.socket.close(4000); render("login"); initAuthPage(); }); }); // Search Box function doSearch(e) { if (e.target.value && String(e.target.value).trim()) { sendMessage(view[0].vId, "SEARCH", { query: e.target.value, dir: view[0].currentFolder, }); } else { openDirectory(view, view[0].currentData); } } view.off("click", ".search.toggled-off").on("click", ".search.toggled-off", function() { const search = $(this); search.removeClass("toggled-off").addClass("toggled-on"); setTimeout(() => { search.find("input")[0].focus(); }, 0); }); view.off("click", ".search.toggled-on svg").on("click", ".search.toggled-on svg", function() { const view = $(this).parents(".view"); openDirectory(view, view[0].currentData); }); view.off("keyup", ".search input").on("keyup", ".search input", function(e) { if (e.keyCode === 27/* escape */) { const view = $(this).parents(".view"); openDirectory(view, view[0].currentData); this.value = ""; $(this).parent().removeClass("toggled-on").addClass("toggled-off"); } else if (e.keyCode === 13/* return */) { doSearch(e); } }); view.off("input", ".search input").on("input", ".search input", debounce(doSearch, 1000)); view.off("click", ".globalsearch input").on("click", ".globalsearch input", (e) => { e.stopPropagation(); }); } function initEntryMenu() { // Play an audio file $("#entry-menu .play").off("click").on("click", (event) => { event.stopPropagation(); const entry = $(`.data-row[data-id="${droppy.menuTargetId}"]`); const view = entry.parents(".view");