Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at upstream/master 197 lines 6.7 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2021-2022 Yomichan Authors 4 * 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 */ 18 19import {EventListenerCollection} from '../core/event-listener-collection.js'; 20 21export class ElementOverflowController { 22 /** 23 * @param {import('./display.js').Display} display 24 */ 25 constructor(display) { 26 /** @type {import('./display.js').Display} */ 27 this._display = display; 28 /** @type {Element[]} */ 29 this._elements = []; 30 /** @type {?(number|import('core').Timeout)} */ 31 this._checkTimer = null; 32 /** @type {EventListenerCollection} */ 33 this._eventListeners = new EventListenerCollection(); 34 /** @type {EventListenerCollection} */ 35 this._windowEventListeners = new EventListenerCollection(); 36 /** @type {Map<string, {collapsed: boolean, force: boolean}>} */ 37 this._dictionaries = new Map(); 38 /** @type {() => void} */ 39 this._updateBind = this._update.bind(this); 40 /** @type {() => void} */ 41 this._onWindowResizeBind = this._onWindowResize.bind(this); 42 /** @type {(event: MouseEvent) => void} */ 43 this._onToggleButtonClickBind = this._onToggleButtonClick.bind(this); 44 } 45 46 /** 47 * @param {import('settings').ProfileOptions} options 48 */ 49 setOptions(options) { 50 this._dictionaries.clear(); 51 for (const {name, definitionsCollapsible} of options.dictionaries) { 52 let collapsible = false; 53 let collapsed = false; 54 let force = false; 55 switch (definitionsCollapsible) { 56 case 'expanded': 57 collapsible = true; 58 break; 59 case 'collapsed': 60 collapsible = true; 61 collapsed = true; 62 break; 63 case 'force-expanded': 64 collapsible = true; 65 force = true; 66 break; 67 case 'force-collapsed': 68 collapsible = true; 69 collapsed = true; 70 force = true; 71 break; 72 } 73 if (!collapsible) { continue; } 74 this._dictionaries.set(name, {collapsed, force}); 75 } 76 } 77 78 /** 79 * @param {Element} entry 80 */ 81 addElements(entry) { 82 if (this._dictionaries.size === 0) { return; } 83 84 85 /** @type {Element[]} */ 86 const elements = [ 87 ...entry.querySelectorAll('.definition-item-inner'), 88 ...entry.querySelectorAll('.kanji-glyph-data'), 89 ]; 90 for (const element of elements) { 91 const {parentNode} = element; 92 if (parentNode === null) { continue; } 93 const {dictionary} = /** @type {HTMLElement} */ (parentNode).dataset; 94 if (typeof dictionary === 'undefined') { continue; } 95 const dictionaryInfo = this._dictionaries.get(dictionary); 96 if (typeof dictionaryInfo === 'undefined') { continue; } 97 98 if (dictionaryInfo.force) { 99 element.classList.add('collapsible', 'collapsible-forced'); 100 } else { 101 this._updateElement(element); 102 this._elements.push(element); 103 } 104 105 if (dictionaryInfo.collapsed) { 106 element.classList.add('collapsed'); 107 } 108 109 const button = element.querySelector('.expansion-button'); 110 if (button !== null) { 111 this._eventListeners.addEventListener(button, 'click', this._onToggleButtonClickBind, false); 112 } 113 } 114 115 if (this._elements.length > 0 && this._windowEventListeners.size === 0) { 116 this._windowEventListeners.addEventListener(window, 'resize', this._onWindowResizeBind, false); 117 } 118 } 119 120 /** */ 121 clearElements() { 122 this._elements.length = 0; 123 this._eventListeners.removeAllEventListeners(); 124 this._windowEventListeners.removeAllEventListeners(); 125 } 126 127 // Private 128 129 /** */ 130 _onWindowResize() { 131 if (this._checkTimer !== null) { 132 this._cancelIdleCallback(this._checkTimer); 133 } 134 this._checkTimer = this._requestIdleCallback(this._updateBind, 100); 135 } 136 137 /** 138 * @param {MouseEvent} e 139 */ 140 _onToggleButtonClick(e) { 141 const element = /** @type {Element} */ (e.currentTarget); 142 /** @type {(Element | null)[]} */ 143 const collapsedElements = [ 144 element.closest('.definition-item-inner'), 145 element.closest('.kanji-glyph-data'), 146 ]; 147 for (const collapsedElement of collapsedElements) { 148 if (collapsedElement === null) { continue; } 149 const collapsed = collapsedElement.classList.toggle('collapsed'); 150 if (collapsed) { 151 this._display.scrollUpToElementTop(element); 152 } 153 } 154 } 155 156 /** */ 157 _update() { 158 for (const element of this._elements) { 159 this._updateElement(element); 160 } 161 } 162 163 /** 164 * @param {Element} element 165 */ 166 _updateElement(element) { 167 const {classList} = element; 168 classList.add('collapse-test'); 169 const collapsible = element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; 170 classList.toggle('collapsible', collapsible); 171 classList.remove('collapse-test'); 172 } 173 174 /** 175 * @param {() => void} callback 176 * @param {number} timeout 177 * @returns {number|import('core').Timeout} 178 */ 179 _requestIdleCallback(callback, timeout) { 180 return ( 181 typeof requestIdleCallback === 'function' ? 182 requestIdleCallback(callback, {timeout}) : 183 setTimeout(callback, timeout) 184 ); 185 } 186 187 /** 188 * @param {number|import('core').Timeout} handle 189 */ 190 _cancelIdleCallback(handle) { 191 if (typeof cancelIdleCallback === 'function') { 192 cancelIdleCallback(/** @type {number} */ (handle)); 193 } else { 194 clearTimeout(handle); 195 } 196 } 197}