Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}