From bcbd413e571d772a4438f57138169ad1a6a3b5a8 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Wed, 31 Mar 2021 18:17:28 -0400 Subject: Definition collapsing when overflowing (#1575) * Add double-down-chevron.svg * Add options * Update dictionary importers * Update settings * Add support for collapsible definitions * Improve case when there is a very small amount of overflow * Fix incorrect enabled state of newly imported dictionaries --- ext/css/display.css | 71 +++++++++ ext/css/material.css | 1 + ext/css/settings.css | 39 +++++ ext/data/schemas/options-schema.json | 8 +- ext/display-templates.html | 11 +- ext/images/double-down-chevron.svg | 1 + ext/js/data/options-util.js | 4 + ext/js/display/display.js | 6 + ext/js/display/element-overflow-controller.js | 129 +++++++++++++++++ ext/js/pages/settings/backup-controller.js | 2 +- .../settings/collapsible-dictionary-controller.js | 159 +++++++++++++++++++++ ext/js/pages/settings/dictionary-controller.js | 19 +-- .../pages/settings/dictionary-import-controller.js | 11 +- ext/js/pages/settings/settings-main.js | 4 + ext/popup.html | 1 + ext/search.html | 1 + ext/settings.html | 40 ++++++ 17 files changed, 486 insertions(+), 21 deletions(-) create mode 100644 ext/images/double-down-chevron.svg create mode 100644 ext/js/display/element-overflow-controller.js create mode 100644 ext/js/pages/settings/collapsible-dictionary-controller.js (limited to 'ext') diff --git a/ext/css/display.css b/ext/css/display.css index 887a04a6..c8669fa2 100644 --- a/ext/css/display.css +++ b/ext/css/display.css @@ -101,6 +101,9 @@ --animation-duration: 0.125s; --animation-duration2: calc(2 * var(--animation-duration)); + --collapsible-definition-line-count: 3; + --collapsible-definition-test-offset: 0.2em; + /* Colors */ --background-color: #ffffff; --glossary-image-background-color: #eeeeee; @@ -1154,6 +1157,74 @@ button.action-button[data-icon=source-term]::before { opacity: 0; white-space: pre-wrap; } +.definition-item { + display: list-item; + position: relative; +} +.definition-item-inner.collapsible.collapsed { + max-height: calc(1em * var(--collapsible-definition-line-count) * var(--line-height)); + overflow: hidden; +} +.definition-item-inner.collapse-test { + max-height: calc(1em * var(--collapsible-definition-line-count) * var(--line-height) + var(--collapsible-definition-test-offset)); + overflow: hidden; +} +.definition-item-inner { + display: flex; + flex-flow: row nowrap; +} +.definition-item-content { + flex: 1 1 auto; + background-color: transparent; + transition: background-color var(--animation-duration) ease-in-out; +} +button.definition-item-expansion-button { + --button-content-color: var(--text-color-light4); + --button-border-color: transparent; + --button-background-color: transparent; + + --button-hover-content-color: var(--text-color-light1); + --button-hover-border-color: var(--accent-color-lighter); + --button-hover-background-color: var(--accent-color-lighter); + + --button-active-content-color: var(--text-color); + --button-active-border-color: var(--accent-color-light); + --button-active-background-color: var(--accent-color-light); + + --button-padding-vertical: 0; + --button-padding-horizontal: 0.125em; + + flex: 0 0 auto; + order: 1; + border-radius: 0; + border: 0; +} +.definition-item-inner:not(.collapsible)>button.definition-item-expansion-button { + display: none; +} +button.definition-item-expansion-button:hover+.definition-item-content, +button.definition-item-expansion-button:active+.definition-item-content, +button.definition-item-expansion-button:focus+.definition-item-content { + background-color: var(--accent-color-transparent25); +} +button.definition-item-expansion-button:focus:not(:focus-visible)+.definition-item-content { + background-color: transparent; +} +button.definition-item-expansion-button:focus:hover+.definition-item-content, +button.definition-item-expansion-button:focus:active+.definition-item-content, +button.definition-item-expansion-button:focus:focus-visible+.definition-item-content { + background-color: var(--accent-color-transparent25); +} +.definition-item-expansion-button-icon { + transform: rotate(0deg); + width: calc(16em / var(--font-size-no-units)); + height: calc(16em / var(--font-size-no-units)); + background-color: var(--button-current-content-color); + transition: background-color var(--animation-duration) ease-in-out; +} +.definition-item-inner.collapsible:not(.collapsed)>button.definition-item-expansion-button>.definition-item-expansion-button-icon { + transform: rotate(180deg); +} /* Frequencies */ diff --git a/ext/css/material.css b/ext/css/material.css index 703f1268..08345769 100644 --- a/ext/css/material.css +++ b/ext/css/material.css @@ -234,6 +234,7 @@ .icon[data-icon=question-mark-thick] { --icon-image: url(/images/question-mark-thick.svg); } .icon[data-icon=left-chevron] { --icon-image: url(/images/left-chevron.svg); } .icon[data-icon=right-chevron] { --icon-image: url(/images/right-chevron.svg); } +.icon[data-icon=double-down-chevron] { --icon-image: url(/images/double-down-chevron.svg); } .icon[data-icon=plus-thick] { --icon-image: url(/images/plus-thick.svg); } .icon[data-icon=clipboard] { --icon-image: url(/images/clipboard.svg); } .icon[data-icon=key] { --icon-image: url(/images/key.svg); } diff --git a/ext/css/settings.css b/ext/css/settings.css index e2485925..22439409 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -47,6 +47,7 @@ --outline-item-icon-size: 32px; --input-short-width: calc(var(--input-width-large) / 2 - var(--padding) / 2); --input-short-height: 24px; + --input-medium-width: calc(var(--input-width-large) * 0.75); --fab-button-size: 56px; --fab-button-padding: 16px; --modal-width: 600px; @@ -657,6 +658,11 @@ input[type=number].short-width, select.short-width { width: var(--input-short-width); } +input[type=text].medium-width, +input[type=number].medium-width, +select.medium-width { + width: var(--input-medium-width); +} input[type=text].short-height, input[type=number].short-height, select.short-height { @@ -2125,6 +2131,39 @@ button.hotkey-list-item-enabled-button[data-scope-count='0'] { background-color: var(--warning-color); } +.collapsible-dictionary-list { + width: 100%; + display: flex; + flex-flow: column nowrap; + align-items: stretch; +} +.collapsible-dictionary-item { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin-left: calc(var(--modal-padding-horizontal) * -1); + margin-right: calc(var(--modal-padding-horizontal) * -1); + padding: var(--settings-group-inner-horizontal-padding-fourth) var(--modal-padding-horizontal); +} +.collapsible-dictionary-item:not(:first-child) { + border-top: var(--thin-border-size) solid var(--separator-color2); +} +.collapsible-dictionary-cell { + display: flex; + flex-flow: row nowrap; + align-items: center; +} +.collapsible-dictionary-cell:first-of-type { + flex: 1 1 auto; +} +.collapsible-dictionary-cell:not(:first-of-type) { + flex: 0 0 auto; + margin-left: 1em; +} +.collapsible-dictionary-cell-label { + margin-left: 0.375em; +} + /* Generic layouts */ .margin-above { diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 89e2d361..d829b392 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -732,7 +732,8 @@ "required": [ "priority", "enabled", - "allowSecondarySearches" + "allowSecondarySearches", + "definitionsCollapsible" ], "properties": { "priority": { @@ -746,6 +747,11 @@ "allowSecondarySearches": { "type": "boolean", "default": false + }, + "definitionsCollapsible": { + "type": "string", + "enum": ["not-collapsible", "expanded", "collapsed"], + "default": "not-collapsible" } } } diff --git a/ext/display-templates.html b/ext/display-templates.html index 20978d65..25beacb0 100644 --- a/ext/display-templates.html +++ b/ext/display-templates.html @@ -45,7 +45,16 @@
- + diff --git a/ext/images/double-down-chevron.svg b/ext/images/double-down-chevron.svg new file mode 100644 index 00000000..90684054 --- /dev/null +++ b/ext/images/double-down-chevron.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 5b2e2bd3..ca122e89 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -755,9 +755,13 @@ class OptionsUtil { // Removed global option useSettingsV2. // Added part-of-speech field template. // Added an argument to hotkey inputs. + // Added definitionsCollapsible to dictionary options. await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v10.handlebars'); delete options.global.useSettingsV2; for (const profile of options.profiles) { + for (const dictionaryOptions of Object.values(profile.options.dictionaries)) { + dictionaryOptions.definitionsCollapsible = 'not-collapsible'; + } for (const hotkey of profile.options.inputs.hotkeys) { switch (hotkey.action) { case 'previousEntry': diff --git a/ext/js/display/display.js b/ext/js/display/display.js index ee2448d6..b17a6168 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -23,6 +23,7 @@ * DisplayHistory * DisplayNotification * DocumentUtil + * ElementOverflowController * FrameEndpoint * Frontend * HotkeyHelpController @@ -114,6 +115,7 @@ class Display extends EventDispatcher { this._ankiNoteNotificationEventListeners = null; this._queryPostProcessor = null; this._optionToggleHotkeyHandler = new OptionToggleHotkeyHandler(this); + this._elementOverflowController = new ElementOverflowController(); this._hotkeyHandler.registerActions([ ['close', () => { this._onHotkeyClose(); }], @@ -305,6 +307,7 @@ class Display extends EventDispatcher { this._hotkeyHelpController.setOptions(options); this._displayGenerator.updateHotkeys(); this._hotkeyHelpController.setupNode(document.documentElement); + this._elementOverflowController.setOptions(options); this._queryParser.setOptions({ selectedParser: options.parsing.selectedParser, @@ -535,6 +538,7 @@ class Display extends EventDispatcher { this._hideAnkiNoteErrors(false); this._definitions = []; this._definitionNodes = []; + this._elementOverflowController.clearElements(); // Prepare const urlSearchParams = new URLSearchParams(location.search); @@ -966,6 +970,8 @@ class Display extends EventDispatcher { if (focusEntry === i) { this._focusEntry(i, false); } + + this._elementOverflowController.addElements(entry); } if (typeof scrollX === 'number' || typeof scrollY === 'number') { diff --git a/ext/js/display/element-overflow-controller.js b/ext/js/display/element-overflow-controller.js new file mode 100644 index 00000000..ccea1417 --- /dev/null +++ b/ext/js/display/element-overflow-controller.js @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +class ElementOverflowController { + constructor() { + this._elements = []; + this._checkTimer = null; + this._eventListeners = new EventListenerCollection(); + this._windowEventListeners = new EventListenerCollection(); + this._dictionaries = new Map(); + this._updateBind = this._update.bind(this); + this._onWindowResizeBind = this._onWindowResize.bind(this); + this._onToggleButtonClickBind = this._onToggleButtonClick.bind(this); + } + + setOptions(options) { + this._dictionaries.clear(); + for (const [dictionary, {definitionsCollapsible}] of Object.entries(options.dictionaries)) { + let collapsible = false; + let collapsed = false; + switch (definitionsCollapsible) { + case 'expanded': + collapsible = true; + break; + case 'collapsed': + collapsible = true; + collapsed = true; + break; + } + if (!collapsible) { continue; } + this._dictionaries.set(dictionary, {collapsed}); + } + } + + addElements(entry) { + if (this._dictionaries.size === 0) { return; } + + const elements = entry.querySelectorAll('.definition-item-inner'); + let any = false; + for (const element of elements) { + const {dictionary} = element.parentNode.dataset; + const dictionaryInfo = this._dictionaries.get(dictionary); + if (typeof dictionaryInfo === 'undefined') { continue; } + + this._updateElement(element); + this._elements.push(element); + + if (dictionaryInfo.collapsed) { + element.classList.add('collapsed'); + } + + const button = element.querySelector('.definition-item-expansion-button'); + if (button !== null) { + this._eventListeners.addEventListener(button, 'click', this._onToggleButtonClickBind, false); + } + + any = true; + } + + if (any && this._windowEventListeners.size === 0) { + this._windowEventListeners.addEventListener(window, 'resize', this._onWindowResizeBind, false); + } + } + + clearElements() { + if (this._elements.length === 0) { return; } + this._elements = []; + this._windowEventListeners.removeAllEventListeners(); + } + + // Private + + _onWindowResize() { + if (this._checkTimer !== null) { + this._cancelIdleCallback(this._checkTimer); + } + this._checkTimer = this._requestIdleCallback(this._updateBind, 100); + } + + _onToggleButtonClick(e) { + const container = e.currentTarget.closest('.definition-item-inner'); + if (container === null) { return; } + container.classList.toggle('collapsed'); + } + + _update() { + for (const element of this._elements) { + this._updateElement(element); + } + } + + _updateElement(element) { + const {classList} = element; + classList.add('collapse-test'); + const collapsible = element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; + classList.toggle('collapsible', collapsible); + classList.remove('collapse-test'); + } + + _requestIdleCallback(callback, timeout) { + if (typeof requestIdleCallback === 'function') { + return requestIdleCallback(callback, {timeout}); + } else { + return setTimeout(callback, timeout); + } + } + + _cancelIdleCallback(handle) { + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(handle); + } else { + clearTimeout(handle); + } + } +} diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js index 6f6614b6..933f0e2a 100644 --- a/ext/js/pages/settings/backup-controller.js +++ b/ext/js/pages/settings/backup-controller.js @@ -404,7 +404,7 @@ class BackupController { const dictionaries = await this._settingsController.getDictionaryInfo(); for (const {options: {dictionaries: optionsDictionaries}} of optionsFull.profiles) { for (const {title} of dictionaries) { - optionsDictionaries[title] = DictionaryController.createDefaultDictionarySettings(); + optionsDictionaries[title] = DictionaryController.createDefaultDictionarySettings(false); } } diff --git a/ext/js/pages/settings/collapsible-dictionary-controller.js b/ext/js/pages/settings/collapsible-dictionary-controller.js new file mode 100644 index 00000000..a3a1df62 --- /dev/null +++ b/ext/js/pages/settings/collapsible-dictionary-controller.js @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * ObjectPropertyAccessor + */ + +class CollapsibleDictionaryController { + constructor(settingsController) { + this._settingsController = settingsController; + this._getDictionaryInfoToken = null; + this._dictionaryInfoMap = new Map(); + this._container = null; + this._selects = []; + this._allSelect = null; + this._eventListeners = new EventListenerCollection(); + } + + async prepare() { + this._container = document.querySelector('#collapsible-dictionary-list'); + + await this._onDatabaseUpdated(); + await this._updateOptions(); + + yomichan.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + } + + // Private + + async _onDatabaseUpdated() { + const token = {}; + this._getDictionaryInfoToken = token; + const dictionaries = await this._settingsController.getDictionaryInfo(); + if (this._getDictionaryInfoToken !== token) { return; } + this._getDictionaryInfoToken = null; + + this._dictionaryInfoMap.clear(); + for (const entry of dictionaries) { + this._dictionaryInfoMap.set(entry.title, entry); + } + + await this._updateOptions(); + } + + _onOptionsChanged({options}) { + this._eventListeners.removeAllEventListeners(); + this._selects = []; + + const {dictionaries} = options; + + const fragment = document.createDocumentFragment(); + + this._setupAllSelect(fragment, options); + + for (const dictionary of Object.keys(dictionaries)) { + const dictionaryInfo = this._dictionaryInfoMap.get(dictionary); + if (typeof dictionaryInfo === 'undefined') { continue; } + + const select = this._addSelect(fragment, dictionary, `rev.${dictionaryInfo.revision}`); + select.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', dictionary, 'definitionsCollapsible']); + this._eventListeners.addEventListener(select, 'settingChanged', this._onDefinitionsCollapsibleChange.bind(this), false); + + this._selects.push(select); + } + + this._container.textContent = ''; + this._container.appendChild(fragment); + } + + _onDefinitionsCollapsibleChange() { + this._updateAllSelectFresh(); + } + + _onAllSelectChange(e) { + const {value} = e.currentTarget; + if (value === 'varies') { return; } + this._setDefinitionsCollapsibleAll(value); + } + + _setupAllSelect(fragment, options) { + const select = this._addSelect(fragment, 'All', ''); + + const option = document.createElement('option'); + option.value = 'varies'; + option.textContent = 'Varies'; + option.disabled = true; + select.appendChild(option); + + this._eventListeners.addEventListener(select, 'change', this._onAllSelectChange.bind(this), false); + + this._allSelect = select; + this._updateAllSelect(options); + } + + _addSelect(fragment, dictionary, version) { + const node = this._settingsController.instantiateTemplate('collapsible-dictionary-item'); + fragment.appendChild(node); + + const nameNode = node.querySelector('.dictionary-title'); + nameNode.textContent = dictionary; + + const versionNode = node.querySelector('.dictionary-version'); + versionNode.textContent = version; + + return node.querySelector('.definitions-collapsible'); + } + + async _updateOptions() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + async _updateAllSelectFresh() { + this._updateAllSelect(await this._settingsController.getOptions()); + } + + _updateAllSelect(options) { + let value = null; + let varies = false; + for (const {definitionsCollapsible} of Object.values(options.dictionaries)) { + if (value === null) { + value = definitionsCollapsible; + } else if (value !== definitionsCollapsible) { + varies = true; + break; + } + } + + this._allSelect.value = (varies || value === null ? 'varies' : value); + } + + async _setDefinitionsCollapsibleAll(value) { + const options = await this._settingsController.getOptions(); + const targets = []; + for (const dictionary of Object.keys(options.dictionaries)) { + const path = ObjectPropertyAccessor.getPathString(['dictionaries', dictionary, 'definitionsCollapsible']); + targets.push({action: 'set', path, value}); + } + await this._settingsController.modifyProfileSettings(targets); + for (const select of this._selects) { + select.value = value; + } + } +} \ No newline at end of file diff --git a/ext/js/pages/settings/dictionary-controller.js b/ext/js/pages/settings/dictionary-controller.js index 17abfa13..65edcb67 100644 --- a/ext/js/pages/settings/dictionary-controller.js +++ b/ext/js/pages/settings/dictionary-controller.js @@ -248,6 +248,15 @@ class DictionaryController { this._updateDictionariesEnabledWarnings(options); } + static createDefaultDictionarySettings(enabled) { + return { + priority: 0, + enabled, + allowSecondarySearches: false, + definitionsCollapsible: 'not-collapsible' + }; + } + // Private _onOptionsChanged({options}) { @@ -535,7 +544,7 @@ class DictionaryController { targets.push({ action: 'set', path, - value: DictionaryController.createDefaultDictionarySettings() + value: DictionaryController.createDefaultDictionarySettings(false) }); } } @@ -548,12 +557,4 @@ class DictionaryController { _triggerStorageChanged() { yomichan.trigger('storageChanged'); } - - static createDefaultDictionarySettings() { - return { - enabled: false, - allowSecondarySearches: false, - priority: 0 - }; - } } diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js index ce724263..afa45899 100644 --- a/ext/js/pages/settings/dictionary-import-controller.js +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -16,6 +16,7 @@ */ /* global + * DictionaryController * DictionaryDatabase * DictionaryImporter * ObjectPropertyAccessor @@ -212,7 +213,7 @@ class DictionaryImportController { const profileCount = optionsFull.profiles.length; for (let i = 0; i < profileCount; ++i) { const {options} = optionsFull.profiles[i]; - const value = this._createDictionaryOptions(); + const value = DictionaryController.createDefaultDictionarySettings(true); const path1 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', title]); targets.push({action: 'set', path: path1, value}); @@ -291,14 +292,6 @@ class DictionaryImportController { }); } - _createDictionaryOptions() { - return { - priority: 0, - enabled: true, - allowSecondarySearches: false - }; - } - _errorToString(error) { error = (typeof error.toString === 'function' ? error.toString() : `${error}`); diff --git a/ext/js/pages/settings/settings-main.js b/ext/js/pages/settings/settings-main.js index 3618836c..2560685c 100644 --- a/ext/js/pages/settings/settings-main.js +++ b/ext/js/pages/settings/settings-main.js @@ -20,6 +20,7 @@ * AnkiTemplatesController * AudioController * BackupController + * CollapsibleDictionaryController * DictionaryController * DictionaryImportController * DocumentFocusController @@ -146,6 +147,9 @@ async function setupGenericSettingsController(genericSettingController) { const mecabController = new MecabController(); mecabController.prepare(); + const collapsibleDictionaryController = new CollapsibleDictionaryController(settingsController); + collapsibleDictionaryController.prepare(); + await Promise.all(preparePromises); document.documentElement.dataset.loaded = 'true'; diff --git a/ext/popup.html b/ext/popup.html index 36cff420..76a0032b 100644 --- a/ext/popup.html +++ b/ext/popup.html @@ -105,6 +105,7 @@ + diff --git a/ext/search.html b/ext/search.html index 4ef8860f..d67dca45 100644 --- a/ext/search.html +++ b/ext/search.html @@ -89,6 +89,7 @@ + diff --git a/ext/settings.html b/ext/settings.html index 3de183db..712dda3f 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -737,6 +737,14 @@

+
+
+
Configure collapsible dictionaries…
+
+
+ +
+
Configure custom CSS…
@@ -2095,6 +2103,24 @@
+ + + +