/** * @typedef {ApplicationOptions} FilePickerOptions * @property {"image"|"audio"|"video"|"text"|"imagevideo"|"font"|"folder"|"any"} [type="any"] A type of file to target * @property {string} [current] The current file path being modified, if any * @property {string} [activeSource=data] A current file source in "data", "public", or "s3" * @property {Function} [callback] A callback function to trigger once a file has been selected * @property {boolean} [allowUpload=true] A flag which permits explicitly disallowing upload, true by default * @property {HTMLElement} [field] An HTML form field that the result of this selection is applied to * @property {HTMLButtonElement} [button] An HTML button element which triggers the display of this picker * @property {Record} [favorites] The picker display mode in FilePicker.DISPLAY_MODES * @property {string} [displayMode] The picker display mode in FilePicker.DISPLAY_MODES * @property {boolean} [tileSize=false] Display the tile size configuration. * @property {string[]} [redirectToRoot] Redirect to the root directory rather than starting in the source directory * of one of these files. */ /** * The FilePicker application renders contents of the server-side public directory. * This app allows for navigating and uploading files to the public path. * * @param {FilePickerOptions} [options={}] Options that configure the behavior of the FilePicker */ class FilePicker extends Application { constructor(options={}) { super(options); /** * The full requested path given by the user * @type {string} */ this.request = options.current; /** * The file sources which are available for browsing * @type {object} */ this.sources = Object.entries({ data: { target: "", label: game.i18n.localize("FILES.SourceUser"), icon: "fas fa-database" }, public: { target: "", label: game.i18n.localize("FILES.SourceCore"), icon: "fas fa-server" }, s3: { buckets: [], bucket: "", target: "", label: game.i18n.localize("FILES.SourceS3"), icon: "fas fa-cloud" } }).reduce((obj, s) => { if ( game.data.files.storages.includes(s[0]) ) obj[s[0]] = s[1]; return obj; }, {}); /** * Track the active source tab which is being browsed * @type {string} */ this.activeSource = options.activeSource || "data"; /** * A callback function to trigger once a file has been selected * @type {Function} */ this.callback = options.callback; /** * The latest set of results browsed from the server * @type {object} */ this.results = {}; /** * The general file type which controls the set of extensions which will be accepted * @type {string} */ this.type = options.type ?? "any"; /** * The target HTML element this file picker is bound to * @type {HTMLElement} */ this.field = options.field; /** * A button which controls the display of the picker UI * @type {HTMLElement} */ this.button = options.button; /** * The display mode of the FilePicker UI * @type {string} */ this.displayMode = options.displayMode || this.constructor.LAST_DISPLAY_MODE; /** * The current set of file extensions which are being filtered upon * @type {string[]} */ this.extensions = FilePicker.#getExtensions(this.type); // Infer the source const [source, target] = this._inferCurrentDirectory(this.request); this.activeSource = source; this.sources[source].target = target; // Track whether we have loaded files this._loaded = false; } /** * The allowed values for the type of this FilePicker instance. * @type {string[]} */ static FILE_TYPES = ["image", "audio", "video", "text", "imagevideo", "font", "folder", "any"]; /** * Record the last-browsed directory path so that re-opening a different FilePicker instance uses the same target * @type {string} */ static LAST_BROWSED_DIRECTORY = ""; /** * Record the last-configured tile size which can automatically be applied to new FilePicker instances * @type {number|null} */ static LAST_TILE_SIZE = null; /** * Record the last-configured display mode so that re-opening a different FilePicker instance uses the same mode. * @type {string} */ static LAST_DISPLAY_MODE = "list"; /** * Enumerate the allowed FilePicker display modes * @type {string[]} */ static DISPLAY_MODES = ["list", "thumbs", "tiles", "images"]; /** * Cache the names of S3 buckets which can be used * @type {Array|null} */ static S3_BUCKETS = null; /** * @typedef FavoriteFolder * @property {string} source The source of the folder (e.g. "data", "public") * @property {string} path The full path to the folder * @property {string} label The label for the path */ /** * Get favorite folders for quick access * @type {Record} */ static get favorites() { return game.settings.get("core", "favoritePaths"); } /* -------------------------------------------- */ /** * Add the given path for the source to the favorites * @param {string} source The source of the folder (e.g. "data", "public") * @param {string} path The path to a folder * @returns {Promise} */ static async setFavorite(source, path ) { const favorites = foundry.utils.deepClone(this.favorites); // Standardize all paths to end with a "/". // Has the side benefit of ensuring that the root path which is normally an empty string has content. path = path.endsWith("/") ? path : `${path}/`; const alreadyFavorite = Object.keys(favorites).includes(`${source}-${path}`); if ( alreadyFavorite ) return ui.notifications.info(game.i18n.format("FILES.AlreadyFavorited", {path})); let label; if ( path === "/" ) label = "root"; else { const directories = path.split("/"); label = directories[directories.length - 2]; // Get the final part of the path for the label } favorites[`${source}-${path}`] = {source, path, label}; await game.settings.set("core", "favoritePaths", favorites); } /* -------------------------------------------- */ /** * Remove the given path from the favorites * @param {string} source The source of the folder (e.g. "data", "public") * @param {string} path The path to a folder * @returns {Promise} */ static async removeFavorite(source, path) { const favorites = foundry.utils.deepClone(this.favorites); delete favorites[`${source}-${path}`]; await game.settings.set("core", "favoritePaths", favorites); } /* -------------------------------------------- */ /** * @override * @returns {FilePickerOptions} */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { template: "templates/apps/filepicker.html", classes: ["filepicker"], width: 546, tabs: [{navSelector: ".tabs"}], dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}], tileSize: false, filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".filepicker-body"}] }); } /* -------------------------------------------- */ /** * Given a current file path, determine the directory it belongs to * @param {string} target The currently requested target path * @returns {string[]} An array of the inferred source and target directory path */ _inferCurrentDirectory(target) { // Determine target const ignored = [CONST.DEFAULT_TOKEN].concat(this.options.redirectToRoot ?? []); if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY; let source = "data"; // Check for s3 matches const s3Match = this.constructor.matchS3URL(target); if ( s3Match ) { this.sources.s3.bucket = s3Match.groups.bucket; source = "s3"; target = s3Match.groups.key; } // Non-s3 URL matches else if ( ["http://", "https://"].some(c => target.startsWith(c)) ) target = ""; // Local file matches else { const p0 = target.split("/").shift(); const publicDirs = ["cards", "css", "fonts", "icons", "lang", "scripts", "sounds", "ui"]; if ( publicDirs.includes(p0) ) source = "public"; } // If the preferred source is not available, use the next available source. if ( !this.sources[source] ) { source = game.data.files.storages[0]; // If that happens to be S3, pick the first available bucket. if ( source === "s3" ) { this.sources.s3.bucket = game.data.files.s3.buckets?.[0] ?? null; target = ""; } } // Split off the file name and retrieve just the directory path let parts = target.split("/"); if ( parts[parts.length - 1].indexOf(".") !== -1 ) parts.pop(); const dir = parts.join("/"); return [source, dir]; } /* -------------------------------------------- */ /** * Get the valid file extensions for a given named file picker type * @param {string} type * @returns {string[]} */ static #getExtensions(type) { // Identify allowed extensions let types = [ CONST.IMAGE_FILE_EXTENSIONS, CONST.AUDIO_FILE_EXTENSIONS, CONST.VIDEO_FILE_EXTENSIONS, CONST.TEXT_FILE_EXTENSIONS, CONST.FONT_FILE_EXTENSIONS, CONST.GRAPHICS_FILE_EXTENSIONS ].flatMap(extensions => Object.keys(extensions)); if ( type === "folder" ) types = []; else if ( type === "font" ) types = Object.keys(CONST.FONT_FILE_EXTENSIONS); else if ( type === "text" ) types = Object.keys(CONST.TEXT_FILE_EXTENSIONS); else if ( type === "graphics" ) types = Object.keys(CONST.GRAPHICS_FILE_EXTENSIONS); else if ( type === "image" ) types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS); else if ( type === "audio" ) types = Object.keys(CONST.AUDIO_FILE_EXTENSIONS); else if ( type === "video" ) types = Object.keys(CONST.VIDEO_FILE_EXTENSIONS); else if ( type === "imagevideo") { types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS).concat(Object.keys(CONST.VIDEO_FILE_EXTENSIONS)); } return types.map(t => `.${t.toLowerCase()}`); } /* -------------------------------------------- */ /** * Test a URL to see if it matches a well known s3 key pattern * @param {string} url An input URL to test * @returns {RegExpMatchArray|null} A regular expression match */ static matchS3URL(url) { const endpoint = game.data.files.s3?.endpoint; if ( !endpoint ) return null; // Match new style S3 urls const s3New = new RegExp(`^${endpoint.protocol}//(?.*).${endpoint.host}/(?.*)`); const matchNew = url.match(s3New); if ( matchNew ) return matchNew; // Match old style S3 urls const s3Old = new RegExp(`^${endpoint.protocol}//${endpoint.host}/(?[^/]+)/(?.*)`); return url.match(s3Old); } /* -------------------------------------------- */ /* FilePicker Properties */ /* -------------------------------------------- */ /** @override */ get title() { let type = this.type || "file"; return game.i18n.localize(type === "imagevideo" ? "FILES.TitleImageVideo" : `FILES.Title${type.capitalize()}`); } /* -------------------------------------------- */ /** * Return the source object for the currently active source * @type {object} */ get source() { return this.sources[this.activeSource]; } /* -------------------------------------------- */ /** * Return the target directory for the currently active source * @type {string} */ get target() { return this.source.target; } /* -------------------------------------------- */ /** * Return a flag for whether the current user is able to upload file content * @type {boolean} */ get canUpload() { if ( this.type === "folder" ) return false; if ( this.options.allowUpload === false ) return false; if ( !["data", "s3"].includes(this.activeSource) ) return false; return !game.user || game.user.can("FILES_UPLOAD"); } /* -------------------------------------------- */ /** * Return the upload URL to which the FilePicker should post uploaded files * @type {string} */ static get uploadURL() { return foundry.utils.getRoute("upload"); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @override */ async getData(options={}) { const result = this.result; const source = this.source; let target = decodeURIComponent(source.target); const isS3 = this.activeSource === "s3"; // Sort directories alphabetically and store their paths let dirs = result.dirs.map(d => ({ name: decodeURIComponent(d.split("/").pop()), path: d, private: result.private || result.privateDirs.includes(d) })); dirs = dirs.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)); // Sort files alphabetically and store their client URLs let files = result.files.map(f => { let img = f; if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg"; else if ( foundry.audio.AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg"; else if ( !ImageHelper.hasImageExtension(f) ) img = "icons/svg/book.svg"; return { name: decodeURIComponent(f.split("/").pop()), url: f, img: img }; }); files = files.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)); // Return rendering data return { bucket: isS3 ? source.bucket : null, buckets: isS3 ? source.buckets.map(b => ({ value: b, label: b })) : null, canGoBack: this.activeSource !== "", canUpload: this.canUpload, canSelect: !this.options.tileSize, cssClass: [this.displayMode, result.private ? "private": "public"].join(" "), dirs: dirs, displayMode: this.displayMode, extensions: this.extensions, files: files, isS3: isS3, noResults: dirs.length + files.length === 0, selected: this.type === "folder" ? target : this.request, source: source, sources: this.sources, target: target, tileSize: this.options.tileSize ? (this.constructor.LAST_TILE_SIZE || canvas.dimensions.size) : null, user: game.user, submitText: this.type === "folder" ? "FILES.SelectFolder" : "FILES.SelectFile", favorites: this.constructor.favorites }; } /* -------------------------------------------- */ /** @inheritDoc */ setPosition(pos={}) { const currentPosition = super.setPosition(pos); const element = this.element[0]; const content = element.querySelector(".window-content"); const lists = element.querySelectorAll(".filepicker-body > ol"); const scroll = content.scrollHeight - content.offsetHeight; if ( (scroll > 0) && lists.length ) { let maxHeight = Number(getComputedStyle(lists[0]).maxHeight.slice(0, -2)); maxHeight -= Math.ceil(scroll / lists.length); lists.forEach(list => list.style.maxHeight = `${maxHeight}px`); } return currentPosition; } /* -------------------------------------------- */ /** * Browse to a specific location for this FilePicker instance * @param {string} [target] The target within the currently active source location. * @param {object} [options] Browsing options */ async browse(target, options={}) { // If the user does not have permission to browse, do not proceed if ( game.world && !game.user.can("FILES_BROWSE") ) return; // Configure browsing parameters target = typeof target === "string" ? target : this.target; const source = this.activeSource; options = foundry.utils.mergeObject({ type: this.type, extensions: this.extensions, wildcard: false }, options); // Determine the S3 buckets which may be used if ( source === "s3" ) { if ( this.constructor.S3_BUCKETS === null ) { const buckets = await this.constructor.browse("s3", ""); this.constructor.S3_BUCKETS = buckets.dirs; } this.sources.s3.buckets = this.constructor.S3_BUCKETS; if ( !this.source.bucket ) this.source.bucket = this.constructor.S3_BUCKETS[0]; options.bucket = this.source.bucket; } // Avoid browsing certain paths if ( target.startsWith("/") ) target = target.slice(1); if ( target === CONST.DEFAULT_TOKEN ) target = this.constructor.LAST_BROWSED_DIRECTORY; // Request files from the server const result = await this.constructor.browse(source, target, options).catch(error => { ui.notifications.warn(error); return this.constructor.browse(source, "", options); }); // Populate browser content this.result = result; this.source.target = result.target; if ( source === "s3" ) this.source.bucket = result.bucket; this.constructor.LAST_BROWSED_DIRECTORY = result.target; this._loaded = true; // Render the application this.render(true); return result; } /* -------------------------------------------- */ /** * Browse files for a certain directory location * @param {string} source The source location in which to browse. See FilePicker#sources for details * @param {string} target The target within the source location * @param {object} options Optional arguments * @param {string} [options.bucket] A bucket within which to search if using the S3 source * @param {string[]} [options.extensions] An Array of file extensions to filter on * @param {boolean} [options.wildcard] The requested dir represents a wildcard path * * @returns {Promise} A Promise which resolves to the directories and files contained in the location */ static async browse(source, target, options={}) { const data = {action: "browseFiles", storage: source, target: target}; return FilePicker.#manageFiles(data, options); } /* -------------------------------------------- */ /** * Configure metadata settings regarding a certain file system path * @param {string} source The source location in which to browse. See FilePicker#sources for details * @param {string} target The target within the source location * @param {object} options Optional arguments which modify the request * @returns {Promise} */ static async configurePath(source, target, options={}) { const data = {action: "configurePath", storage: source, target: target}; return FilePicker.#manageFiles(data, options); } /* -------------------------------------------- */ /** * Create a subdirectory within a given source. The requested subdirectory path must not already exist. * @param {string} source The source location in which to browse. See FilePicker#sources for details * @param {string} target The target within the source location * @param {object} options Optional arguments which modify the request * @returns {Promise} */ static async createDirectory(source, target, options={}) { const data = {action: "createDirectory", storage: source, target: target}; return FilePicker.#manageFiles(data, options); } /* -------------------------------------------- */ /** * General dispatcher method to submit file management commands to the server * @param {object} data Request data dispatched to the server * @param {object} options Options dispatched to the server * @returns {Promise} The server response */ static async #manageFiles(data, options) { return new Promise((resolve, reject) => { game.socket.emit("manageFiles", data, options, result => { if ( result.error ) return reject(new Error(result.error)); resolve(result); }); }); } /* -------------------------------------------- */ /** * Dispatch a POST request to the server containing a directory path and a file to upload * @param {string} source The data source to which the file should be uploaded * @param {string} path The destination path * @param {File} file The File object to upload * @param {object} [body={}] Additional file upload options sent in the POST body * @param {object} [options] Additional options to configure how the method behaves * @param {boolean} [options.notify=true] Display a UI notification when the upload is processed * @returns {Promise} The response object */ static async upload(source, path, file, body={}, {notify=true}={}) { // Create the form data to post const fd = new FormData(); fd.set("source", source); fd.set("target", path); fd.set("upload", file); Object.entries(body).forEach(o => fd.set(...o)); const notifications = Object.fromEntries(["ErrorSomethingWrong", "WarnUploadModules", "ErrorTooLarge"].map(key => { const i18n = `FILES.${key}`; return [key, game.i18n.localize(i18n)]; })); // Dispatch the request try { const request = await fetch(this.uploadURL, {method: "POST", body: fd}); const response = await request.json(); // Attempt to obtain the response if ( response.error ) { ui.notifications.error(response.error); return false; } else if ( !response.path ) { if ( notify ) ui.notifications.error(notifications.ErrorSomethingWrong); else console.error(notifications.ErrorSomethingWrong); return; } // Check for uploads to system or module directories. const [packageType, packageId, folder] = response.path.split("/"); if ( ["modules", "systems"].includes(packageType) ) { let pkg; if ( packageType === "modules" ) pkg = game.modules.get(packageId); else if ( packageId === game.system.id ) pkg = game.system; if ( !pkg?.persistentStorage || (folder !== "storage") ) { if ( notify ) ui.notifications.warn(notifications.WarnUploadModules); else console.warn(notifications.WarnUploadModules); } } // Display additional response messages if ( response.message ) { if ( notify ) ui.notifications.info(response.message); else console.info(response.message); } return response; } catch(e) { if ( (e instanceof foundry.utils.HttpError) && (e.code === 413) ) { if ( notify ) ui.notifications.error(notifications.ErrorTooLarge); else console.error(notifications.ErrorTooLarge); return; } return {}; } } /* -------------------------------------------- */ /** * A convenience function that uploads a file to a given package's persistent /storage/ directory * @param {string} packageId The id of the package to which the file should be uploaded. * Only supports Systems and Modules. * @param {string} path The relative destination path in the package's storage directory * @param {File} file The File object to upload * @param {object} [body={}] Additional file upload options sent in the POST body * @param {object} [options] Additional options to configure how the method behaves * @param {boolean} [options.notify=true] Display a UI notification when the upload is processed * @returns {Promise} The response object */ static async uploadPersistent(packageId, path, file, body={}, {notify=true}={}) { let pack = game.system.id === packageId ? game.system : game.modules.get(packageId); if ( !pack ) throw new Error(`Package ${packageId} not found`); if ( !pack.persistentStorage ) throw new Error(`Package ${packageId} does not have persistent storage enabled. ` + "Set the \"persistentStorage\" flag to true in the package manifest."); const source = "data"; const target = `${pack.type}s/${pack.id}/storage/${path}`; return this.upload(source, target, file, body, {notify}); } /* -------------------------------------------- */ /** @inheritDoc */ render(force, options) { if ( game.world && !game.user.can("FILES_BROWSE") ) return this; this.position.height = null; this.element.css({height: ""}); this._tabs[0].active = this.activeSource; if ( !this._loaded ) { this.browse(); return this; } else return super.render(force, options); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); const header = html.find("header.filepicker-header"); const form = html[0]; // Change the directory const target = header.find('input[name="target"]'); target.on("keydown", this.#onRequestTarget.bind(this)); target[0].focus(); // Header Control Buttons html.find(".current-dir button").click(this.#onClickDirectoryControl.bind(this)); // Change the S3 bucket html.find('select[name="bucket"]').change(this.#onChangeBucket.bind(this)); // Change the tile size. form.elements.tileSize?.addEventListener("change", this._onChangeTileSize.bind(this)); // Activate display mode controls const modes = html.find(".display-modes"); modes.on("click", ".display-mode", this.#onChangeDisplayMode.bind(this)); for ( let li of modes[0].children ) { li.classList.toggle("active", li.dataset.mode === this.displayMode); } // Upload new file if ( this.canUpload ) form.upload.onchange = ev => this.#onUpload(ev); // Directory-level actions html.find(".directory").on("click", "li", this.#onPick.bind(this)); // Directory-level actions html.find(".favorites").on("click", "a", this.#onClickFavorite.bind(this)); // Flag the current pick let li = form.querySelector(`.file[data-path="${encodeURIComponent(this.request)}"]`); if ( li ) li.classList.add("picked"); // Form submission form.onsubmit = ev => this._onSubmit(ev); // Intersection Observer to lazy-load images const files = html.find(".files-list"); const observer = new IntersectionObserver(this.#onLazyLoadImages.bind(this), {root: files[0]}); files.find("li.file").each((i, li) => observer.observe(li)); } /* -------------------------------------------- */ /** * Handle a click event to change the display mode of the File Picker * @param {MouseEvent} event The triggering click event */ #onChangeDisplayMode(event) { event.preventDefault(); const a = event.currentTarget; if ( !this.constructor.DISPLAY_MODES.includes(a.dataset.mode) ) { throw new Error("Invalid display mode requested"); } if ( a.dataset.mode === this.displayMode ) return; this.constructor.LAST_DISPLAY_MODE = this.displayMode = a.dataset.mode; this.render(); } /* -------------------------------------------- */ /** @override */ _onChangeTab(event, tabs, active) { this.activeSource = active; this.browse(this.source.target); } /* -------------------------------------------- */ /** @override */ _canDragStart(selector) { return game.user?.isGM && (canvas.activeLayer instanceof TilesLayer); } /* -------------------------------------------- */ /** @override */ _canDragDrop(selector) { return this.canUpload; } /* -------------------------------------------- */ /** @override */ _onDragStart(event) { const li = event.currentTarget; // Get the tile size ratio const tileSize = parseInt(li.closest("form").tileSize.value) || canvas.dimensions.size; const ratio = canvas.dimensions.size / tileSize; // Set drag data const dragData = { type: "Tile", texture: {src: li.dataset.path}, fromFilePicker: true, tileSize }; event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); // Create the drag preview for the image const img = li.querySelector("img"); const w = img.naturalWidth * ratio * canvas.stage.scale.x; const h = img.naturalHeight * ratio * canvas.stage.scale.y; const preview = DragDrop.createDragImage(img, w, h); event.dataTransfer.setDragImage(preview, w/2, h/2); } /* -------------------------------------------- */ /** @override */ async _onDrop(event) { if ( !this.canUpload ) return; const form = event.currentTarget.closest("form"); form.disabled = true; const target = form.target.value; // Process the data transfer const data = TextEditor.getDragEventData(event); const files = event.dataTransfer.files; if ( !files || !files.length || data.fromFilePicker ) return; // Iterate over dropped files for ( let upload of files ) { const name = upload.name.toLowerCase(); try { this.#validateExtension(name); } catch(err) { ui.notifications.error(err, {console: true}); continue; } const response = await this.constructor.upload(this.activeSource, target, upload, { bucket: form.bucket ? form.bucket.value : null }); if ( response ) this.request = response.path; } // Re-enable the form form.disabled = false; return this.browse(target); } /* -------------------------------------------- */ /** * Validate that the extension of the uploaded file is permitted for this file-picker instance. * This is an initial client-side test, the MIME type will be further checked by the server. * @param {string} name The file name attempted for upload */ #validateExtension(name) { const ext = `.${name.split(".").pop()}`; if ( !this.extensions.includes(ext) ) { const msg = game.i18n.format("FILES.ErrorDisallowedExtension", {name, ext, allowed: this.extensions.join(" ")}); throw new Error(msg); } } /* -------------------------------------------- */ /** * Handle user submission of the address bar to request an explicit target * @param {KeyboardEvent} event The originating keydown event */ #onRequestTarget(event) { if ( event.key === "Enter" ) { event.preventDefault(); return this.browse(event.target.value); } } /* -------------------------------------------- */ /** * Handle user interaction with the favorites * @param {PointerEvent} event The originating click event */ async #onClickFavorite(event) { const action = event.currentTarget.dataset.action; const source = event.currentTarget.dataset.source || this.activeSource; const path = event.currentTarget.dataset.path || this.target; switch (action) { case "goToFavorite": this.activeSource = source; await this.browse(path); break; case "setFavorite": await this.constructor.setFavorite(source, path); break; case "removeFavorite": await this.constructor.removeFavorite(source, path); break; } this.render(true); } /* -------------------------------------------- */ /** * Handle requests from the IntersectionObserver to lazily load an image file * @param {...any} args */ #onLazyLoadImages(...args) { if ( this.displayMode === "list" ) return; return SidebarTab.prototype._onLazyLoadImage.call(this, ...args); } /* -------------------------------------------- */ /** * Handle file or folder selection within the file picker * @param {Event} event The originating click event */ #onPick(event) { const li = event.currentTarget; const form = li.closest("form"); if ( li.classList.contains("dir") ) return this.browse(li.dataset.path); for ( let l of li.parentElement.children ) { l.classList.toggle("picked", l === li); } if ( form.file ) form.file.value = li.dataset.path; } /* -------------------------------------------- */ /** * Handle backwards navigation of the folder structure. * @param {PointerEvent} event The triggering click event */ #onClickDirectoryControl(event) { event.preventDefault(); const button = event.currentTarget; const action = button.dataset.action; switch (action) { case "back": let target = this.target.split("/"); target.pop(); return this.browse(target.join("/")); case "mkdir": return this.#createDirectoryDialog(this.source); case "toggle-privacy": let isPrivate = !this.result.private; const data = {private: isPrivate, bucket: this.result.bucket}; return this.constructor.configurePath(this.activeSource, this.target, data).then(r => { this.result.private = r.private; this.render(); }); } } /* -------------------------------------------- */ /** * Present the user with a dialog to create a subdirectory within their currently browsed file storage location. * @param {object} source The data source being browsed */ #createDirectoryDialog(source) { const form = `
`; return Dialog.confirm({ title: game.i18n.localize("FILES.CreateSubfolder"), content: form, yes: async html => { const dirname = html.querySelector("input").value; const path = [source.target, dirname].filterJoin("/"); try { await this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket}); } catch( err ) { ui.notifications.error(err.message); } return this.browse(this.target); }, options: {jQuery: false} }); } /* -------------------------------------------- */ /** * Handle changes to the bucket selector * @param {Event} event The S3 bucket select change event */ #onChangeBucket(event) { event.preventDefault(); const select = event.currentTarget; this.sources.s3.bucket = select.value; return this.browse("/"); } /* -------------------------------------------- */ /** * Handle changes to the tile size. * @param {Event} event The triggering event. * @protected */ _onChangeTileSize(event) { this.constructor.LAST_TILE_SIZE = event.currentTarget.valueAsNumber; } /* -------------------------------------------- */ /** @override */ _onSearchFilter(event, query, rgx, html) { for ( let ol of html.querySelectorAll(".directory") ) { let matched = false; for ( let li of ol.children ) { let match = rgx.test(SearchFilter.cleanQuery(li.dataset.name)); if ( match ) matched = true; li.style.display = !match ? "none" : ""; } ol.style.display = matched ? "" : "none"; } this.setPosition({height: "auto"}); } /* -------------------------------------------- */ /** @inheritDoc */ _onSubmit(ev) { ev.preventDefault(); let path = ev.target.file.value; if ( !path ) return ui.notifications.error("You must select a file to proceed."); // Update the target field if ( this.field ) { this.field.value = path; this.field.dispatchEvent(new Event("change", {bubbles: true, cancelable: true})); } // Trigger a callback and close if ( this.callback ) this.callback(path, this); return this.close(); } /* -------------------------------------------- */ /** * Handle file upload * @param {Event} ev The file upload event */ async #onUpload(ev) { const form = ev.target.form; const upload = form.upload.files[0]; const name = upload.name.toLowerCase(); // Validate file extension try { this.#validateExtension(name); } catch(err) { ui.notifications.error(err, {console: true}); return false; } // Dispatch the request const target = form.target.value; const options = { bucket: form.bucket ? form.bucket.value : null }; const response = await this.constructor.upload(this.activeSource, target, upload, options); // Handle errors if ( response.error ) { return ui.notifications.error(response.error); } // Flag the uploaded file as the new request this.request = response.path; return this.browse(target); } /* -------------------------------------------- */ /* Factory Methods /* -------------------------------------------- */ /** * Bind the file picker to a new target field. * Assumes the user will provide a HTMLButtonElement which has the data-target and data-type attributes * The data-target attribute should provide the name of the input field which should receive the selected file * The data-type attribute is a string in ["image", "audio"] which sets the file extensions which will be accepted * * @param {HTMLButtonElement} button The button element */ static fromButton(button) { if ( !(button instanceof HTMLButtonElement ) ) throw new Error("You must pass an HTML button"); let type = button.getAttribute("data-type"); const form = button.form; const field = form[button.dataset.target] || null; const current = field?.value || ""; return new FilePicker({field, type, current, button}); } }