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> |