diff options
author | Kuuuube <61125188+Kuuuube@users.noreply.github.com> | 2024-05-21 13:48:29 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-21 17:48:29 +0000 |
commit | 6301ba6b33fc763872d9c500cd257ac5c7ffbb44 (patch) | |
tree | 894d0bfa12ac8dcd56f5d4dd50ce9885adccea08 | |
parent | 02c60eea797b9548328bd160b453dd95a54b1641 (diff) |
Rework dictionary import UX (#937)24.5.21.0
* Add option to import from URL
* Remove some debug code
* Improve import ui
* Add drag and drop option
* Add basic-only setting css
* Better sizing of import elements
* Hide import from url if advanced is not enabled
* Improve file drag and drop box look
* Remove redundant css
* Allow clicking on drag and drop box to open file picker
* Allow drag and drop for folders
* Prevent welcome page from breaking due to unnecessary imports
* Note that the drop zone can be clicked on
* Reject directories with item counts requiring more than 1000 processing steps (roughly 500 items)
* Improve import modal styling
* Fix typing
* Add book icon to drag zone
* Remove drag-over class on drop
* Filter only for .zip files in drag and drop
* Drop zone text rename Files to Dictionaries and add (.zip)
* Clarify not using instanceof in ts-expect-error
* Only show drag-over styling when file is zip or directory
-rw-r--r-- | ext/css/settings.css | 72 | ||||
-rw-r--r-- | ext/js/pages/settings/dictionary-import-controller.js | 170 | ||||
-rw-r--r-- | ext/js/pages/welcome-main.js | 8 | ||||
-rw-r--r-- | ext/settings.html | 26 |
4 files changed, 262 insertions, 14 deletions
diff --git a/ext/css/settings.css b/ext/css/settings.css index ba82ecf7..d23062b1 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -53,6 +53,8 @@ --modal-height: 400px; --modal-width-small: 400px; --modal-height-small: 200px; + --modal-width-medium: 600px; + --modal-height-medium: 400px; --modal-transition-offset: -64px; --badge-size: 16px; @@ -587,6 +589,9 @@ a.heading-link-light { :root:not([data-advanced=true]) .advanced-only { display: none; } +:root:not([data-advanced=false]) .basic-only { + display: none; +} .settings-item.settings-item-button, a.settings-item.settings-item-button { cursor: pointer; @@ -769,6 +774,12 @@ select.short-height { height: auto; max-height: 100%; } +.modal-content.modal-content-medium { + width: var(--modal-width-medium); + min-height: var(--modal-height-medium); + height: auto; + max-height: 100%; +} .modal-content.modal-content-full { width: var(--content-width); height: 100%; @@ -2352,6 +2363,67 @@ input[type=number].dictionary-priority { } +/* Dictionary Import */ +#dictionary-import-url-text { + width: 100%; + height: 4em; + white-space: nowrap; + resize: none; +} + +#dictionary-import-url-button { + flex: auto; +} + +#dictionary-drop-file-zone { + transition: background-color var(--animation-duration) ease-in-out, border var(--animation-duration) ease-in-out; + border: 2px dashed rgb(204, 204, 204); + border-radius: 5px; + flex: auto; + min-height: 20em; + user-select: none; + text-align: center; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +#dictionary-drop-file-zone:hover { + background-color: rgba(28, 116, 233, 0.05); + border: 2px dashed var(--accent-color); +} + +#dictionary-drop-file-zone.drag-over { + border: 2px solid var(--accent-color); + background-color: rgb(191, 209, 255); +} + +#dictionary-drag-drop-text { + pointer-events: none; +} + +#dictionary-drag-drop-text>.icon { + display: block; + margin: auto; + background-color: var(--button-default-icon-color); + width: var(--outline-item-icon-size); + height: var(--outline-item-icon-size); +} + +#dictionary-drag-drop-text h1, +#dictionary-drag-drop-text h5 { + margin: 0; + padding: 0; + font-weight: normal; + border-bottom: none; +} + +#dictionary-import-modal .modal-body:has(#dictionary-drop-file-zone) { + display: flex; +} + + /* Generic layouts */ .margin-above { margin-top: 0.85em; diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js index 7ed61fc3..7090e296 100644 --- a/ext/js/pages/settings/dictionary-import-controller.js +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -43,9 +43,17 @@ export class DictionaryImportController { /** @type {HTMLButtonElement} */ this._purgeConfirmButton = querySelectorNotNull(document, '#dictionary-confirm-delete-all-button'); /** @type {HTMLButtonElement} */ - this._importFileButton = querySelectorNotNull(document, '#dictionary-import-file-button'); - /** @type {HTMLInputElement} */ this._importFileInput = querySelectorNotNull(document, '#dictionary-import-file-input'); + /** @type {HTMLButtonElement} */ + this._importFileDrop = querySelectorNotNull(document, '#dictionary-drop-file-zone'); + /** @type {number} */ + this._importFileDropItemCount = 0; + /** @type {HTMLInputElement} */ + this._importButton = querySelectorNotNull(document, '#dictionary-import-button'); + /** @type {HTMLInputElement} */ + this._importURLButton = querySelectorNotNull(document, '#dictionary-import-url-button'); + /** @type {HTMLInputElement} */ + this._importURLText = querySelectorNotNull(document, '#dictionary-import-url-text'); /** @type {?import('./modal.js').Modal} */ this._purgeConfirmModal = null; /** @type {HTMLElement} */ @@ -65,22 +73,153 @@ export class DictionaryImportController { /** */ prepare() { + this._importModal = this._modalController.getModal('dictionary-import'); this._purgeConfirmModal = this._modalController.getModal('dictionary-confirm-delete-all'); this._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false); this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false); - this._importFileButton.addEventListener('click', this._onImportButtonClick.bind(this), false); + this._importButton.addEventListener('click', this._onImportButtonClick.bind(this), false); + this._importURLButton.addEventListener('click', this._onImportFromURL.bind(this), false); this._importFileInput.addEventListener('change', this._onImportFileChange.bind(this), false); + + this._importFileDrop.addEventListener('click', this._onImportFileButtonClick.bind(this), false); + this._importFileDrop.addEventListener('dragenter', this._onFileDropEnter.bind(this), false); + this._importFileDrop.addEventListener('dragover', this._onFileDropOver.bind(this), false); + this._importFileDrop.addEventListener('dragleave', this._onFileDropLeave.bind(this), false); + this._importFileDrop.addEventListener('drop', this._onFileDrop.bind(this), false); } // Private /** */ - _onImportButtonClick() { + _onImportFileButtonClick() { /** @type {HTMLInputElement} */ (this._importFileInput).click(); } /** + * @param {DragEvent} e + */ + _onFileDropEnter(e) { + e.preventDefault(); + if (!e.dataTransfer) { return; } + for (const item of e.dataTransfer.items) { + // Directories and files with no extension both show as '' + if (item.type === '' || item.type === 'application/zip') { + this._importFileDrop.classList.add('drag-over'); + break; + } + } + } + + /** + * @param {DragEvent} e + */ + _onFileDropOver(e) { + e.preventDefault(); + } + + /** + * @param {DragEvent} e + */ + _onFileDropLeave(e) { + e.preventDefault(); + this._importFileDrop.classList.remove('drag-over'); + } + + /** + * @param {DragEvent} e + */ + async _onFileDrop(e) { + e.preventDefault(); + this._importFileDrop.classList.remove('drag-over'); + if (e.dataTransfer === null) { return; } + /** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false); + /** @type {File[]} */ + const fileArray = []; + for (const fileEntry of await this._getAllFileEntries(e.dataTransfer.items)) { + if (!fileEntry) { return; } + try { + fileArray.push(await new Promise((resolve, reject) => { fileEntry.file(resolve, reject); })); + } catch (error) { + log.error(error); + } + } + void this._importDictionaries(fileArray); + } + + /** + * @param {DataTransferItemList} dataTransferItemList + * @returns {Promise<FileSystemFileEntry[]>} + */ + async _getAllFileEntries(dataTransferItemList) { + /** @type {(FileSystemFileEntry)[]} */ + const fileEntries = []; + /** @type {(FileSystemEntry | null)[]} */ + const entries = []; + for (let i = 0; i < dataTransferItemList.length; i++) { + entries.push(dataTransferItemList[i].webkitGetAsEntry()); + } + this._importFileDropItemCount = entries.length - 1; + while (entries.length > 0) { + this._importFileDropItemCount += 1; + this._validateDirectoryItemCount(); + + /** @type {(FileSystemEntry | null) | undefined} */ + const entry = entries.shift(); + if (!entry) { continue; } + if (entry.isFile) { + if (entry.name.substring(entry.name.lastIndexOf('.'), entry.name.length) === '.zip') { + // @ts-expect-error - ts does not recognize `if (entry.isFile)` as verifying `entry` is type `FileSystemFileEntry` and instanceof does not work + fileEntries.push(entry); + } + } else if (entry.isDirectory) { + // @ts-expect-error - ts does not recognize `if (entry.isDirectory)` as verifying `entry` is type `FileSystemDirectoryEntry` and instanceof does not work + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + entries.push(...await this._readAllDirectoryEntries(entry.createReader())); + } + } + return fileEntries; + } + + /** + * @param {FileSystemDirectoryReader} directoryReader + * @returns {Promise<(FileSystemEntry)[]>} + */ + async _readAllDirectoryEntries(directoryReader) { + const entries = []; + /** @type {(FileSystemEntry)[]} */ + let readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); }); + while (readEntries.length > 0) { + this._importFileDropItemCount += readEntries.length; + this._validateDirectoryItemCount(); + + entries.push(...readEntries); + readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); }); + } + return entries; + } + + /** + * @throws + */ + _validateDirectoryItemCount() { + if (this._importFileDropItemCount > 1000) { + this._importFileDropItemCount = 0; + const errorText = 'Directory upload item count too large'; + this._showErrors([new Error(errorText)]); + throw new Error(errorText); + } + } + + /** + * @param {MouseEvent} e + */ + _onImportButtonClick(e) { + e.preventDefault(); + /** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(true); + } + + /** * @param {MouseEvent} e */ _onPurgeButtonClick(e) { @@ -100,7 +239,8 @@ export class DictionaryImportController { /** * @param {Event} e */ - _onImportFileChange(e) { + async _onImportFileChange(e) { + /** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false); const node = /** @type {HTMLInputElement} */ (e.currentTarget); const {files} = node; if (files === null) { return; } @@ -110,6 +250,26 @@ export class DictionaryImportController { } /** */ + async _onImportFromURL() { + const text = this._importURLText.value.trim(); + if (!text) { return; } + const urls = text.split('\n'); + const files = []; + for (const url of urls) { + try { + files.push(await fetch(url.trim()) + .then((res) => res.blob()) + .then((blob) => { + return new File([blob], 'fileFromURL'); + })); + } catch (error) { + log.error(error); + } + } + void this._importDictionaries(files); + } + + /** */ async _purgeDatabase() { if (this._modifying) { return; } diff --git a/ext/js/pages/welcome-main.js b/ext/js/pages/welcome-main.js index 7cb28cda..10a84a59 100644 --- a/ext/js/pages/welcome-main.js +++ b/ext/js/pages/welcome-main.js @@ -20,8 +20,6 @@ import {Application} from '../application.js'; import {DocumentFocusController} from '../dom/document-focus-controller.js'; import {querySelectorNotNull} from '../dom/query-selector.js'; import {ExtensionContentController} from './common/extension-content-controller.js'; -import {DictionaryController} from './settings/dictionary-controller.js'; -import {DictionaryImportController} from './settings/dictionary-import-controller.js'; import {GenericSettingController} from './settings/generic-setting-controller.js'; import {LanguagesController} from './settings/languages-controller.js'; import {ModalController} from './settings/modal-controller.js'; @@ -82,12 +80,6 @@ await Application.main(true, async (application) => { const settingsController = new SettingsController(application); await settingsController.prepare(); - const dictionaryController = new DictionaryController(settingsController, modalController, statusFooter); - preparePromises.push(dictionaryController.prepare()); - - const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter); - preparePromises.push(dictionaryImportController.prepare()); - const genericSettingController = new GenericSettingController(settingsController); preparePromises.push(setupGenericSettingsController(genericSettingController)); diff --git a/ext/settings.html b/ext/settings.html index 3eb2fc2e..6f4857db 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -2415,7 +2415,31 @@ <div class="modal-footer"> <button type="button" class="low-emphasis danger dictionary-database-mutating-input" id="dictionary-delete-all-button">Delete All</button> <button type="button" class="low-emphasis dictionary-database-mutating-input" id="dictionary-check-integrity">Check Integrity</button> - <button type="button" class="low-emphasis dictionary-database-mutating-input" id="dictionary-import-file-button">Import</button> + <button type="button" class="low-emphasis dictionary-database-mutating-input" id="dictionary-import-button">Import</button> + <button type="button" data-modal-action="hide">Close</button> + </div> +</div></div> + +<div id="dictionary-import-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-medium"> + <div class="modal-header"><div class="modal-title">Import Dictionaries</div></div> + <div class="modal-body"> + <div id="dictionary-drop-file-zone"> + <div id="dictionary-drag-drop-text"> + <span class="icon" data-icon="book"></span> + <h1>Drag and drop dictionaries (.zip)</h1> + <h5>or click here to upload</h5> + </div> + </div> + </div> + <div class="modal-footer"> + <button type="button" data-modal-action="hide" class="basic-only">Close</button> + </div> + <div class="modal-body advanced-only"> + <p>Import dictionaries from URLs:</p> + <textarea type="text" id="dictionary-import-url-text"></textarea> + </div> + <div class="modal-footer advanced-only"> + <button type="button" data-modal-action="hide" class="low-emphasis dictionary-database-mutating-input" id="dictionary-import-url-button">Import from URLs</button> <button type="button" data-modal-action="hide">Close</button> </div> </div></div> |