Skip to content

Commit

Permalink
Rework dictionary import UX (#937)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Kuuuube authored May 21, 2024
1 parent 02c60ee commit 6301ba6
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 14 deletions.
72 changes: 72 additions & 0 deletions ext/css/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -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;
Expand Down
170 changes: 165 additions & 5 deletions ext/js/pages/settings/dictionary-import-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -65,21 +73,152 @@ 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
*/
Expand All @@ -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; }
Expand All @@ -109,6 +249,26 @@ export class DictionaryImportController {
void this._importDictionaries(files2);
}

/** */
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; }
Expand Down
8 changes: 0 additions & 8 deletions ext/js/pages/welcome-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));

Expand Down
26 changes: 25 additions & 1 deletion ext/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -2415,7 +2415,31 @@ <h1>Yomitan Settings</h1>
<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>
Expand Down

0 comments on commit 6301ba6

Please sign in to comment.