diff --git a/src/pages/fileBrowser/NavStack.js b/src/pages/fileBrowser/NavStack.js new file mode 100644 index 000000000..29230e770 --- /dev/null +++ b/src/pages/fileBrowser/NavStack.js @@ -0,0 +1,135 @@ +import Url from "utils/Url"; + +/** + * @typedef {{url: string, name: string}} Location + */ + +export default class NavStack extends EventTarget { + static { + Object.defineProperty(this.prototype, Symbol.toStringTag, { + value: "NavStack", + configurable: true, + }); + } + + get length() { + return this.#arr.length; + } + toJSON() { + return this.#arr.map((obj) => ({ ...obj })); + } + on() { + return this.addEventListener(...arguments); + } + off() { + return this.removeEventListener(...arguments); + } + + /** @type {null | { added: Map, removed: Set }} */ + #updatedURLs; + #queueUpdateEvent() { + if (this.#updatedURLs) return; + const added = new Map(); + const removed = new Set(); + this.#updatedURLs = Object.freeze({ added, removed }); + queueMicrotask(() => { + this.#updatedURLs = null; + this.dispatchEvent( + new CustomEvent("update", { + detail: Object.freeze({ + get added() { + return added.entries(); + }, + get removed() { + return removed.values(); + }, + }), + }), + ); + }); + } + + /** @type {Set} */ + #urlSet = new Set(); + /** @type {Array} */ + #arr = []; + /** + * @param {{ url: string, name?: string } | string} url + * @param {string} [name] + */ + push(url, name) { + if (typeof url === "object") ({ url, name } = url); + if (!(url = `${url ?? ""}`)) { + throw new TypeError( + "NavStack.prototype.push(" + + "url: { url: string, name?: string } | string, name?: string): \n" + + '"url" is either missing, null or undefined, or resolves to an empty string.', + ); + } + const urlSet = this.#urlSet; + if (urlSet.has(url)) return; + urlSet.add(url); + name = `${name ?? ""}` || Url.basename(url) || url; + const arr = this.#arr; + const i = arr.length; + arr[i] = { url, name }; + + this.#queueUpdateEvent(); + const { added, removed } = this.#updatedURLs; + if (removed.has(url)) removed.delete(url); + else added.set(url, { name, index: i }); + } + /** + * @param {string} [url] + */ + #popUntil(url) { + const urlSet = this.#urlSet; + const arr = this.#arr; + for (let i = arr.length - 1; i >= 0; i--) { + const item = arr[i]; + const url2 = item.url; + if (url && url === url2) return; + this.#urlSet.delete(url2); + arr.length = i; + + this.#queueUpdateEvent(); + const { added, removed } = this.#updatedURLs; + if (!added.has(url2)) removed.add(url2); + else added.delete(url2); + + if (!url) return; + } + } + /** + * @param {string} url + */ + popUntil(url) { + if ((url = `${url ?? ""}`)) return this.#popUntil(url); + throw new TypeError( + "NavStack.prototype.popUntil(url: string): \n" + + '"url" is either missing, null or undefined, or resolves to an empty string.', + ); + } + pop() { + return this.#popUntil(); + } + /** + * @param {number} i + * @returns {Location} + */ + get(i) { + if ((i = +i) !== i) { + throw new TypeError( + 'NavStack.prototype.get(i: number): "i" is either missing or resolves to NaN.', + ); + } + const arr = this.#arr; + const l = arr.length; + if (i < 0) i += l; + if (i < 0 || i > l - 1) return; + return { ...arr[i] }; + } + has(url) { + return this.#urlSet.has(`${url ?? ""}`); + } +} diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index b26758b18..a34ad61d9 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -33,10 +33,11 @@ import _addMenu from "./add-menu.hbs"; import _addMenuHome from "./add-menu-home.hbs"; import _template from "./fileBrowser.hbs"; import _list from "./list.hbs"; +import NavStack from "./NavStack"; import util from "./util"; /** - * @typedef {{url: String, name: String}} Location + * @typedef {import("./NavStack.js").Location} Location */ /** @@ -58,11 +59,10 @@ import util from "./util"; function FileBrowserInclude(mode, info, doesOpenLast = true) { mode = mode || "file"; + const navStack = new NavStack(); const IS_FOLDER_MODE = ["folder", "both"].includes(mode); const IS_FILE_MODE = ["file", "both"].includes(mode); const storedState = helpers.parseJSON(localStorage.fileBrowserState) || []; - /**@type {Array} */ - const state = []; /**@type {Array} */ const allStorages = []; let storageList = helpers.parseJSON(localStorage.storageList); @@ -638,7 +638,33 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { document.removeEventListener("resume", reload); }; + /** @type {Map} */ + const navBarEls = new Map(); + const saveFileBrowserState = doesOpenLast + ? () => (localStorage.fileBrowserState = JSON.stringify(navStack)) + : null; + navStack.addEventListener("update", (ev) => { + saveFileBrowserState?.(); + const { added, removed } = ev.detail; + for (const url of removed) { + actionStack.remove(url); + navBarEls.get(url)?.remove(); + navBarEls.delete(url); + } + for (const [url, { name, index: i }] of added) { + const prevDir = i && navStack.get(i - 1); + if (prevDir && !actionStack.has(url)) { + actionStack.push({ + id: url, + action: () => navigate(prevDir), + }); + } + pushToNavbar(name, url); + } + }); + if (doesOpenLast && storedState.length) { + navStack.push("/", "/"); loadStates(storedState); return; } @@ -1507,60 +1533,19 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { * @param {String} url * @param {String} name */ - async function navigate(url, name, assignBackButton = true) { + async function navigate(url, name) { + if (typeof url === "object") ({ url, name } = url); + if (document.getElementById("search-bar")) { hideSearchBar(); } - if (!url) { - throw new Error('navigate(url, name): "url" is required.'); - } - - if (!name) { - throw new Error('navigate(url, name): "name" is required.'); - } - - if (url === "/") { - if (IS_FOLDER_MODE) $openFolder.disabled = true; - } else { - if (IS_FOLDER_MODE) $openFolder.disabled = false; - } - - const $nav = tag.get(`#${getNavId(url)}`); - //If navigate to previous directories, clear the rest navigation - if ($nav) { - let $topNav; - while (($topNav = $navigation.lastChild) !== $nav) { - const url = $topNav.dataset.url; - actionStack.remove(url); - $topNav.remove(); - } - - while (1) { - const location = state.slice(-1)[0]; - if (!location || location.url === url) break; - state.pop(); - } - localStorage.fileBrowserState = JSON.stringify(state); - - const dir = await getDir(url, name); - if (dir) { - render(dir); - } - return; - } + const inStack = navStack.has(url); + if (inStack) navStack.popUntil(url); const dir = await getDir(url, name); if (dir) { - const { url: curl, name: cname } = currentDir; - let action; - if (doesOpenLast) pushState({ name, url }); - if (curl && cname && assignBackButton) { - action = () => { - navigate(curl, cname, false); - }; - } - pushToNavbar(name, url, action); + if (!inStack) navStack.push(url, name); render(dir); } } @@ -1670,33 +1655,29 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } /** - * Pushes a navigation button to navbar - * @param {String} id - * @param {String} name - * @param {String} url + * Pushes a navigation button to navbar + * @param {string} name + * @param {string} url */ - function pushToNavbar(name, url, action) { - if (!url) return; - const displayName = name || Url.basename(url) || url; - $navigation.append( - , - ); - $navigation.scrollLeft = $navigation.scrollWidth; - - if (action && !actionStack.has(url)) { - actionStack.push({ - id: url, - action, - }); + function pushToNavbar(name, url) { + /** @type {HTMLSpanElement} */ + let el = navBarEls.get(url); + if (!el) { + el = ( + + ); + navBarEls.set(url, el); } + $navigation.append(el); + $navigation.scrollLeft = $navigation.scrollWidth; } /** @@ -1705,37 +1686,15 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { */ function loadStates(states) { if (!Array.isArray(states) || !states.length) return; - - const backNavigation = []; - const lastState = states.pop(); - if (!lastState || !lastState.url) return; - const { url } = lastState; - const name = lastState.name || Url.basename(url) || url; - let { url: lastUrl, name: lastName } = currentDir; - while (states.length) { - const location = states.splice(0, 1)[0]; - if (!location || !location.url) { - continue; - } - const { url, name } = location; - let action; - - if (doesOpenLast) pushState({ name, url }); - if (lastUrl && lastName) { - backNavigation.push([lastUrl, lastName]); - action = () => { - const [url, name] = backNavigation.pop(); - navigate(url, name, false); - }; + try { + navStack.push(states.shift()); + } catch (err) { + console.error(err); } - pushToNavbar(name, url, action); - lastUrl = url; - lastName = name; } - - currentDir = { url: lastUrl, name: lastName }; - navigate(url, name); + const dir = navStack.get(-1); + if (dir) navigate(dir); } /** @@ -1772,7 +1731,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { } function render(dir) { - const { list, scroll } = dir; + const { url, list, scroll } = dir; const $list = helpers.parseHTML( mustache.render(_list, { msg: strings["empty folder message"], @@ -1780,6 +1739,8 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { }), ); + if (IS_FOLDER_MODE) $openFolder.disabled = (url || "/") === "/"; + if (document.getElementById("search-bar")) { hideSearchBar(); } @@ -1797,7 +1758,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { $list.focus(); currentDir = dir; - cachedDir[dir.url] = dir; + cachedDir[url] = dir; updatePasteToggler(); } @@ -1807,13 +1768,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { navigate(url, name); } - function pushState({ url, name }) { - if (!url || !name) return; - if (state.find((l) => l.url === url)) return; - state.push({ url, name }); - localStorage.fileBrowserState = JSON.stringify(state); - } - /** * Adds a new storage and refresh location */