diff options
Diffstat (limited to 'ext/js')
36 files changed, 10696 insertions, 0 deletions
diff --git a/ext/js/background/environment.js b/ext/js/background/environment.js new file mode 100644 index 00000000..5e61a56d --- /dev/null +++ b/ext/js/background/environment.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + + +class Environment { + constructor() { + this._cachedEnvironmentInfo = null; + } + + async prepare() { + this._cachedEnvironmentInfo = await this._loadEnvironmentInfo(); + } + + getInfo() { + if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); } + return this._cachedEnvironmentInfo; + } + + async _loadEnvironmentInfo() { + const browser = await this._getBrowser(); + const os = await this._getOperatingSystem(); + return { + browser, + platform: {os} + }; + } + + async _getOperatingSystem() { + try { + const {os} = await this._getPlatformInfo(); + if (typeof os === 'string') { + return os; + } + } catch (e) { + // NOP + } + return 'unknown'; + } + + _getPlatformInfo() { + return new Promise((resolve, reject) => { + chrome.runtime.getPlatformInfo((result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + } + + async _getBrowser() { + try { + if (chrome.runtime.getURL('/').startsWith('ms-browser-extension://')) { + return 'edge-legacy'; + } + if (/\bEdge?\//.test(navigator.userAgent)) { + return 'edge'; + } + } catch (e) { + // NOP + } + if (typeof browser !== 'undefined') { + try { + const info = await browser.runtime.getBrowserInfo(); + if (info.name === 'Fennec') { + return 'firefox-mobile'; + } + } catch (e) { + // NOP + } + return 'firefox'; + } else { + return 'chrome'; + } + } +} diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js new file mode 100644 index 00000000..d37b091a --- /dev/null +++ b/ext/js/comm/api.js @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2016-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * CrossFrameAPI + */ + +const api = (() => { + class API { + constructor() { + this._forwardLogsToBackendEnabled = false; + this._crossFrame = new CrossFrameAPI(); + } + + get crossFrame() { + return this._crossFrame; + } + + prepare() { + this._crossFrame.prepare(); + } + + forwardLogsToBackend() { + if (this._forwardLogsToBackendEnabled) { return; } + this._forwardLogsToBackendEnabled = true; + + yomichan.on('log', async ({error, level, context}) => { + try { + await this.log(serializeError(error), level, context); + } catch (e) { + // NOP + } + }); + } + + // Invoke functions + + optionsGet(optionsContext) { + return this._invoke('optionsGet', {optionsContext}); + } + + optionsGetFull() { + return this._invoke('optionsGetFull'); + } + + termsFind(text, details, optionsContext) { + return this._invoke('termsFind', {text, details, optionsContext}); + } + + textParse(text, optionsContext) { + return this._invoke('textParse', {text, optionsContext}); + } + + kanjiFind(text, optionsContext) { + return this._invoke('kanjiFind', {text, optionsContext}); + } + + isAnkiConnected() { + return this._invoke('isAnkiConnected'); + } + + getAnkiConnectVersion() { + return this._invoke('getAnkiConnectVersion'); + } + + addAnkiNote(note) { + return this._invoke('addAnkiNote', {note}); + } + + getAnkiNoteInfo(notes) { + return this._invoke('getAnkiNoteInfo', {notes}); + } + + injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails) { + return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails}); + } + + noteView(noteId) { + return this._invoke('noteView', {noteId}); + } + + suspendAnkiCardsForNote(noteId) { + return this._invoke('suspendAnkiCardsForNote', {noteId}); + } + + getExpressionAudioInfoList(source, expression, reading, details) { + return this._invoke('getExpressionAudioInfoList', {source, expression, reading, details}); + } + + commandExec(command, params) { + return this._invoke('commandExec', {command, params}); + } + + sendMessageToFrame(frameId, action, params) { + return this._invoke('sendMessageToFrame', {frameId, action, params}); + } + + broadcastTab(action, params) { + return this._invoke('broadcastTab', {action, params}); + } + + frameInformationGet() { + return this._invoke('frameInformationGet'); + } + + injectStylesheet(type, value) { + return this._invoke('injectStylesheet', {type, value}); + } + + getStylesheetContent(url) { + return this._invoke('getStylesheetContent', {url}); + } + + getEnvironmentInfo() { + return this._invoke('getEnvironmentInfo'); + } + + clipboardGet() { + return this._invoke('clipboardGet'); + } + + getDisplayTemplatesHtml() { + return this._invoke('getDisplayTemplatesHtml'); + } + + getZoom() { + return this._invoke('getZoom'); + } + + getDefaultAnkiFieldTemplates() { + return this._invoke('getDefaultAnkiFieldTemplates'); + } + + getDictionaryInfo() { + return this._invoke('getDictionaryInfo'); + } + + getDictionaryCounts(dictionaryNames, getTotal) { + return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}); + } + + purgeDatabase() { + return this._invoke('purgeDatabase'); + } + + getMedia(targets) { + return this._invoke('getMedia', {targets}); + } + + log(error, level, context) { + return this._invoke('log', {error, level, context}); + } + + logIndicatorClear() { + return this._invoke('logIndicatorClear'); + } + + modifySettings(targets, source) { + return this._invoke('modifySettings', {targets, source}); + } + + getSettings(targets) { + return this._invoke('getSettings', {targets}); + } + + setAllSettings(value, source) { + return this._invoke('setAllSettings', {value, source}); + } + + getOrCreateSearchPopup(details) { + return this._invoke('getOrCreateSearchPopup', isObject(details) ? details : {}); + } + + isTabSearchPopup(tabId) { + return this._invoke('isTabSearchPopup', {tabId}); + } + + triggerDatabaseUpdated(type, cause) { + return this._invoke('triggerDatabaseUpdated', {type, cause}); + } + + testMecab() { + return this._invoke('testMecab', {}); + } + + // Utilities + + _createActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + const portDetails = deferPromise(); + + const onConnect = async (port) => { + try { + const {name: expectedName, id: expectedId} = await portDetails.promise; + const {name, id} = JSON.parse(port.name); + if (name !== expectedName || id !== expectedId || timer === null) { return; } + } catch (e) { + return; + } + + clearTimeout(timer); + timer = null; + + chrome.runtime.onConnect.removeListener(onConnect); + resolve(port); + }; + + const onError = (e) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + chrome.runtime.onConnect.removeListener(onConnect); + portDetails.reject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + this._invoke('createActionPort').then(portDetails.resolve, onError); + }); + } + + _invokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + case 'progress': + try { + onProgress(...message.data); + } catch (e) { + // NOP + } + break; + case 'complete': + cleanup(); + resolve(message.data); + break; + case 'error': + cleanup(); + reject(deserializeError(message.data)); + break; + } + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + (async () => { + try { + port = await this._createActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + + // Chrome has a maximum message size that can be sent, so longer messages must be fragmented. + const messageString = JSON.stringify({action, params}); + const fragmentSize = 1e7; // 10 MB + for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) { + const data = messageString.substring(i, i + fragmentSize); + port.postMessage({action: 'fragment', data}); + } + port.postMessage({action: 'invoke'}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; + } + })(); + }); + } + + _invoke(action, params={}) { + const data = {action, params}; + return new Promise((resolve, reject) => { + try { + yomichan.sendMessage(data, (response) => { + this._checkLastError(chrome.runtime.lastError); + if (response !== null && typeof response === 'object') { + if (typeof response.error !== 'undefined') { + reject(deserializeError(response.error)); + } else { + resolve(response.result); + } + } else { + const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; + reject(new Error(`${message} (${JSON.stringify(data)})`)); + } + }); + } catch (e) { + reject(e); + } + }); + } + + _checkLastError() { + // NOP + } + } + + // eslint-disable-next-line no-shadow + const api = new API(); + api.prepare(); + return api; +})(); diff --git a/ext/js/comm/cross-frame-api.js b/ext/js/comm/cross-frame-api.js new file mode 100644 index 00000000..997249c8 --- /dev/null +++ b/ext/js/comm/cross-frame-api.js @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class CrossFrameAPIPort extends EventDispatcher { + constructor(otherTabId, otherFrameId, port, messageHandlers) { + super(); + this._otherTabId = otherTabId; + this._otherFrameId = otherFrameId; + this._port = port; + this._messageHandlers = messageHandlers; + this._activeInvocations = new Map(); + this._invocationId = 0; + this._eventListeners = new EventListenerCollection(); + } + + get otherTabId() { + return this._otherTabId; + } + + get otherFrameId() { + return this._otherFrameId; + } + + prepare() { + this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); + this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); + } + + invoke(action, params, ackTimeout, responseTimeout) { + return new Promise((resolve, reject) => { + if (this._port === null) { + reject(new Error(`Port is disconnected (${action})`)); + return; + } + + const id = this._invocationId++; + const invocation = { + id, + resolve, + reject, + responseTimeout, + action, + ack: false, + timer: null + }; + this._activeInvocations.set(id, invocation); + + if (ackTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout); + } catch (e) { + this._onError(id, 'Failed to set timeout'); + return; + } + } + + try { + this._port.postMessage({type: 'invoke', id, data: {action, params}}); + } catch (e) { + this._onError(id, e); + } + }); + } + + disconnect() { + this._onDisconnect(); + } + + // Private + + _onDisconnect() { + if (this._port === null) { return; } + this._eventListeners.removeAllEventListeners(); + this._port = null; + for (const id of this._activeInvocations.keys()) { + this._onError(id, 'Disconnected'); + } + this.trigger('disconnect', this); + } + + _onMessage({type, id, data}) { + switch (type) { + case 'invoke': + this._onInvoke(id, data); + break; + case 'ack': + this._onAck(id); + break; + case 'result': + this._onResult(id, data); + break; + } + } + + // Response handlers + + _onAck(id) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found for acknowledgement`)); + return; + } + + if (invocation.ack) { + this._onError(id, `Request ${id} already acknowledged`); + return; + } + + invocation.ack = true; + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const responseTimeout = invocation.responseTimeout; + if (responseTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, 'Response timeout'), responseTimeout); + } catch (e) { + this._onError(id, 'Failed to set timeout'); + } + } + } + + _onResult(id, data) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found`)); + return; + } + + if (!invocation.ack) { + this._onError(id, `Request ${id} not acknowledged`); + return; + } + + this._activeInvocations.delete(id); + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const error = data.error; + if (typeof error !== 'undefined') { + invocation.reject(deserializeError(error)); + } else { + invocation.resolve(data.result); + } + } + + _onError(id, error) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { return; } + + if (typeof error === 'string') { + error = new Error(`${error} (${invocation.action})`); + } + + this._activeInvocations.delete(id); + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + invocation.reject(error); + } + + // Invocation + + _onInvoke(id, {action, params}) { + const messageHandler = this._messageHandlers.get(action); + this._sendAck(id); + if (typeof messageHandler === 'undefined') { + this._sendError(id, new Error(`Unknown action: ${action}`)); + return false; + } + + const callback = (data) => this._sendResult(id, data); + return yomichan.invokeMessageHandler(messageHandler, params, callback); + } + + _sendResponse(data) { + if (this._port === null) { return; } + try { + this._port.postMessage(data); + } catch (e) { + // NOP + } + } + + _sendAck(id) { + this._sendResponse({type: 'ack', id}); + } + + _sendResult(id, data) { + this._sendResponse({type: 'result', id, data}); + } + + _sendError(id, error) { + this._sendResponse({type: 'result', id, data: {error: serializeError(error)}}); + } +} + +class CrossFrameAPI { + constructor() { + this._ackTimeout = 3000; // 3 seconds + this._responseTimeout = 10000; // 10 seconds + this._commPorts = new Map(); + this._messageHandlers = new Map(); + this._onDisconnectBind = this._onDisconnect.bind(this); + } + + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); + } + + invoke(targetFrameId, action, params={}) { + return this.invokeTab(null, targetFrameId, action, params); + } + + async invokeTab(targetTabId, targetFrameId, action, params={}) { + if (typeof targetTabId !== 'number') { targetTabId = null; } + const commPort = this._getOrCreateCommPort(targetTabId, targetFrameId); + return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); + } + + registerHandlers(messageHandlers) { + for (const [key, value] of messageHandlers) { + if (this._messageHandlers.has(key)) { + throw new Error(`Handler ${key} is already registered`); + } + this._messageHandlers.set(key, value); + } + } + + unregisterHandler(key) { + return this._messageHandlers.delete(key); + } + + // Private + + _onConnect(port) { + try { + let details; + try { + details = JSON.parse(port.name); + } catch (e) { + return; + } + if (details.name !== 'cross-frame-communication-port') { return; } + + const otherTabId = details.sourceTabId; + const otherFrameId = details.sourceFrameId; + this._setupCommPort(otherTabId, otherFrameId, port); + } catch (e) { + port.disconnect(); + yomichan.logError(e); + } + } + + _onDisconnect(commPort) { + commPort.off('disconnect', this._onDisconnectBind); + const {otherTabId, otherFrameId} = commPort; + const tabPorts = this._commPorts.get(otherTabId); + if (typeof tabPorts !== 'undefined') { + tabPorts.delete(otherFrameId); + if (tabPorts.size === 0) { + this._commPorts.delete(otherTabId); + } + } + } + + _getOrCreateCommPort(otherTabId, otherFrameId) { + const tabPorts = this._commPorts.get(otherTabId); + if (typeof tabPorts !== 'undefined') { + const commPort = tabPorts.get(otherFrameId); + if (typeof commPort !== 'undefined') { + return commPort; + } + } + return this._createCommPort(otherTabId, otherFrameId); + } + + _createCommPort(otherTabId, otherFrameId) { + const details = { + name: 'background-cross-frame-communication-port', + targetTabId: otherTabId, + targetFrameId: otherFrameId + }; + const port = yomichan.connect(null, {name: JSON.stringify(details)}); + return this._setupCommPort(otherTabId, otherFrameId, port); + } + + _setupCommPort(otherTabId, otherFrameId, port) { + const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._messageHandlers); + let tabPorts = this._commPorts.get(otherTabId); + if (typeof tabPorts === 'undefined') { + tabPorts = new Map(); + this._commPorts.set(otherTabId, tabPorts); + } + tabPorts.set(otherFrameId, commPort); + commPort.prepare(); + commPort.on('disconnect', this._onDisconnectBind); + return commPort; + } +} diff --git a/ext/js/comm/frame-client.js b/ext/js/comm/frame-client.js new file mode 100644 index 00000000..d15cee30 --- /dev/null +++ b/ext/js/comm/frame-client.js @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class FrameClient { + constructor() { + this._secret = null; + this._token = null; + this._frameId = null; + } + + get frameId() { + return this._frameId; + } + + async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout=10000) { + const {secret, token, frameId} = await this._connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout); + this._secret = secret; + this._token = token; + this._frameId = frameId; + } + + isConnected() { + return (this._secret !== null); + } + + createMessage(data) { + if (!this.isConnected()) { + throw new Error('Not connected'); + } + return { + token: this._token, + secret: this._secret, + data + }; + } + + _connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) { + return new Promise((resolve, reject) => { + const tokenMap = new Map(); + let timer = null; + let { + promise: frameLoadedPromise, + resolve: frameLoadedResolve, + reject: frameLoadedReject + } = deferPromise(); + + const postMessage = (action, params) => { + const contentWindow = frame.contentWindow; + if (contentWindow === null) { throw new Error('Frame missing content window'); } + + let validOrigin = true; + try { + validOrigin = (contentWindow.location.origin === targetOrigin); + } catch (e) { + // NOP + } + if (!validOrigin) { throw new Error('Unexpected frame origin'); } + + contentWindow.postMessage({action, params}, targetOrigin); + }; + + const onMessage = (message) => { + onMessageInner(message); + return false; + }; + + const onMessageInner = async (message) => { + try { + if (!isObject(message)) { return; } + const {action, params} = message; + if (!isObject(params)) { return; } + await frameLoadedPromise; + if (timer === null) { return; } // Done + + switch (action) { + case 'frameEndpointReady': + { + const {secret} = params; + const token = generateId(16); + tokenMap.set(secret, token); + postMessage('frameEndpointConnect', {secret, token, hostFrameId}); + } + break; + case 'frameEndpointConnected': + { + const {secret, token} = params; + const frameId = message.frameId; + const token2 = tokenMap.get(secret); + if (typeof token2 !== 'undefined' && token === token2) { + cleanup(); + resolve({secret, token, frameId}); + } + } + break; + } + } catch (e) { + cleanup(); + reject(e); + } + }; + + const onLoad = () => { + if (frameLoadedResolve === null) { + cleanup(); + reject(new Error('Unexpected load event')); + return; + } + + if (FrameClient.isFrameAboutBlank(frame)) { + return; + } + + frameLoadedResolve(); + frameLoadedResolve = null; + frameLoadedReject = null; + }; + + const cleanup = () => { + if (timer === null) { return; } // Done + clearTimeout(timer); + timer = null; + + frameLoadedResolve = null; + if (frameLoadedReject !== null) { + frameLoadedReject(new Error('Terminated')); + frameLoadedReject = null; + } + + chrome.runtime.onMessage.removeListener(onMessage); + frame.removeEventListener('load', onLoad); + }; + + // Start + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + chrome.runtime.onMessage.addListener(onMessage); + frame.addEventListener('load', onLoad); + + // Prevent unhandled rejections + frameLoadedPromise.catch(() => {}); // NOP + + setupFrame(frame); + }); + } + + static isFrameAboutBlank(frame) { + try { + const contentDocument = frame.contentDocument; + if (contentDocument === null) { return false; } + const url = contentDocument.location.href; + return /^about:blank(?:[#?]|$)/.test(url); + } catch (e) { + return false; + } + } +} diff --git a/ext/js/comm/frame-endpoint.js b/ext/js/comm/frame-endpoint.js new file mode 100644 index 00000000..27af9cf3 --- /dev/null +++ b/ext/js/comm/frame-endpoint.js @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * api + */ + +class FrameEndpoint { + constructor() { + this._secret = generateId(16); + this._token = null; + this._eventListeners = new EventListenerCollection(); + this._eventListenersSetup = false; + } + + signal() { + if (!this._eventListenersSetup) { + this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false); + this._eventListenersSetup = true; + } + api.broadcastTab('frameEndpointReady', {secret: this._secret}); + } + + authenticate(message) { + return ( + this._token !== null && + isObject(message) && + this._token === message.token && + this._secret === message.secret + ); + } + + _onMessage(e) { + if (this._token !== null) { return; } // Already initialized + + const data = e.data; + if (!isObject(data) || data.action !== 'frameEndpointConnect') { return; } // Invalid message + + const params = data.params; + if (!isObject(params)) { return; } // Invalid data + + const secret = params.secret; + if (secret !== this._secret) { return; } // Invalid authentication + + const {token, hostFrameId} = params; + this._token = token; + + this._eventListeners.removeAllEventListeners(); + api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', {secret, token}); + } +} diff --git a/ext/js/core.js b/ext/js/core.js new file mode 100644 index 00000000..9305739a --- /dev/null +++ b/ext/js/core.js @@ -0,0 +1,609 @@ +/* + * Copyright (C) 2019-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 <https://www.gnu.org/licenses/>. + */ + +/** + * Converts an `Error` object to a serializable JSON object. + * @param error An error object to convert. + * @returns A simple object which can be serialized by `JSON.stringify()`. + */ +function serializeError(error) { + try { + if (typeof error === 'object' && error !== null) { + return { + name: error.name, + message: error.message, + stack: error.stack, + data: error.data + }; + } + } catch (e) { + // NOP + } + return { + value: error, + hasValue: true + }; +} + +/** + * Converts a serialized erorr into a standard `Error` object. + * @param serializedError A simple object which was initially generated by serializeError. + * @returns A new `Error` instance. + */ +function deserializeError(serializedError) { + if (serializedError.hasValue) { + return serializedError.value; + } + const error = new Error(serializedError.message); + error.name = serializedError.name; + error.stack = serializedError.stack; + error.data = serializedError.data; + return error; +} + +/** + * Checks whether a given value is a non-array object. + * @param value The value to check. + * @returns `true` if the value is an object and not an array, `false` otherwise. + */ +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Converts any string into a form that can be passed into the RegExp constructor. + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + * @param string The string to convert to a valid regular expression. + * @returns The escaped string. + */ +function escapeRegExp(string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Reverses a string. + * @param string The string to reverse. + * @returns The returned string, which retains proper UTF-16 surrogate pair order. + */ +function stringReverse(string) { + return [...string].reverse().join(''); +} + +/** + * Creates a deep clone of an object or value. This is similar to `JSON.parse(JSON.stringify(value))`. + * @param value The value to clone. + * @returns A new clone of the value. + * @throws An error if the value is circular and cannot be cloned. + */ +const clone = (() => { + // eslint-disable-next-line no-shadow + function clone(value) { + if (value === null) { return null; } + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + case 'bigint': + case 'symbol': + case 'undefined': + return value; + default: + return cloneInternal(value, new Set()); + } + } + + function cloneInternal(value, visited) { + if (value === null) { return null; } + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + case 'bigint': + case 'symbol': + case 'undefined': + return value; + case 'function': + return cloneObject(value, visited); + case 'object': + return Array.isArray(value) ? cloneArray(value, visited) : cloneObject(value, visited); + } + } + + function cloneArray(value, visited) { + if (visited.has(value)) { throw new Error('Circular'); } + try { + visited.add(value); + const result = []; + for (const item of value) { + result.push(cloneInternal(item, visited)); + } + return result; + } finally { + visited.delete(value); + } + } + + function cloneObject(value, visited) { + if (visited.has(value)) { throw new Error('Circular'); } + try { + visited.add(value); + const result = {}; + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + result[key] = cloneInternal(value[key], visited); + } + } + return result; + } finally { + visited.delete(value); + } + } + + return clone; +})(); + +/** + * Checks if an object or value is deeply equal to another object or value. + * @param value1 The first value to check. + * @param value2 The second value to check. + * @returns `true` if the values are the same object, or deeply equal without cycles. `false` otherwise. + */ +const deepEqual = (() => { + // eslint-disable-next-line no-shadow + function deepEqual(value1, value2) { + if (value1 === value2) { return true; } + + const type = typeof value1; + if (typeof value2 !== type) { return false; } + + switch (type) { + case 'object': + case 'function': + return deepEqualInternal(value1, value2, new Set()); + default: + return false; + } + } + + function deepEqualInternal(value1, value2, visited1) { + if (value1 === value2) { return true; } + + const type = typeof value1; + if (typeof value2 !== type) { return false; } + + switch (type) { + case 'object': + case 'function': + { + if (value1 === null || value2 === null) { return false; } + const array = Array.isArray(value1); + if (array !== Array.isArray(value2)) { return false; } + if (visited1.has(value1)) { return false; } + visited1.add(value1); + return array ? areArraysEqual(value1, value2, visited1) : areObjectsEqual(value1, value2, visited1); + } + default: + return false; + } + } + + function areObjectsEqual(value1, value2, visited1) { + const keys1 = Object.keys(value1); + const keys2 = Object.keys(value2); + if (keys1.length !== keys2.length) { return false; } + + const keys1Set = new Set(keys1); + for (const key of keys2) { + if (!keys1Set.has(key) || !deepEqualInternal(value1[key], value2[key], visited1)) { return false; } + } + + return true; + } + + function areArraysEqual(value1, value2, visited1) { + const length = value1.length; + if (length !== value2.length) { return false; } + + for (let i = 0; i < length; ++i) { + if (!deepEqualInternal(value1[i], value2[i], visited1)) { return false; } + } + + return true; + } + + return deepEqual; +})(); + +/** + * Creates a new base-16 (lower case) string of a sequence of random bytes of the given length. + * @param length The number of bytes the string represents. The returned string's length will be twice as long. + * @returns A string of random characters. + */ +function generateId(length) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + let id = ''; + for (const value of array) { + id += value.toString(16).padStart(2, '0'); + } + return id; +} + +/** + * Creates an unresolved promise that can be resolved later, outside the promise's executor function. + * @returns An object `{promise, resolve, reject}`, containing the promise and the resolve/reject functions. + */ +function deferPromise() { + let resolve; + let reject; + const promise = new Promise((resolve2, reject2) => { + resolve = resolve2; + reject = reject2; + }); + return {promise, resolve, reject}; +} + +/** + * Creates a promise that is resolved after a set delay. + * @param delay How many milliseconds until the promise should be resolved. If 0, the promise is immediately resolved. + * @param resolveValue Optional; the value returned when the promise is resolved. + * @returns A promise with two additional properties: `resolve` and `reject`, which can be used to complete the promise early. + */ +function promiseTimeout(delay, resolveValue) { + if (delay <= 0) { + const promise = Promise.resolve(resolveValue); + promise.resolve = () => {}; // NOP + promise.reject = () => {}; // NOP + return promise; + } + + let timer = null; + let {promise, resolve, reject} = deferPromise(); + + const complete = (callback, value) => { + if (callback === null) { return; } + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + resolve = null; + reject = null; + callback(value); + }; + + const resolveWrapper = (value) => complete(resolve, value); + const rejectWrapper = (value) => complete(reject, value); + + timer = setTimeout(() => { + timer = null; + resolveWrapper(resolveValue); + }, delay); + + promise.resolve = resolveWrapper; + promise.reject = rejectWrapper; + + return promise; +} + +/** + * Creates a promise that will resolve after the next animation frame, using `requestAnimationFrame`. + * @param timeout Optional; a maximum duration (in milliseconds) to wait until the promise resolves. If null or omitted, no timeout is used. + * @returns A promise that is resolved with `{time, timeout}`, where `time` is the timestamp from `requestAnimationFrame`, + * and `timeout` is a boolean indicating whether the cause was a timeout or not. + * @throws The promise throws an error if animation is not supported in this context, such as in a service worker. + */ +function promiseAnimationFrame(timeout=null) { + return new Promise((resolve, reject) => { + if (typeof cancelAnimationFrame !== 'function' || typeof requestAnimationFrame !== 'function') { + reject(new Error('Animation not supported in this context')); + return; + } + + let timer = null; + let frameRequest = null; + const onFrame = (time) => { + frameRequest = null; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + resolve({time, timeout: false}); + }; + const onTimeout = () => { + timer = null; + if (frameRequest !== null) { + // eslint-disable-next-line no-undef + cancelAnimationFrame(frameRequest); + frameRequest = null; + } + resolve({time: performance.now(), timeout: true}); + }; + + // eslint-disable-next-line no-undef + frameRequest = requestAnimationFrame(onFrame); + if (typeof timeout === 'number') { + timer = setTimeout(onTimeout, timeout); + } + }); +} + +/** + * Base class controls basic event dispatching. + */ +class EventDispatcher { + /** + * Creates a new instance. + */ + constructor() { + this._eventMap = new Map(); + } + + /** + * Triggers an event with the given name and specified argument. + * @param eventName The string representing the event's name. + * @param details Optional; the argument passed to the callback functions. + * @returns `true` if any callbacks were registered, `false` otherwise. + */ + trigger(eventName, details) { + const callbacks = this._eventMap.get(eventName); + if (typeof callbacks === 'undefined') { return false; } + + for (const callback of callbacks) { + callback(details); + } + return true; + } + + /** + * Adds a single event listener to a specific event. + * @param eventName The string representing the event's name. + * @param callback The event listener callback to add. + */ + on(eventName, callback) { + let callbacks = this._eventMap.get(eventName); + if (typeof callbacks === 'undefined') { + callbacks = []; + this._eventMap.set(eventName, callbacks); + } + callbacks.push(callback); + } + + /** + * Removes a single event listener from a specific event. + * @param eventName The string representing the event's name. + * @param callback The event listener callback to add. + * @returns `true` if the callback was removed, `false` otherwise. + */ + off(eventName, callback) { + const callbacks = this._eventMap.get(eventName); + if (typeof callbacks === 'undefined') { return true; } + + const ii = callbacks.length; + for (let i = 0; i < ii; ++i) { + if (callbacks[i] === callback) { + callbacks.splice(i, 1); + if (callbacks.length === 0) { + this._eventMap.delete(eventName); + } + return true; + } + } + return false; + } + + /** + * Checks if an event has any listeners. + * @param eventName The string representing the event's name. + * @returns `true` if the event has listeners, `false` otherwise. + */ + hasListeners(eventName) { + const callbacks = this._eventMap.get(eventName); + return (typeof callbacks !== 'undefined' && callbacks.length > 0); + } +} + +/** + * Class which stores event listeners added to various objects, making it easy to remove them in bulk. + */ +class EventListenerCollection { + /** + * Creates a new instance. + */ + constructor() { + this._eventListeners = []; + } + + /** + * Returns the number of event listeners that are currently in the object. + * @returns The number of event listeners that are currently in the object. + */ + get size() { + return this._eventListeners.length; + } + + /** + * Adds an event listener of a generic type. + * @param type The type of event listener, which can be 'addEventListener', 'addListener', or 'on'. + * @param object The object to add the event listener to. + * @param args The argument array passed to the object's event listener adding function. + * @throws An error if type is not an expected value. + */ + addGeneric(type, object, ...args) { + switch (type) { + case 'addEventListener': return this.addEventListener(object, ...args); + case 'addListener': return this.addListener(object, ...args); + case 'on': return this.on(object, ...args); + default: throw new Error(`Invalid type: ${type}`); + } + } + + /** + * Adds an event listener using `object.addEventListener`. The listener will later be removed using `object.removeEventListener`. + * @param object The object to add the event listener to. + * @param args The argument array passed to the `addEventListener`/`removeEventListener` functions. + */ + addEventListener(object, ...args) { + object.addEventListener(...args); + this._eventListeners.push(['removeEventListener', object, ...args]); + } + + /** + * Adds an event listener using `object.addListener`. The listener will later be removed using `object.removeListener`. + * @param object The object to add the event listener to. + * @param args The argument array passed to the `addListener`/`removeListener` function. + */ + addListener(object, ...args) { + object.addListener(...args); + this._eventListeners.push(['removeListener', object, ...args]); + } + + /** + * Adds an event listener using `object.on`. The listener will later be removed using `object.off`. + * @param object The object to add the event listener to. + * @param args The argument array passed to the `on`/`off` function. + */ + on(object, ...args) { + object.on(...args); + this._eventListeners.push(['off', object, ...args]); + } + + /** + * Removes all event listeners added to objects for this instance and clears the internal list of event listeners. + */ + removeAllEventListeners() { + if (this._eventListeners.length === 0) { return; } + for (const [removeFunctionName, object, ...args] of this._eventListeners) { + switch (removeFunctionName) { + case 'removeEventListener': + object.removeEventListener(...args); + break; + case 'removeListener': + object.removeListener(...args); + break; + case 'off': + object.off(...args); + break; + default: + throw new Error(`Unknown remove function: ${removeFunctionName}`); + } + } + this._eventListeners = []; + } +} + +/** + * Class representing a generic value with an override stack. + * Changes can be observed by listening to the 'change' event. + */ +class DynamicProperty extends EventDispatcher { + /** + * Creates a new instance with the specified value. + * @param value The value to assign. + */ + constructor(value) { + super(); + this._value = value; + this._defaultValue = value; + this._overrides = []; + } + + /** + * Gets the default value for the property, which is assigned to the + * public value property when no overrides are present. + */ + get defaultValue() { + return this._defaultValue; + } + + /** + * Assigns the default value for the property. If no overrides are present + * and if the value is different than the current default value, + * the 'change' event will be triggered. + * @param value The value to assign. + */ + set defaultValue(value) { + this._defaultValue = value; + if (this._overrides.length === 0) { this._updateValue(); } + } + + /** + * Gets the current value for the property, taking any overrides into account. + */ + get value() { + return this._value; + } + + /** + * Gets the number of overrides added to the property. + */ + get overrideCount() { + return this._overrides.length; + } + + /** + * Adds an override value with the specified priority to the override stack. + * Values with higher priority will take precedence over those with lower. + * For tie breaks, the override value added first will take precedence. + * If the newly added override has the highest priority of all overrides + * and if the override value is different from the current value, + * the 'change' event will be fired. + * @param value The override value to assign. + * @param priority The priority value to use, as a number. + * @returns A string token which can be passed to the clearOverride function + * to remove the override. + */ + setOverride(value, priority=0) { + const overridesCount = this._overrides.length; + let i = 0; + for (; i < overridesCount; ++i) { + if (priority > this._overrides[i].priority) { break; } + } + const token = generateId(16); + this._overrides.splice(i, 0, {value, priority, token}); + if (i === 0) { this._updateValue(); } + return token; + } + + /** + * Removes a specific override value. If the removed override + * had the highest priority, and the new value is different from + * the previous value, the 'change' event will be fired. + * @param token The token for the corresponding override which is to be removed. + * @returns true if an override was returned, false otherwise. + */ + clearOverride(token) { + for (let i = 0, ii = this._overrides.length; i < ii; ++i) { + if (this._overrides[i].token === token) { + this._overrides.splice(i, 1); + if (i === 0) { this._updateValue(); } + return true; + } + } + return false; + } + + /** + * Updates the current value using the current overrides and default value. + * If the new value differs from the previous value, the 'change' event will be fired. + */ + _updateValue() { + const value = this._overrides.length > 0 ? this._overrides[0].value : this._defaultValue; + if (this._value === value) { return; } + this._value = value; + this.trigger('change', {value}); + } +} diff --git a/ext/js/debug/timer.js b/ext/js/debug/timer.js new file mode 100644 index 00000000..7c43efdf --- /dev/null +++ b/ext/js/debug/timer.js @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2019-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 <https://www.gnu.org/licenses/>. + */ + + +class Timer { + constructor(name) { + this.samples = []; + this.parent = null; + + this.sample(name); + const current = Timer.current; + if (current !== null) { + current.samples[current.samples.length - 1].children.push(this); + this.parent = current; + } + Timer.current = this; + } + + sample(name) { + const time = performance.now(); + this.samples.push({ + name, + time, + children: [] + }); + } + + complete(skip) { + this.sample('complete'); + + Timer.current = this.parent; + if (this.parent === null) { + if (!skip) { + console.log(this.toString()); + } + } else { + if (skip) { + const sample = this.parent.samples[this.parent.samples.length - 1]; + sample.children.splice(sample.children.length - 1, 1); + } + } + } + + duration(sampleIndex) { + const sampleIndexIsValid = (typeof sampleIndex === 'number'); + const startIndex = (sampleIndexIsValid ? sampleIndex : 0); + const endIndex = (sampleIndexIsValid ? sampleIndex + 1 : this.times.length - 1); + return (this.times[endIndex].time - this.times[startIndex].time); + } + + toString() { + const indent = ' '; + const name = this.samples[0].name; + const duration = this.samples[this.samples.length - 1].time - this.samples[0].time; + const extensionName = chrome.runtime.getManifest().name; + return `${name} took ${duration.toFixed(8)}ms [${extensionName}]` + this._indentString(this.getSampleString(), indent); + } + + getSampleString() { + const indent = ' '; + const duration = this.samples[this.samples.length - 1].time - this.samples[0].time; + let message = ''; + + for (let i = 0, ii = this.samples.length - 1; i < ii; ++i) { + const sample = this.samples[i]; + const sampleDuration = this.samples[i + 1].time - sample.time; + message += `\nSample[${i}] took ${sampleDuration.toFixed(8)}ms (${((sampleDuration / duration) * 100.0).toFixed(1)}%) [${sample.name}]`; + for (const child of sample.children) { + message += this._indentString(child.getSampleString(), indent); + } + } + + return message; + } + + _indentString(message, indent) { + return message.replace(/\n/g, `\n${indent}`); + } +} + +Timer.current = null; diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js new file mode 100644 index 00000000..f624d85b --- /dev/null +++ b/ext/js/display/display-audio.js @@ -0,0 +1,544 @@ +/* + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * AudioSystem + * PopupMenu + * api + */ + +class DisplayAudio { + constructor(display) { + this._display = display; + this._audioPlaying = null; + this._audioSystem = new AudioSystem(); + this._autoPlayAudioTimer = null; + this._autoPlayAudioDelay = 400; + this._eventListeners = new EventListenerCollection(); + this._cache = new Map(); + this._menuContainer = document.querySelector('#popup-menus'); + } + + get autoPlayAudioDelay() { + return this._autoPlayAudioDelay; + } + + set autoPlayAudioDelay(value) { + this._autoPlayAudioDelay = value; + } + + prepare() { + this._audioSystem.prepare(); + } + + updateOptions(options) { + const data = document.documentElement.dataset; + data.audioEnabled = `${options.audio.enabled && options.audio.sources.length > 0}`; + } + + cleanupEntries() { + this._cache.clear(); + this.clearAutoPlayTimer(); + this._eventListeners.removeAllEventListeners(); + } + + setupEntry(entry, definitionIndex) { + for (const button of entry.querySelectorAll('.action-play-audio')) { + const expressionIndex = this._getAudioPlayButtonExpressionIndex(button); + this._eventListeners.addEventListener(button, 'click', this._onAudioPlayButtonClick.bind(this, definitionIndex, expressionIndex), false); + this._eventListeners.addEventListener(button, 'contextmenu', this._onAudioPlayButtonContextMenu.bind(this, definitionIndex, expressionIndex), false); + this._eventListeners.addEventListener(button, 'menuClose', this._onAudioPlayMenuCloseClick.bind(this, definitionIndex, expressionIndex), false); + } + } + + setupEntriesComplete() { + const audioOptions = this._getAudioOptions(); + if (!audioOptions.enabled || !audioOptions.autoPlay) { return; } + + this.clearAutoPlayTimer(); + + const definitions = this._display.definitions; + if (definitions.length === 0) { return; } + + const firstDefinition = definitions[0]; + if (firstDefinition.type === 'kanji') { return; } + + const callback = () => { + this._autoPlayAudioTimer = null; + this.playAudio(0, 0); + }; + + if (this._autoPlayAudioDelay > 0) { + this._autoPlayAudioTimer = setTimeout(callback, this._autoPlayAudioDelay); + } else { + callback(); + } + } + + clearAutoPlayTimer() { + if (this._autoPlayAudioTimer === null) { return; } + clearTimeout(this._autoPlayAudioTimer); + this._autoPlayAudioTimer = null; + } + + stopAudio() { + if (this._audioPlaying === null) { return; } + this._audioPlaying.pause(); + this._audioPlaying = null; + } + + async playAudio(definitionIndex, expressionIndex, sources=null, sourceDetailsMap=null) { + this.stopAudio(); + this.clearAutoPlayTimer(); + + const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex); + if (expressionReading === null) { return; } + + const buttons = this._getAudioPlayButtons(definitionIndex, expressionIndex); + + const {expression, reading} = expressionReading; + const audioOptions = this._getAudioOptions(); + const {textToSpeechVoice, customSourceUrl, customSourceType, volume} = audioOptions; + if (!Array.isArray(sources)) { + ({sources} = audioOptions); + } + if (!(sourceDetailsMap instanceof Map)) { + sourceDetailsMap = null; + } + + const progressIndicatorVisible = this._display.progressIndicatorVisible; + const overrideToken = progressIndicatorVisible.setOverride(true); + try { + // Create audio + let audio; + let title; + const info = await this._createExpressionAudio(sources, sourceDetailsMap, expression, reading, {textToSpeechVoice, customSourceUrl, customSourceType}); + if (info !== null) { + let source; + ({audio, source} = info); + const sourceIndex = sources.indexOf(source); + title = `From source ${1 + sourceIndex}: ${source}`; + } else { + audio = this._audioSystem.getFallbackAudio(); + title = 'Could not find audio'; + } + + // Stop any currently playing audio + this.stopAudio(); + + // Update details + const potentialAvailableAudioCount = this._getPotentialAvailableAudioCount(expression, reading); + for (const button of buttons) { + const titleDefault = button.dataset.titleDefault || ''; + button.title = `${titleDefault}\n${title}`; + this._updateAudioPlayButtonBadge(button, potentialAvailableAudioCount); + } + + // Play + audio.currentTime = 0; + audio.volume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; + + const playPromise = audio.play(); + this._audioPlaying = audio; + + if (typeof playPromise !== 'undefined') { + try { + await playPromise; + } catch (e) { + // NOP + } + } + } finally { + progressIndicatorVisible.clearOverride(overrideToken); + } + } + + // Private + + _onAudioPlayButtonClick(definitionIndex, expressionIndex, e) { + e.preventDefault(); + + if (e.shiftKey) { + this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex); + } else { + this.playAudio(definitionIndex, expressionIndex); + } + } + + _onAudioPlayButtonContextMenu(definitionIndex, expressionIndex, e) { + e.preventDefault(); + + this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex); + } + + _onAudioPlayMenuCloseClick(definitionIndex, expressionIndex, e) { + const {detail: {action, item}} = e; + switch (action) { + case 'playAudioFromSource': + { + const {source, index} = item.dataset; + let sourceDetailsMap = null; + if (typeof index !== 'undefined') { + const index2 = Number.parseInt(index, 10); + sourceDetailsMap = new Map([ + [source, {start: index2, end: index2 + 1}] + ]); + } + this.playAudio(definitionIndex, expressionIndex, [source], sourceDetailsMap); + } + break; + } + } + + _getAudioPlayButtonExpressionIndex(button) { + const expressionNode = button.closest('.term-expression'); + if (expressionNode !== null) { + const expressionIndex = parseInt(expressionNode.dataset.index, 10); + if (Number.isFinite(expressionIndex)) { return expressionIndex; } + } + return 0; + } + + _getAudioPlayButtons(definitionIndex, expressionIndex) { + const results = []; + const {definitionNodes} = this._display; + if (definitionIndex >= 0 && definitionIndex < definitionNodes.length) { + const node = definitionNodes[definitionIndex]; + const button1 = (expressionIndex === 0 ? node.querySelector('.action-play-audio') : null); + const button2 = node.querySelector(`.term-expression:nth-of-type(${expressionIndex + 1}) .action-play-audio`); + if (button1 !== null) { results.push(button1); } + if (button2 !== null) { results.push(button2); } + } + return results; + } + + async _createExpressionAudio(sources, sourceDetailsMap, expression, reading, details) { + const key = this._getExpressionReadingKey(expression, reading); + + let sourceMap = this._cache.get(key); + if (typeof sourceMap === 'undefined') { + sourceMap = new Map(); + this._cache.set(key, sourceMap); + } + + for (let i = 0, ii = sources.length; i < ii; ++i) { + const source = sources[i]; + + let infoListPromise; + let sourceInfo = sourceMap.get(source); + if (typeof sourceInfo === 'undefined') { + infoListPromise = this._getExpressionAudioInfoList(source, expression, reading, details); + sourceInfo = {infoListPromise, infoList: null}; + sourceMap.set(source, sourceInfo); + } + + let {infoList} = sourceInfo; + if (infoList === null) { + infoList = await infoListPromise; + sourceInfo.infoList = infoList; + } + + let start = 0; + let end = infoList.length; + + if (sourceDetailsMap !== null) { + const sourceDetails = sourceDetailsMap.get(source); + if (typeof sourceDetails !== 'undefined') { + const {start: start2, end: end2} = sourceDetails; + if (this._isInteger(start2)) { start = this._clamp(start2, start, end); } + if (this._isInteger(end2)) { end = this._clamp(end2, start, end); } + } + } + + const audio = await this._createAudioFromInfoList(source, infoList, start, end); + if (audio !== null) { return audio; } + } + + return null; + } + + async _createAudioFromInfoList(source, infoList, start, end) { + for (let i = start; i < end; ++i) { + const item = infoList[i]; + + let {audio, audioResolved} = item; + + if (!audioResolved) { + let {audioPromise} = item; + if (audioPromise === null) { + audioPromise = this._createAudioFromInfo(item.info, source); + item.audioPromise = audioPromise; + } + + try { + audio = await audioPromise; + } catch (e) { + continue; + } finally { + item.audioResolved = true; + } + + item.audio = audio; + } + + if (audio === null) { continue; } + + return {audio, source, infoListIndex: i}; + } + return null; + } + + async _createAudioFromInfo(info, source) { + switch (info.type) { + case 'url': + return await this._audioSystem.createAudio(info.url, source); + case 'tts': + return this._audioSystem.createTextToSpeechAudio(info.text, info.voice); + default: + throw new Error(`Unsupported type: ${info.type}`); + } + } + + async _getExpressionAudioInfoList(source, expression, reading, details) { + const infoList = await api.getExpressionAudioInfoList(source, expression, reading, details); + return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null})); + } + + _getExpressionAndReading(definitionIndex, expressionIndex) { + const {definitions} = this._display; + if (definitionIndex < 0 || definitionIndex >= definitions.length) { return null; } + + const definition = definitions[definitionIndex]; + if (definition.type === 'kanji') { return null; } + + const {expressions} = definition; + if (expressionIndex < 0 || expressionIndex >= expressions.length) { return null; } + + const {expression, reading} = expressions[expressionIndex]; + return {expression, reading}; + } + + _getExpressionReadingKey(expression, reading) { + return JSON.stringify([expression, reading]); + } + + _getAudioOptions() { + return this._display.getOptions().audio; + } + + _isInteger(value) { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.floor(value) === value + ); + } + + _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + _updateAudioPlayButtonBadge(button, potentialAvailableAudioCount) { + if (potentialAvailableAudioCount === null) { + delete button.dataset.potentialAvailableAudioCount; + } else { + button.dataset.potentialAvailableAudioCount = `${potentialAvailableAudioCount}`; + } + + const badge = button.querySelector('.action-button-badge'); + if (badge === null) { return; } + + const badgeData = badge.dataset; + switch (potentialAvailableAudioCount) { + case 0: + badgeData.icon = 'cross'; + badgeData.hidden = false; + break; + case 1: + case null: + delete badgeData.icon; + badgeData.hidden = true; + break; + default: + badgeData.icon = 'plus-thick'; + badgeData.hidden = false; + break; + } + } + + _getPotentialAvailableAudioCount(expression, reading) { + const key = this._getExpressionReadingKey(expression, reading); + const sourceMap = this._cache.get(key); + if (typeof sourceMap === 'undefined') { return null; } + + let count = 0; + for (const {infoList} of sourceMap.values()) { + if (infoList === null) { continue; } + for (const {audio, audioResolved} of infoList) { + if (!audioResolved || audio !== null) { + ++count; + } + } + } + return count; + } + + _showAudioMenu(button, definitionIndex, expressionIndex) { + const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex); + if (expressionReading === null) { return; } + + const {expression, reading} = expressionReading; + const popupMenu = this._createMenu(button, expression, reading); + popupMenu.prepare(); + } + + _createMenu(button, expression, reading) { + // Options + const {sources, textToSpeechVoice, customSourceUrl} = this._getAudioOptions(); + const sourceIndexMap = new Map(); + for (let i = 0, ii = sources.length; i < ii; ++i) { + sourceIndexMap.set(sources[i], i); + } + + // Create menu + const menuNode = this._display.displayGenerator.createPopupMenu('audio-button'); + + // Create menu item metadata + const menuItems = []; + const menuItemNodes = menuNode.querySelectorAll('.popup-menu-item'); + for (let i = 0, ii = menuItemNodes.length; i < ii; ++i) { + const node = menuItemNodes[i]; + const {source} = node.dataset; + let optionsIndex = sourceIndexMap.get(source); + if (typeof optionsIndex === 'undefined') { optionsIndex = null; } + menuItems.push({node, source, index: i, optionsIndex}); + } + + // Sort according to source order in options + menuItems.sort((a, b) => { + const ai = a.optionsIndex; + const bi = b.optionsIndex; + if (ai !== null) { + if (bi !== null) { + const i = ai - bi; + if (i !== 0) { return i; } + } else { + return -1; + } + } else { + if (bi !== null) { + return 1; + } + } + return a.index - b.index; + }); + + // Set up items based on cache data + const sourceMap = this._cache.get(this._getExpressionReadingKey(expression, reading)); + const menuEntryMap = new Map(); + let showIcons = false; + for (let i = 0, ii = menuItems.length; i < ii; ++i) { + const {node, source, optionsIndex} = menuItems[i]; + const entries = this._getMenuItemEntries(node, sourceMap, source); + menuEntryMap.set(source, entries); + for (const {node: node2, valid, index} of entries) { + if (valid !== null) { + const icon = node2.querySelector('.popup-menu-item-icon'); + icon.dataset.icon = valid ? 'checkmark' : 'cross'; + showIcons = true; + } + if (index !== null) { + node2.dataset.index = `${index}`; + } + node2.dataset.valid = `${valid}`; + node2.dataset.sourceInOptions = `${optionsIndex !== null}`; + node2.style.order = `${i}`; + } + } + menuNode.dataset.showIcons = `${showIcons}`; + + // Hide options + if (textToSpeechVoice.length === 0) { + this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech', true); + this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech-reading', true); + } + if (customSourceUrl.length === 0) { + this._setMenuItemEntriesHidden(menuEntryMap, 'custom', true); + } + + // Create popup menu + this._menuContainer.appendChild(menuNode); + return new PopupMenu(button, menuNode); + } + + _getMenuItemEntries(node, sourceMap, source) { + const entries = [{node, valid: null, index: null}]; + + const nextNode = node.nextSibling; + + if (typeof sourceMap === 'undefined') { return entries; } + + const sourceInfo = sourceMap.get(source); + if (typeof sourceInfo === 'undefined') { return entries; } + + const {infoList} = sourceInfo; + if (infoList === null) { return entries; } + + if (infoList.length === 0) { + entries[0].valid = false; + return entries; + } + + const defaultLabel = node.querySelector('.popup-menu-item-label').textContent; + + for (let i = 0, ii = infoList.length; i < ii; ++i) { + // Get/create entry + let entry; + if (i < entries.length) { + entry = entries[i]; + } else { + const node2 = node.cloneNode(true); + nextNode.parentNode.insertBefore(node2, nextNode); + entry = {node: node2, valid: null, index: null}; + entries.push(entry); + } + + // Entry info + entry.index = i; + + const {audio, audioResolved, info: {name}} = infoList[i]; + if (audioResolved) { entry.valid = (audio !== null); } + + const labelNode = entry.node.querySelector('.popup-menu-item-label'); + let label = defaultLabel; + if (ii > 1) { label = `${label} ${i + 1}`; } + if (typeof name === 'string' && name.length > 0) { label += `: ${name}`; } + labelNode.textContent = label; + } + + return entries; + } + + _setMenuItemEntriesHidden(menuEntryMap, source, hidden) { + const entries = menuEntryMap.get(source); + if (typeof entries === 'undefined') { return; } + + for (const {node} of entries) { + node.hidden = hidden; + } + } +} diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js new file mode 100644 index 00000000..05376ee5 --- /dev/null +++ b/ext/js/display/display-generator.js @@ -0,0 +1,702 @@ +/* + * Copyright (C) 2019-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 <http://www.gnu.org/licenses/>. + */ + +/* global + * DictionaryDataUtil + * HtmlTemplateCollection + * api + */ + +class DisplayGenerator { + constructor({japaneseUtil, mediaLoader, hotkeyHelpController=null}) { + this._japaneseUtil = japaneseUtil; + this._mediaLoader = mediaLoader; + this._hotkeyHelpController = hotkeyHelpController; + this._templates = null; + this._termPitchAccentStaticTemplateIsSetup = false; + } + + async prepare() { + const html = await api.getDisplayTemplatesHtml(); + this._templates = new HtmlTemplateCollection(html); + this.updateHotkeys(); + } + + updateHotkeys() { + const hotkeyHelpController = this._hotkeyHelpController; + if (hotkeyHelpController === null) { return; } + for (const template of this._templates.getAllTemplates()) { + hotkeyHelpController.setupNode(template.content); + } + } + + preparePitchAccents() { + if (this._termPitchAccentStaticTemplateIsSetup) { return; } + this._termPitchAccentStaticTemplateIsSetup = true; + const t = this._templates.instantiate('term-pitch-accent-static'); + document.head.appendChild(t); + } + + createTermEntry(details) { + const node = this._templates.instantiate('term-entry'); + + const expressionsContainer = node.querySelector('.term-expression-list'); + const reasonsContainer = node.querySelector('.term-reasons'); + const pitchesContainer = node.querySelector('.term-pitch-accent-group-list'); + const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); + const definitionsContainer = node.querySelector('.term-definition-list'); + const termTagsContainer = node.querySelector('.term-tags'); + + const {expressions, type, reasons, frequencies} = details; + const definitions = (type === 'term' ? [details] : details.definitions); + const merged = (type === 'termMerged' || type === 'termMergedByGlossary'); + const pitches = DictionaryDataUtil.getPitchAccentInfos(details); + const pitchCount = pitches.reduce((i, v) => i + v.pitches.length, 0); + const groupedFrequencies = DictionaryDataUtil.groupTermFrequencies(frequencies); + const termTags = DictionaryDataUtil.groupTermTags(details); + + const uniqueExpressions = new Set(); + const uniqueReadings = new Set(); + for (const {expression, reading} of expressions) { + uniqueExpressions.add(expression); + uniqueReadings.add(reading); + } + + node.dataset.format = type; + node.dataset.expressionMulti = `${merged}`; + node.dataset.expressionCount = `${expressions.length}`; + node.dataset.definitionCount = `${definitions.length}`; + node.dataset.pitchAccentDictionaryCount = `${pitches.length}`; + node.dataset.pitchAccentCount = `${pitchCount}`; + node.dataset.uniqueExpressionCount = `${uniqueExpressions.size}`; + node.dataset.uniqueReadingCount = `${uniqueReadings.size}`; + node.dataset.frequencyCount = `${frequencies.length}`; + node.dataset.groupedFrequencyCount = `${groupedFrequencies.length}`; + + this._appendMultiple(expressionsContainer, this._createTermExpression.bind(this), expressions); + this._appendMultiple(reasonsContainer, this._createTermReason.bind(this), reasons); + this._appendMultiple(frequencyGroupListContainer, this._createFrequencyGroup.bind(this), groupedFrequencies, false); + this._appendMultiple(pitchesContainer, this._createPitches.bind(this), pitches); + this._appendMultiple(termTagsContainer, this._createTermTag.bind(this), termTags, expressions.length); + + // Add definitions + const dictionaryTag = this._createDictionaryTag(null); + for (let i = 0, ii = definitions.length; i < ii; ++i) { + const definition = definitions[i]; + const {dictionary} = definition; + + if (dictionaryTag.dictionary === dictionary) { + dictionaryTag.redundant = true; + } else { + dictionaryTag.redundant = false; + dictionaryTag.dictionary = dictionary; + dictionaryTag.name = dictionary; + } + + const node2 = this._createTermDefinitionItem(definition, dictionaryTag); + node2.dataset.index = `${i}`; + definitionsContainer.appendChild(node2); + } + definitionsContainer.dataset.count = `${definitions.length}`; + + return node; + } + + createKanjiEntry(details) { + const node = this._templates.instantiate('kanji-entry'); + + const glyphContainer = node.querySelector('.kanji-glyph'); + const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); + const tagContainer = node.querySelector('.tags'); + const glossaryContainer = node.querySelector('.kanji-glossary-list'); + const chineseReadingsContainer = node.querySelector('.kanji-readings-chinese'); + const japaneseReadingsContainer = node.querySelector('.kanji-readings-japanese'); + const statisticsContainer = node.querySelector('.kanji-statistics'); + const classificationsContainer = node.querySelector('.kanji-classifications'); + const codepointsContainer = node.querySelector('.kanji-codepoints'); + const dictionaryIndicesContainer = node.querySelector('.kanji-dictionary-indices'); + + this._setTextContent(glyphContainer, details.character, 'ja'); + const groupedFrequencies = DictionaryDataUtil.groupKanjiFrequencies(details.frequencies); + + const dictionaryTag = this._createDictionaryTag(details.dictionary); + + this._appendMultiple(frequencyGroupListContainer, this._createFrequencyGroup.bind(this), groupedFrequencies, true); + this._appendMultiple(tagContainer, this._createTag.bind(this), [...details.tags, dictionaryTag]); + this._appendMultiple(glossaryContainer, this._createKanjiGlossaryItem.bind(this), details.glossary); + this._appendMultiple(chineseReadingsContainer, this._createKanjiReading.bind(this), details.onyomi); + this._appendMultiple(japaneseReadingsContainer, this._createKanjiReading.bind(this), details.kunyomi); + + statisticsContainer.appendChild(this._createKanjiInfoTable(details.stats.misc)); + classificationsContainer.appendChild(this._createKanjiInfoTable(details.stats.class)); + codepointsContainer.appendChild(this._createKanjiInfoTable(details.stats.code)); + dictionaryIndicesContainer.appendChild(this._createKanjiInfoTable(details.stats.index)); + + return node; + } + + createEmptyFooterNotification() { + return this._templates.instantiate('footer-notification'); + } + + createTagFooterNotificationDetails(tagNode) { + const node = this._templates.instantiateFragment('footer-notification-tag-details'); + + const details = tagNode.dataset.details; + this._setTextContent(node.querySelector('.tag-details'), details); + + let disambiguation = null; + try { + let a = tagNode.dataset.disambiguation; + if (typeof a !== 'undefined') { + a = JSON.parse(a); + if (Array.isArray(a)) { disambiguation = a; } + } + } catch (e) { + // NOP + } + + if (disambiguation !== null) { + const disambiguationContainer = node.querySelector('.tag-details-disambiguation-list'); + const copyAttributes = ['totalExpressionCount', 'matchedExpressionCount', 'unmatchedExpressionCount']; + for (const attribute of copyAttributes) { + const value = tagNode.dataset[attribute]; + if (typeof value === 'undefined') { continue; } + disambiguationContainer.dataset[attribute] = value; + } + for (const {expression, reading} of disambiguation) { + const segments = this._japaneseUtil.distributeFurigana(expression, reading); + const disambiguationItem = document.createElement('span'); + disambiguationItem.className = 'tag-details-disambiguation'; + disambiguationItem.lang = 'ja'; + this._appendFurigana(disambiguationItem, segments, (container, text) => { + container.appendChild(document.createTextNode(text)); + }); + disambiguationContainer.appendChild(disambiguationItem); + } + } + + return node; + } + + createAnkiNoteErrorsNotificationContent(errors) { + const content = this._templates.instantiate('footer-notification-anki-errors-content'); + + const header = content.querySelector('.anki-note-error-header'); + this._setTextContent(header, (errors.length === 1 ? 'An error occurred:' : `${errors.length} errors occurred:`), 'en'); + + const list = content.querySelector('.anki-note-error-list'); + for (const error of errors) { + const div = document.createElement('li'); + div.className = 'anki-note-error-message'; + this._setTextContent(div, isObject(error) && typeof error.message === 'string' ? error.message : `${error}`); + list.appendChild(div); + } + + return content; + } + + createProfileListItem() { + return this._templates.instantiate('profile-list-item'); + } + + createPopupMenu(name) { + return this._templates.instantiate(`${name}-popup-menu`); + } + + // Private + + _createTermExpression(details) { + const {termFrequency, furiganaSegments, expression, reading, termTags} = details; + + const searchQueries = []; + if (expression) { searchQueries.push(expression); } + if (reading) { searchQueries.push(reading); } + + const node = this._templates.instantiate('term-expression'); + + const expressionContainer = node.querySelector('.term-expression-text'); + const tagContainer = node.querySelector('.tags'); + + node.dataset.readingIsSame = `${!reading || reading === expression}`; + node.dataset.frequency = termFrequency; + + expressionContainer.lang = 'ja'; + + this._appendFurigana(expressionContainer, furiganaSegments, this._appendKanjiLinks.bind(this)); + this._appendMultiple(tagContainer, this._createTag.bind(this), termTags); + this._appendMultiple(tagContainer, this._createSearchTag.bind(this), searchQueries); + + return node; + } + + _createTermReason(reason) { + const fragment = this._templates.instantiateFragment('term-reason'); + const node = fragment.querySelector('.term-reason'); + this._setTextContent(node, reason); + node.dataset.reason = reason; + return fragment; + } + + _createTermDefinitionItem(details, dictionaryTag) { + const node = this._templates.instantiate('term-definition-item'); + + const tagListContainer = node.querySelector('.term-definition-tag-list'); + const onlyListContainer = node.querySelector('.term-definition-disambiguation-list'); + const glossaryContainer = node.querySelector('.term-glossary-list'); + + const {dictionary, definitionTags} = details; + node.dataset.dictionary = dictionary; + + this._appendMultiple(tagListContainer, this._createTag.bind(this), [...definitionTags, dictionaryTag]); + this._appendMultiple(onlyListContainer, this._createTermDisambiguation.bind(this), details.only); + this._appendMultiple(glossaryContainer, this._createTermGlossaryItem.bind(this), details.glossary, dictionary); + + return node; + } + + _createTermGlossaryItem(glossary, dictionary) { + if (typeof glossary === 'string') { + return this._createTermGlossaryItemText(glossary); + } else if (typeof glossary === 'object' && glossary !== null) { + switch (glossary.type) { + case 'image': + return this._createTermGlossaryItemImage(glossary, dictionary); + } + } + + return null; + } + + _createTermGlossaryItemText(glossary) { + const node = this._templates.instantiate('term-glossary-item'); + const container = node.querySelector('.term-glossary'); + this._setTextContent(container, glossary); + return node; + } + + _createTermGlossaryItemImage(data, dictionary) { + const {path, width, height, preferredWidth, preferredHeight, title, description, pixelated} = data; + + const usedWidth = ( + typeof preferredWidth === 'number' ? + preferredWidth : + width + ); + const aspectRatio = ( + typeof preferredWidth === 'number' && + typeof preferredHeight === 'number' ? + preferredWidth / preferredHeight : + width / height + ); + + const node = this._templates.instantiate('term-glossary-item-image'); + node.dataset.path = path; + node.dataset.dictionary = dictionary; + node.dataset.imageLoadState = 'not-loaded'; + + const imageContainer = node.querySelector('.term-glossary-image-container'); + imageContainer.style.width = `${usedWidth}em`; + if (typeof title === 'string') { + imageContainer.title = title; + } + + const aspectRatioSizer = node.querySelector('.term-glossary-image-aspect-ratio-sizer'); + aspectRatioSizer.style.paddingTop = `${aspectRatio * 100.0}%`; + + const image = node.querySelector('img.term-glossary-image'); + const imageLink = node.querySelector('.term-glossary-image-link'); + image.dataset.pixelated = `${pixelated === true}`; + + if (this._mediaLoader !== null) { + this._mediaLoader.loadMedia( + path, + dictionary, + (url) => this._setImageData(node, image, imageLink, url, false), + () => this._setImageData(node, image, imageLink, null, true) + ); + } + + if (typeof description === 'string') { + const container = node.querySelector('.term-glossary-image-description'); + this._setTextContent(container, description); + } + + return node; + } + + _setImageData(container, image, imageLink, url, unloaded) { + if (url !== null) { + image.src = url; + imageLink.href = url; + container.dataset.imageLoadState = 'loaded'; + } else { + image.removeAttribute('src'); + imageLink.removeAttribute('href'); + container.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; + } + } + + _createTermDisambiguation(disambiguation) { + const node = this._templates.instantiate('term-definition-disambiguation'); + node.dataset.term = disambiguation; + this._setTextContent(node, disambiguation, 'ja'); + return node; + } + + _createKanjiLink(character) { + const node = document.createElement('a'); + node.className = 'kanji-link'; + this._setTextContent(node, character, 'ja'); + return node; + } + + _createKanjiGlossaryItem(glossary) { + const node = this._templates.instantiate('kanji-glossary-item'); + const container = node.querySelector('.kanji-glossary'); + this._setTextContent(container, glossary); + return node; + } + + _createKanjiReading(reading) { + const node = this._templates.instantiate('kanji-reading'); + this._setTextContent(node, reading, 'ja'); + return node; + } + + _createKanjiInfoTable(details) { + const node = this._templates.instantiate('kanji-info-table'); + const container = node.querySelector('.kanji-info-table-body'); + + const count = this._appendMultiple(container, this._createKanjiInfoTableItem.bind(this), details); + if (count === 0) { + const n = this._createKanjiInfoTableItemEmpty(); + container.appendChild(n); + } + + return node; + } + + _createKanjiInfoTableItem(details) { + const node = this._templates.instantiate('kanji-info-table-item'); + const nameNode = node.querySelector('.kanji-info-table-item-header'); + const valueNode = node.querySelector('.kanji-info-table-item-value'); + this._setTextContent(nameNode, details.notes || details.name); + this._setTextContent(valueNode, details.value); + return node; + } + + _createKanjiInfoTableItemEmpty() { + return this._templates.instantiate('kanji-info-table-empty'); + } + + _createTag(details) { + const node = this._templates.instantiate('tag'); + + const inner = node.querySelector('.tag-inner'); + + node.title = details.notes; + this._setTextContent(inner, details.name); + node.dataset.details = details.notes || details.name; + node.dataset.category = details.category; + if (details.redundant) { node.dataset.redundant = 'true'; } + + return node; + } + + _createTermTag(details, totalExpressionCount) { + const {tag, expressions} = details; + const node = this._createTag(tag); + node.dataset.disambiguation = `${JSON.stringify(expressions)}`; + node.dataset.totalExpressionCount = `${totalExpressionCount}`; + node.dataset.matchedExpressionCount = `${expressions.length}`; + node.dataset.unmatchedExpressionCount = `${Math.max(0, totalExpressionCount - expressions.length)}`; + return node; + } + + _createSearchTag(text) { + return this._createTag({ + notes: '', + name: text, + category: 'search', + redundant: false + }); + } + + _createPitches(details) { + this.preparePitchAccents(); + + const {dictionary, pitches} = details; + + const node = this._templates.instantiate('term-pitch-accent-group'); + node.dataset.dictionary = dictionary; + node.dataset.pitchesMulti = 'true'; + node.dataset.pitchesCount = `${pitches.length}`; + + const tag = this._createTag({notes: '', name: dictionary, category: 'pitch-accent-dictionary'}); + node.querySelector('.term-pitch-accent-group-tag-list').appendChild(tag); + + let hasTags = false; + for (const {tags} of pitches) { + if (tags.length > 0) { + hasTags = true; + break; + } + } + + const n = node.querySelector('.term-pitch-accent-list'); + n.dataset.hasTags = `${hasTags}`; + this._appendMultiple(n, this._createPitch.bind(this), pitches); + + return node; + } + + _createPitch(details) { + const jp = this._japaneseUtil; + const {reading, position, tags, exclusiveExpressions, exclusiveReadings} = details; + const morae = jp.getKanaMorae(reading); + + const node = this._templates.instantiate('term-pitch-accent'); + + node.dataset.pitchAccentPosition = `${position}`; + node.dataset.tagCount = `${tags.length}`; + + let n = node.querySelector('.term-pitch-accent-position'); + this._setTextContent(n, `${position}`, ''); + + n = node.querySelector('.term-pitch-accent-tag-list'); + this._appendMultiple(n, this._createTag.bind(this), tags); + + n = node.querySelector('.term-pitch-accent-disambiguation-list'); + this._createPitchAccentDisambiguations(n, exclusiveExpressions, exclusiveReadings); + + n = node.querySelector('.term-pitch-accent-characters'); + for (let i = 0, ii = morae.length; i < ii; ++i) { + const mora = morae[i]; + const highPitch = jp.isMoraPitchHigh(i, position); + const highPitchNext = jp.isMoraPitchHigh(i + 1, position); + + const n1 = this._templates.instantiate('term-pitch-accent-character'); + const n2 = n1.querySelector('.term-pitch-accent-character-inner'); + + n1.dataset.position = `${i}`; + n1.dataset.pitch = highPitch ? 'high' : 'low'; + n1.dataset.pitchNext = highPitchNext ? 'high' : 'low'; + this._setTextContent(n2, mora, 'ja'); + + n.appendChild(n1); + } + + if (morae.length > 0) { + this._populatePitchGraph(node.querySelector('.term-pitch-accent-graph'), position, morae); + } + + return node; + } + + _createPitchAccentDisambiguations(container, exclusiveExpressions, exclusiveReadings) { + const templateName = 'term-pitch-accent-disambiguation'; + for (const exclusiveExpression of exclusiveExpressions) { + const node = this._templates.instantiate(templateName); + node.dataset.type = 'expression'; + this._setTextContent(node, exclusiveExpression, 'ja'); + container.appendChild(node); + } + + for (const exclusiveReading of exclusiveReadings) { + const node = this._templates.instantiate(templateName); + node.dataset.type = 'reading'; + this._setTextContent(node, exclusiveReading, 'ja'); + container.appendChild(node); + } + + container.dataset.count = `${exclusiveExpressions.length + exclusiveReadings.length}`; + container.dataset.expressionCount = `${exclusiveExpressions.length}`; + container.dataset.readingCount = `${exclusiveReadings.length}`; + } + + _populatePitchGraph(svg, position, morae) { + const jp = this._japaneseUtil; + const svgns = svg.getAttribute('xmlns'); + const ii = morae.length; + svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`); + + const pathPoints = []; + for (let i = 0; i < ii; ++i) { + const highPitch = jp.isMoraPitchHigh(i, position); + const highPitchNext = jp.isMoraPitchHigh(i + 1, position); + const graphic = (highPitch && !highPitchNext ? '#term-pitch-accent-graph-dot-downstep' : '#term-pitch-accent-graph-dot'); + const x = `${i * 50 + 25}`; + const y = highPitch ? '25' : '75'; + const use = document.createElementNS(svgns, 'use'); + use.setAttribute('href', graphic); + use.setAttribute('x', x); + use.setAttribute('y', y); + svg.appendChild(use); + pathPoints.push(`${x} ${y}`); + } + + let path = svg.querySelector('.term-pitch-accent-graph-line'); + path.setAttribute('d', `M${pathPoints.join(' L')}`); + + pathPoints.splice(0, ii - 1); + { + const highPitch = jp.isMoraPitchHigh(ii, position); + const x = `${ii * 50 + 25}`; + const y = highPitch ? '25' : '75'; + const use = document.createElementNS(svgns, 'use'); + use.setAttribute('href', '#term-pitch-accent-graph-triangle'); + use.setAttribute('x', x); + use.setAttribute('y', y); + svg.appendChild(use); + pathPoints.push(`${x} ${y}`); + } + + path = svg.querySelector('.term-pitch-accent-graph-line-tail'); + path.setAttribute('d', `M${pathPoints.join(' L')}`); + } + + _createFrequencyGroup(details, kanji) { + const {dictionary, frequencyData} = details; + const node = this._templates.instantiate('frequency-group-item'); + + const tagList = node.querySelector('.frequency-tag-list'); + const tag = this._createTag({notes: '', name: dictionary, category: 'frequency'}); + tagList.appendChild(tag); + + const frequencyListContainer = node.querySelector('.frequency-list'); + const createItem = (kanji ? this._createKanjiFrequency.bind(this) : this._createTermFrequency.bind(this)); + this._appendMultiple(frequencyListContainer, createItem, frequencyData, dictionary); + + node.dataset.count = `${frequencyData.length}`; + + return node; + } + + _createTermFrequency(details, dictionary) { + const {expression, reading, frequencies} = details; + const node = this._templates.instantiate('term-frequency-item'); + + const frequency = frequencies.join(', '); + + this._setTextContent(node.querySelector('.frequency-disambiguation-expression'), expression, 'ja'); + this._setTextContent(node.querySelector('.frequency-disambiguation-reading'), (reading !== null ? reading : ''), 'ja'); + this._setTextContent(node.querySelector('.frequency-value'), frequency, 'ja'); + + node.dataset.expression = expression; + node.dataset.reading = reading; + node.dataset.hasReading = `${reading !== null}`; + node.dataset.readingIsSame = `${reading === expression}`; + node.dataset.dictionary = dictionary; + node.dataset.frequency = `${frequency}`; + + return node; + } + + _createKanjiFrequency(details, dictionary) { + const {character, frequencies} = details; + const node = this._templates.instantiate('kanji-frequency-item'); + + const frequency = frequencies.join(', '); + + this._setTextContent(node.querySelector('.frequency-value'), frequency, 'ja'); + + node.dataset.character = character; + node.dataset.dictionary = dictionary; + node.dataset.frequency = `${frequency}`; + + return node; + } + + _appendKanjiLinks(container, text) { + container.lang = 'ja'; + const jp = this._japaneseUtil; + let part = ''; + for (const c of text) { + if (jp.isCodePointKanji(c.codePointAt(0))) { + if (part.length > 0) { + container.appendChild(document.createTextNode(part)); + part = ''; + } + + const link = this._createKanjiLink(c); + container.appendChild(link); + } else { + part += c; + } + } + if (part.length > 0) { + container.appendChild(document.createTextNode(part)); + } + } + + _appendMultiple(container, createItem, detailsArray, ...args) { + let count = 0; + const {ELEMENT_NODE} = Node; + if (Array.isArray(detailsArray)) { + for (const details of detailsArray) { + const item = createItem(details, ...args); + if (item === null) { continue; } + container.appendChild(item); + if (item.nodeType === ELEMENT_NODE) { + item.dataset.index = `${count}`; + } + ++count; + } + } + + container.dataset.count = `${count}`; + + return count; + } + + _appendFurigana(container, segments, addText) { + for (const {text, furigana} of segments) { + if (furigana) { + const ruby = document.createElement('ruby'); + const rt = document.createElement('rt'); + addText(ruby, text); + ruby.appendChild(rt); + rt.appendChild(document.createTextNode(furigana)); + container.appendChild(ruby); + } else { + addText(container, text); + } + } + } + + _createDictionaryTag(dictionary) { + return { + name: dictionary, + category: 'dictionary', + notes: '', + order: 100, + score: 0, + dictionary, + redundant: false + }; + } + + _setTextContent(node, value, language) { + node.textContent = value; + if (typeof language === 'string') { + node.lang = language; + } else if (this._japaneseUtil.isStringPartiallyJapanese(value)) { + node.lang = 'ja'; + } + } +} diff --git a/ext/js/display/display-history.js b/ext/js/display/display-history.js new file mode 100644 index 00000000..a6335521 --- /dev/null +++ b/ext/js/display/display-history.js @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class DisplayHistory extends EventDispatcher { + constructor({clearable=true, useBrowserHistory=false}) { + super(); + this._clearable = clearable; + this._useBrowserHistory = useBrowserHistory; + this._historyMap = new Map(); + + const historyState = history.state; + const {id, state} = isObject(historyState) ? historyState : {id: null, state: null}; + this._current = this._createHistoryEntry(id, location.href, state, null, null); + } + + get state() { + return this._current.state; + } + + get content() { + return this._current.content; + } + + get useBrowserHistory() { + return this._useBrowserHistory; + } + + set useBrowserHistory(value) { + this._useBrowserHistory = value; + } + + prepare() { + window.addEventListener('popstate', this._onPopState.bind(this), false); + } + + hasNext() { + return this._current.next !== null; + } + + hasPrevious() { + return this._current.previous !== null; + } + + clear() { + if (!this._clearable) { return; } + this._clear(); + } + + back() { + return this._go(false); + } + + forward() { + return this._go(true); + } + + pushState(state, content, url) { + if (typeof url === 'undefined') { url = location.href; } + + const entry = this._createHistoryEntry(null, url, state, content, this._current); + this._current.next = entry; + this._current = entry; + this._updateHistoryFromCurrent(!this._useBrowserHistory); + } + + replaceState(state, content, url) { + if (typeof url === 'undefined') { url = location.href; } + + this._current.url = url; + this._current.state = state; + this._current.content = content; + this._updateHistoryFromCurrent(true); + } + + _onPopState() { + this._updateStateFromHistory(); + this._triggerStateChanged(false); + } + + _go(forward) { + const target = forward ? this._current.next : this._current.previous; + if (target === null) { + return false; + } + + if (this._useBrowserHistory) { + if (forward) { + history.forward(); + } else { + history.back(); + } + } else { + this._current = target; + this._updateHistoryFromCurrent(true); + } + + return true; + } + + _triggerStateChanged(synthetic) { + this.trigger('stateChanged', {history: this, synthetic}); + } + + _updateHistoryFromCurrent(replace) { + const {id, state, url} = this._current; + if (replace) { + history.replaceState({id, state}, '', url); + } else { + history.pushState({id, state}, '', url); + } + this._triggerStateChanged(true); + } + + _updateStateFromHistory() { + let state = history.state; + let id = null; + if (isObject(state)) { + id = state.id; + if (typeof id === 'string') { + const entry = this._historyMap.get(id); + if (typeof entry !== 'undefined') { + // Valid + this._current = entry; + return; + } + } + // Partial state recovery + state = state.state; + } else { + state = null; + } + + // Fallback + this._current.id = (typeof id === 'string' ? id : this._generateId()); + this._current.state = state; + this._current.content = null; + this._clear(); + } + + _createHistoryEntry(id, url, state, content, previous) { + if (typeof id !== 'string') { id = this._generateId(); } + const entry = { + id, + url, + next: null, + previous, + state, + content + }; + this._historyMap.set(id, entry); + return entry; + } + + _generateId() { + return generateId(16); + } + + _clear() { + this._historyMap.clear(); + this._historyMap.set(this._current.id, this._current); + this._current.next = null; + this._current.previous = null; + } +} diff --git a/ext/js/display/display-notification.js b/ext/js/display/display-notification.js new file mode 100644 index 00000000..8b6325d0 --- /dev/null +++ b/ext/js/display/display-notification.js @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2017-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 <https://www.gnu.org/licenses/>. + */ + +class DisplayNotification { + constructor(container, node) { + this._container = container; + this._node = node; + this._body = node.querySelector('.footer-notification-body'); + this._closeButton = node.querySelector('.footer-notification-close-button'); + this._eventListeners = new EventListenerCollection(); + this._closeTimer = null; + } + + open() { + if (!this.isClosed()) { return; } + + this._clearTimer(); + + const node = this._node; + this._container.appendChild(node); + const style = getComputedStyle(node); + node.hidden = true; + style.getPropertyValue('opacity'); // Force CSS update, allowing animation + node.hidden = false; + this._eventListeners.addEventListener(this._closeButton, 'click', this._onCloseButtonClick.bind(this), false); + } + + close(animate=false) { + if (this.isClosed()) { return; } + + if (animate) { + if (this._closeTimer !== null) { return; } + + this._node.hidden = true; + this._closeTimer = setTimeout(this._onDelayClose.bind(this), 200); + } else { + this._clearTimer(); + + this._eventListeners.removeAllEventListeners(); + const parent = this._node.parentNode; + if (parent !== null) { + parent.removeChild(this._node); + } + } + } + + setContent(value) { + if (typeof value === 'string') { + this._body.textContent = value; + } else { + this._body.textContent = ''; + this._body.appendChild(value); + } + } + + isClosing() { + return this._closeTimer !== null; + } + + isClosed() { + return this._node.parentNode === null; + } + + // Private + + _onCloseButtonClick() { + this.close(true); + } + + _onDelayClose() { + this._closeTimer = null; + this.close(false); + } + + _clearTimer() { + if (this._closeTimer !== null) { + clearTimeout(this._closeTimer); + this._closeTimer = null; + } + } +} diff --git a/ext/js/display/display-profile-selection.js b/ext/js/display/display-profile-selection.js new file mode 100644 index 00000000..0a44392e --- /dev/null +++ b/ext/js/display/display-profile-selection.js @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * PanelElement + * api + */ + +class DisplayProfileSelection { + constructor(display) { + this._display = display; + this._profielList = document.querySelector('#profile-list'); + this._profileButton = document.querySelector('#profile-button'); + this._profilePanel = new PanelElement({ + node: document.querySelector('#profile-panel'), + closingAnimationDuration: 375 // Milliseconds; includes buffer + }); + this._profileListNeedsUpdate = false; + this._eventListeners = new EventListenerCollection(); + this._source = generateId(16); + } + + async prepare() { + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + this._profileButton.addEventListener('click', this._onProfileButtonClick.bind(this), false); + this._profileListNeedsUpdate = true; + } + + // Private + + _onOptionsUpdated({source}) { + if (source === this._source) { return; } + this._profileListNeedsUpdate = true; + if (this._profilePanel.isVisible()) { + this._updateProfileList(); + } + } + + _onProfileButtonClick(e) { + e.preventDefault(); + e.stopPropagation(); + this._setProfilePanelVisible(!this._profilePanel.isVisible()); + } + + _setProfilePanelVisible(visible) { + this._profilePanel.setVisible(visible); + this._profileButton.classList.toggle('sidebar-button-highlight', visible); + document.documentElement.dataset.profilePanelVisible = `${visible}`; + if (visible && this._profileListNeedsUpdate) { + this._updateProfileList(); + } + } + + async _updateProfileList() { + this._profileListNeedsUpdate = false; + const options = await api.optionsGetFull(); + + this._eventListeners.removeAllEventListeners(); + const displayGenerator = this._display.displayGenerator; + + const {profileCurrent, profiles} = options; + const fragment = document.createDocumentFragment(); + for (let i = 0, ii = profiles.length; i < ii; ++i) { + const {name} = profiles[i]; + const entry = displayGenerator.createProfileListItem(); + const radio = entry.querySelector('.profile-entry-is-default-radio'); + radio.checked = (i === profileCurrent); + const nameNode = entry.querySelector('.profile-list-item-name'); + nameNode.textContent = name; + fragment.appendChild(entry); + this._eventListeners.addEventListener(radio, 'change', this._onProfileRadioChange.bind(this, i), false); + } + this._profielList.textContent = ''; + this._profielList.appendChild(fragment); + } + + _onProfileRadioChange(index, e) { + if (e.currentTarget.checked) { + this._setProfileCurrent(index); + } + } + + async _setProfileCurrent(index) { + await api.modifySettings([{ + action: 'set', + path: 'profileCurrent', + value: index, + scope: 'global' + }], this._source); + this._setProfilePanelVisible(false); + } +} diff --git a/ext/js/display/display.js b/ext/js/display/display.js new file mode 100644 index 00000000..ffadd055 --- /dev/null +++ b/ext/js/display/display.js @@ -0,0 +1,1886 @@ +/* + * Copyright (C) 2017-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiNoteBuilder + * DisplayAudio + * DisplayGenerator + * DisplayHistory + * DisplayNotification + * DocumentUtil + * FrameEndpoint + * Frontend + * HotkeyHelpController + * MediaLoader + * PopupFactory + * PopupMenu + * QueryParser + * TextScanner + * WindowScroll + * api + * dynamicLoader + */ + +class Display extends EventDispatcher { + constructor(tabId, frameId, pageType, japaneseUtil, documentFocusController, hotkeyHandler) { + super(); + this._tabId = tabId; + this._frameId = frameId; + this._pageType = pageType; + this._japaneseUtil = japaneseUtil; + this._documentFocusController = documentFocusController; + this._hotkeyHandler = hotkeyHandler; + this._container = document.querySelector('#definitions'); + this._definitions = []; + this._definitionNodes = []; + this._optionsContext = {depth: 0, url: window.location.href}; + this._options = null; + this._index = 0; + this._styleNode = null; + this._eventListeners = new EventListenerCollection(); + this._setContentToken = null; + this._mediaLoader = new MediaLoader(); + this._hotkeyHelpController = new HotkeyHelpController(); + this._displayGenerator = new DisplayGenerator({ + japaneseUtil, + mediaLoader: this._mediaLoader, + hotkeyHelpController: this._hotkeyHelpController + }); + this._messageHandlers = new Map(); + this._directMessageHandlers = new Map(); + this._windowMessageHandlers = new Map(); + this._history = new DisplayHistory({clearable: true, useBrowserHistory: false}); + this._historyChangeIgnore = false; + this._historyHasChanged = false; + this._navigationHeader = document.querySelector('#navigation-header'); + this._contentType = 'clear'; + this._defaultTitle = document.title; + this._titleMaxLength = 1000; + this._query = ''; + this._rawQuery = ''; + this._fullQuery = ''; + this._documentUtil = new DocumentUtil(); + this._progressIndicator = document.querySelector('#progress-indicator'); + this._progressIndicatorTimer = null; + this._progressIndicatorVisible = new DynamicProperty(false); + this._queryParserVisible = false; + this._queryParserVisibleOverride = null; + this._queryParserContainer = document.querySelector('#query-parser-container'); + this._queryParser = new QueryParser({ + getSearchContext: this._getSearchContext.bind(this), + documentUtil: this._documentUtil + }); + this._ankiFieldTemplates = null; + this._ankiFieldTemplatesDefault = null; + this._ankiNoteBuilder = new AnkiNoteBuilder(true); + this._updateAdderButtonsPromise = Promise.resolve(); + this._contentScrollElement = document.querySelector('#content-scroll'); + this._contentScrollBodyElement = document.querySelector('#content-body'); + this._windowScroll = new WindowScroll(this._contentScrollElement); + this._closeButton = document.querySelector('#close-button'); + this._navigationPreviousButton = document.querySelector('#navigate-previous-button'); + this._navigationNextButton = document.querySelector('#navigate-next-button'); + this._frontend = null; + this._frontendSetupPromise = null; + this._depth = 0; + this._parentPopupId = null; + this._parentFrameId = null; + this._contentOriginTabId = tabId; + this._contentOriginFrameId = frameId; + this._childrenSupported = true; + this._frameEndpoint = (pageType === 'popup' ? new FrameEndpoint() : null); + this._browser = null; + this._copyTextarea = null; + this._definitionTextScanner = null; + this._frameResizeToken = null; + this._frameResizeHandle = document.querySelector('#frame-resizer-handle'); + this._frameResizeStartSize = null; + this._frameResizeStartOffset = null; + this._frameResizeEventListeners = new EventListenerCollection(); + this._tagNotification = null; + this._footerNotificationContainer = document.querySelector('#content-footer'); + this._displayAudio = new DisplayAudio(this); + this._ankiNoteNotification = null; + this._ankiNoteNotificationEventListeners = null; + this._queryPostProcessor = null; + + this._hotkeyHandler.registerActions([ + ['close', () => { this._onHotkeyClose(); }], + ['nextEntry', () => { this._focusEntry(this._index + 1, true); }], + ['nextEntry3', () => { this._focusEntry(this._index + 3, true); }], + ['previousEntry', () => { this._focusEntry(this._index - 1, true); }], + ['previousEntry3', () => { this._focusEntry(this._index - 3, true); }], + ['lastEntry', () => { this._focusEntry(this._definitions.length - 1, true); }], + ['firstEntry', () => { this._focusEntry(0, true); }], + ['historyBackward', () => { this._sourceTermView(); }], + ['historyForward', () => { this._nextTermView(); }], + ['addNoteKanji', () => { this._tryAddAnkiNoteForSelectedDefinition('kanji'); }], + ['addNoteTermKanji', () => { this._tryAddAnkiNoteForSelectedDefinition('term-kanji'); }], + ['addNoteTermKana', () => { this._tryAddAnkiNoteForSelectedDefinition('term-kana'); }], + ['viewNote', () => { this._tryViewAnkiNoteForSelectedDefinition(); }], + ['playAudio', () => { this._playAudioCurrent(); }], + ['copyHostSelection', () => this._copyHostSelection()], + ['nextEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(1, true); }], + ['previousEntryDifferentDictionary', () => { this._focusEntryWithDifferentDictionary(-1, true); }] + ]); + this.registerDirectMessageHandlers([ + ['setOptionsContext', {async: false, handler: this._onMessageSetOptionsContext.bind(this)}], + ['setContent', {async: false, handler: this._onMessageSetContent.bind(this)}], + ['clearAutoPlayTimer', {async: false, handler: this._onMessageClearAutoPlayTimer.bind(this)}], + ['setCustomCss', {async: false, handler: this._onMessageSetCustomCss.bind(this)}], + ['setContentScale', {async: false, handler: this._onMessageSetContentScale.bind(this)}], + ['configure', {async: true, handler: this._onMessageConfigure.bind(this)}] + ]); + this.registerWindowMessageHandlers([ + ['extensionUnloaded', {async: false, handler: this._onMessageExtensionUnloaded.bind(this)}] + ]); + } + + get displayGenerator() { + return this._displayGenerator; + } + + get autoPlayAudioDelay() { + return this._displayAudio.autoPlayAudioDelay; + } + + set autoPlayAudioDelay(value) { + this._displayAudio.autoPlayAudioDelay = value; + } + + get queryParserVisible() { + return this._queryParserVisible; + } + + set queryParserVisible(value) { + this._queryParserVisible = value; + this._updateQueryParser(); + } + + get japaneseUtil() { + return this._japaneseUtil; + } + + get depth() { + return this._depth; + } + + get hotkeyHandler() { + return this._hotkeyHandler; + } + + get definitions() { + return this._definitions; + } + + get definitionNodes() { + return this._definitionNodes; + } + + get progressIndicatorVisible() { + return this._progressIndicatorVisible; + } + + get tabId() { + return this._tabId; + } + + get frameId() { + return this._frameId; + } + + async prepare() { + // State setup + const {documentElement} = document; + const {browser} = await api.getEnvironmentInfo(); + this._browser = browser; + + // Prepare + await this._hotkeyHelpController.prepare(); + await this._displayGenerator.prepare(); + this._displayAudio.prepare(); + this._queryParser.prepare(); + this._history.prepare(); + + // Event setup + this._history.on('stateChanged', this._onStateChanged.bind(this)); + this._queryParser.on('searched', this._onQueryParserSearch.bind(this)); + this._progressIndicatorVisible.on('change', this._onProgressIndicatorVisibleChanged.bind(this)); + yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this)); + api.crossFrame.registerHandlers([ + ['popupMessage', {async: 'dynamic', handler: this._onDirectMessage.bind(this)}] + ]); + window.addEventListener('message', this._onWindowMessage.bind(this), false); + + if (this._pageType === 'popup' && documentElement !== null) { + documentElement.addEventListener('mouseup', this._onDocumentElementMouseUp.bind(this), false); + documentElement.addEventListener('click', this._onDocumentElementClick.bind(this), false); + documentElement.addEventListener('auxclick', this._onDocumentElementClick.bind(this), false); + } + + document.addEventListener('wheel', this._onWheel.bind(this), {passive: false}); + if (this._closeButton !== null) { + this._closeButton.addEventListener('click', this._onCloseButtonClick.bind(this), false); + } + if (this._navigationPreviousButton !== null) { + this._navigationPreviousButton.addEventListener('click', this._onSourceTermView.bind(this), false); + } + if (this._navigationNextButton !== null) { + this._navigationNextButton.addEventListener('click', this._onNextTermView.bind(this), false); + } + + if (this._frameResizeHandle !== null) { + this._frameResizeHandle.addEventListener('mousedown', this._onFrameResizerMouseDown.bind(this), false); + } + } + + getContentOrigin() { + return { + tabId: this._contentOriginTabId, + frameId: this._contentOriginFrameId + }; + } + + initializeState() { + this._onStateChanged(); + if (this._frameEndpoint !== null) { + this._frameEndpoint.signal(); + } + } + + setHistorySettings({clearable, useBrowserHistory}) { + if (typeof clearable !== 'undefined') { + this._history.clearable = clearable; + } + if (typeof useBrowserHistory !== 'undefined') { + this._history.useBrowserHistory = useBrowserHistory; + } + } + + onError(error) { + if (yomichan.isExtensionUnloaded) { return; } + yomichan.logError(error); + } + + getOptions() { + return this._options; + } + + getOptionsContext() { + return this._optionsContext; + } + + async setOptionsContext(optionsContext) { + this._optionsContext = optionsContext; + await this.updateOptions(); + } + + async updateOptions() { + const options = await api.optionsGet(this.getOptionsContext()); + const templates = await this._getAnkiFieldTemplates(options); + const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; + this._options = options; + this._ankiFieldTemplates = templates; + + this._updateHotkeys(options); + this._updateDocumentOptions(options); + this._updateTheme(options.general.popupTheme); + this.setCustomCss(options.general.customPopupCss); + this._displayAudio.updateOptions(options); + this._hotkeyHelpController.setOptions(options); + this._displayGenerator.updateHotkeys(); + this._hotkeyHelpController.setupNode(document.documentElement); + + this._queryParser.setOptions({ + selectedParser: options.parsing.selectedParser, + termSpacing: options.parsing.termSpacing, + scanning: { + inputs: scanningOptions.inputs, + deepContentScan: scanningOptions.deepDomScan, + selectText: scanningOptions.selectText, + delay: scanningOptions.delay, + touchInputEnabled: scanningOptions.touchInputEnabled, + pointerEventsEnabled: scanningOptions.pointerEventsEnabled, + scanLength: scanningOptions.length, + layoutAwareScan: scanningOptions.layoutAwareScan, + preventMiddleMouse: scanningOptions.preventMiddleMouse.onSearchQuery, + sentenceParsingOptions + } + }); + + this._updateNestedFrontend(options); + this._updateDefinitionTextScanner(options); + + this.trigger('optionsUpdated', {options}); + } + + clearAutoPlayTimer() { + this._displayAudio.clearAutoPlayTimer(); + } + + setContent(details) { + const {focus, history, params, state, content} = details; + + if (focus) { + window.focus(); + } + + const urlSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + urlSearchParams.append(key, value); + } + const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`; + + if (history && this._historyHasChanged) { + this._updateHistoryState(); + this._history.pushState(state, content, url); + } else { + this._history.clear(); + this._history.replaceState(state, content, url); + } + } + + setCustomCss(css) { + if (this._styleNode === null) { + if (css.length === 0) { return; } + this._styleNode = document.createElement('style'); + } + + this._styleNode.textContent = css; + + const parent = document.head; + if (this._styleNode.parentNode !== parent) { + parent.appendChild(this._styleNode); + } + } + + registerDirectMessageHandlers(handlers) { + for (const [name, handlerInfo] of handlers) { + this._directMessageHandlers.set(name, handlerInfo); + } + } + + registerWindowMessageHandlers(handlers) { + for (const [name, handlerInfo] of handlers) { + this._windowMessageHandlers.set(name, handlerInfo); + } + } + + authenticateMessageData(data) { + if (this._frameEndpoint === null) { + return data; + } + if (!this._frameEndpoint.authenticate(data)) { + throw new Error('Invalid authentication'); + } + return data.data; + } + + setQueryPostProcessor(func) { + this._queryPostProcessor = func; + } + + close() { + switch (this._pageType) { + case 'popup': + this._invokeContentOrigin('closePopup'); + break; + case 'search': + this._closeTab(); + break; + } + } + + blurElement(element) { + this._documentFocusController.blurElement(element); + } + + searchLast() { + const type = this._contentType; + if (type === 'clear') { return; } + const query = this._rawQuery; + const state = ( + this._historyHasState() ? + clone(this._history.state) : + { + focusEntry: 0, + optionsContext: this._optionsContext, + url: window.location.href, + sentence: {text: query, offset: 0}, + documentTitle: document.title + } + ); + const details = { + focus: false, + history: false, + params: this._createSearchParams(type, query, false), + state, + content: { + definitions: null, + contentOrigin: this.getContentOrigin() + } + }; + this.setContent(details); + } + + // Message handlers + + _onDirectMessage(data) { + data = this.authenticateMessageData(data); + const {action, params} = data; + const handlerInfo = this._directMessageHandlers.get(action); + if (typeof handlerInfo === 'undefined') { + throw new Error(`Invalid action: ${action}`); + } + + const {async, handler} = handlerInfo; + const result = handler(params); + return {async, result}; + } + + _onWindowMessage({data}) { + try { + data = this.authenticateMessageData(data); + } catch (e) { + return; + } + + const {action, params} = data; + const messageHandler = this._windowMessageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return; } + + const callback = () => {}; // NOP + yomichan.invokeMessageHandler(messageHandler, params, callback); + } + + _onMessageSetOptionsContext({optionsContext}) { + this.setOptionsContext(optionsContext); + this.searchLast(); + } + + _onMessageSetContent({details}) { + this.setContent(details); + } + + _onMessageClearAutoPlayTimer() { + this.clearAutoPlayTimer(); + } + + _onMessageSetCustomCss({css}) { + this.setCustomCss(css); + } + + _onMessageSetContentScale({scale}) { + this._setContentScale(scale); + } + + async _onMessageConfigure({depth, parentPopupId, parentFrameId, childrenSupported, scale, optionsContext}) { + this._depth = depth; + this._parentPopupId = parentPopupId; + this._parentFrameId = parentFrameId; + this._childrenSupported = childrenSupported; + this._setContentScale(scale); + await this.setOptionsContext(optionsContext); + } + + _onMessageExtensionUnloaded() { + if (yomichan.isExtensionUnloaded) { return; } + yomichan.triggerExtensionUnloaded(); + } + + // Private + + async _onStateChanged() { + if (this._historyChangeIgnore) { return; } + + const token = {}; // Unique identifier token + this._setContentToken = token; + try { + // Clear + this._closePopups(); + this._closeAllPopupMenus(); + this._eventListeners.removeAllEventListeners(); + this._mediaLoader.unloadAll(); + this._displayAudio.cleanupEntries(); + this._hideTagNotification(false); + this._hideAnkiNoteErrors(false); + this._definitions = []; + this._definitionNodes = []; + + // Prepare + const urlSearchParams = new URLSearchParams(location.search); + let type = urlSearchParams.get('type'); + if (type === null) { type = 'terms'; } + + const fullVisible = urlSearchParams.get('full-visible'); + this._queryParserVisibleOverride = (fullVisible === null ? null : (fullVisible !== 'false')); + this._updateQueryParser(); + + let clear = true; + this._historyHasChanged = true; + this._contentType = type; + this._query = ''; + this._rawQuery = ''; + const eventArgs = {type, urlSearchParams, token}; + + // Set content + switch (type) { + case 'terms': + case 'kanji': + { + let query = urlSearchParams.get('query'); + if (!query) { break; } + + this._query = query; + clear = false; + const isTerms = (type === 'terms'); + query = this._postProcessQuery(query); + this._rawQuery = query; + let queryFull = urlSearchParams.get('full'); + queryFull = (queryFull !== null ? this._postProcessQuery(queryFull) : query); + const wildcardsEnabled = (urlSearchParams.get('wildcards') !== 'off'); + const lookup = (urlSearchParams.get('lookup') !== 'false'); + await this._setContentTermsOrKanji(token, isTerms, query, queryFull, lookup, wildcardsEnabled, eventArgs); + } + break; + case 'unloaded': + { + clear = false; + const {content} = this._history; + eventArgs.content = content; + this.trigger('contentUpdating', eventArgs); + this._setContentExtensionUnloaded(); + } + break; + } + + // Clear + if (clear) { + type = 'clear'; + this._contentType = type; + const {content} = this._history; + eventArgs.type = type; + eventArgs.content = content; + this.trigger('contentUpdating', eventArgs); + this._clearContent(); + } + + const stale = (this._setContentToken !== token); + eventArgs.stale = stale; + this.trigger('contentUpdated', eventArgs); + } catch (e) { + this.onError(e); + } + } + + _onQueryParserSearch({type, definitions, sentence, inputInfo: {eventType}, textSource, optionsContext}) { + const query = textSource.text(); + const historyState = this._history.state; + const history = ( + eventType === 'click' || + !isObject(historyState) || + historyState.cause !== 'queryParser' + ); + const details = { + focus: false, + history, + params: this._createSearchParams(type, query, false), + state: { + sentence, + optionsContext, + cause: 'queryParser' + }, + content: { + definitions, + contentOrigin: this.getContentOrigin() + } + }; + this.setContent(details); + } + + _onExtensionUnloaded() { + const type = 'unloaded'; + if (this._contentType === type) { return; } + const details = { + focus: false, + history: false, + params: {type}, + state: {}, + content: { + contentOrigin: { + tabId: this._tabId, + frameId: this._frameId + } + } + }; + this.setContent(details); + } + + _onCloseButtonClick(e) { + e.preventDefault(); + this.close(); + } + + _onSourceTermView(e) { + e.preventDefault(); + this._sourceTermView(); + } + + _onNextTermView(e) { + e.preventDefault(); + this._nextTermView(); + } + + _onProgressIndicatorVisibleChanged({value}) { + if (this._progressIndicatorTimer !== null) { + clearTimeout(this._progressIndicatorTimer); + this._progressIndicatorTimer = null; + } + + if (value) { + this._progressIndicator.hidden = false; + getComputedStyle(this._progressIndicator).getPropertyValue('display'); // Force update of CSS display property, allowing animation + this._progressIndicator.dataset.active = 'true'; + } else { + this._progressIndicator.dataset.active = 'false'; + this._progressIndicatorTimer = setTimeout(() => { + this._progressIndicator.hidden = true; + this._progressIndicatorTimer = null; + }, 250); + } + } + + async _onKanjiLookup(e) { + try { + e.preventDefault(); + if (!this._historyHasState()) { return; } + + let {state: {sentence, url, documentTitle}} = this._history; + if (typeof url !== 'string') { url = window.location.href; } + if (typeof documentTitle !== 'string') { documentTitle = document.title; } + const optionsContext = this.getOptionsContext(); + const query = e.currentTarget.textContent; + const definitions = await api.kanjiFind(query, optionsContext); + const details = { + focus: false, + history: true, + params: this._createSearchParams('kanji', query, false), + state: { + focusEntry: 0, + optionsContext, + url, + sentence, + documentTitle + }, + content: { + definitions, + contentOrigin: this.getContentOrigin() + } + }; + this.setContent(details); + } catch (error) { + this.onError(error); + } + } + + _onNoteAdd(e) { + e.preventDefault(); + const link = e.currentTarget; + const index = this._getClosestDefinitionIndex(link); + this._addAnkiNote(index, link.dataset.mode); + } + + _onNoteView(e) { + e.preventDefault(); + const link = e.currentTarget; + api.noteView(link.dataset.noteId); + } + + _onWheel(e) { + if (e.altKey) { + if (e.deltaY !== 0) { + this._focusEntry(this._index + (e.deltaY > 0 ? 1 : -1), true); + e.preventDefault(); + } + } else if (e.shiftKey) { + this._onHistoryWheel(e); + } + } + + _onHistoryWheel(e) { + if (e.altKey) { return; } + const delta = -e.deltaX || e.deltaY; + if (delta > 0) { + this._sourceTermView(); + e.preventDefault(); + e.stopPropagation(); + } else if (delta < 0) { + this._nextTermView(); + e.preventDefault(); + e.stopPropagation(); + } + } + + _onDebugLogClick(e) { + const link = e.currentTarget; + const index = this._getClosestDefinitionIndex(link); + if (index < 0 || index >= this._definitions.length) { return; } + const definition = this._definitions[index]; + console.log(definition); + } + + _onDocumentElementMouseUp(e) { + switch (e.button) { + case 3: // Back + if (this._history.hasPrevious()) { + e.preventDefault(); + } + break; + case 4: // Forward + if (this._history.hasNext()) { + e.preventDefault(); + } + break; + } + } + + _onDocumentElementClick(e) { + switch (e.button) { + case 3: // Back + if (this._history.hasPrevious()) { + e.preventDefault(); + this._history.back(); + } + break; + case 4: // Forward + if (this._history.hasNext()) { + e.preventDefault(); + this._history.forward(); + } + break; + } + } + + _onEntryClick(e) { + if (e.button !== 0) { return; } + const node = e.currentTarget; + const index = parseInt(node.dataset.index, 10); + if (!Number.isFinite(index)) { return; } + this._entrySetCurrent(index); + } + + _onTagClick(e) { + this._showTagNotification(e.currentTarget); + } + + _showTagNotification(tagNode) { + if (this._tagNotification === null) { + const node = this._displayGenerator.createEmptyFooterNotification(); + node.classList.add('click-scannable'); + this._tagNotification = new DisplayNotification(this._footerNotificationContainer, node); + } + + const content = this._displayGenerator.createTagFooterNotificationDetails(tagNode); + this._tagNotification.setContent(content); + this._tagNotification.open(); + } + + _hideTagNotification(animate) { + if (this._tagNotification === null) { return; } + this._tagNotification.close(animate); + } + + _updateDocumentOptions(options) { + const data = document.documentElement.dataset; + data.ankiEnabled = `${options.anki.enable}`; + data.glossaryLayoutMode = `${options.general.glossaryLayoutMode}`; + data.compactTags = `${options.general.compactTags}`; + data.enableSearchTags = `${options.scanning.enableSearchTags}`; + data.showPitchAccentDownstepNotation = `${options.general.showPitchAccentDownstepNotation}`; + data.showPitchAccentPositionNotation = `${options.general.showPitchAccentPositionNotation}`; + data.showPitchAccentGraph = `${options.general.showPitchAccentGraph}`; + data.debug = `${options.general.debugInfo}`; + data.popupDisplayMode = `${options.general.popupDisplayMode}`; + data.popupCurrentIndicatorMode = `${options.general.popupCurrentIndicatorMode}`; + data.popupActionBarVisibility = `${options.general.popupActionBarVisibility}`; + data.popupActionBarLocation = `${options.general.popupActionBarLocation}`; + } + + _updateTheme(themeName) { + document.documentElement.dataset.theme = themeName; + } + + async _findDefinitions(isTerms, source, wildcardsEnabled, optionsContext) { + if (isTerms) { + const findDetails = {}; + if (wildcardsEnabled) { + const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source); + if (match !== null) { + if (match[1]) { + findDetails.wildcard = 'prefix'; + } else if (match[3]) { + findDetails.wildcard = 'suffix'; + } + source = match[2]; + } + } + + const {definitions} = await api.termsFind(source, findDetails, optionsContext); + return definitions; + } else { + const definitions = await api.kanjiFind(source, optionsContext); + return definitions; + } + } + + async _setContentTermsOrKanji(token, isTerms, query, queryFull, lookup, wildcardsEnabled, eventArgs) { + let {state, content} = this._history; + let changeHistory = false; + if (!isObject(content)) { + content = {}; + changeHistory = true; + } + if (!isObject(state)) { + state = {}; + changeHistory = true; + } + + let { + focusEntry=null, + scrollX=null, + scrollY=null, + optionsContext=null + } = state; + if (typeof focusEntry !== 'number') { focusEntry = 0; } + if (!(typeof optionsContext === 'object' && optionsContext !== null)) { + optionsContext = this.getOptionsContext(); + state.optionsContext = optionsContext; + changeHistory = true; + } + + this._setFullQuery(queryFull); + this._setTitleText(query); + + let {definitions} = content; + if (!Array.isArray(definitions)) { + definitions = lookup ? await this._findDefinitions(isTerms, query, wildcardsEnabled, optionsContext) : []; + if (this._setContentToken !== token) { return; } + content.definitions = definitions; + changeHistory = true; + } + + let contentOriginValid = false; + const {contentOrigin} = content; + if (typeof contentOrigin === 'object' && contentOrigin !== null) { + const {tabId, frameId} = contentOrigin; + if (typeof tabId === 'number' && typeof frameId === 'number') { + this._contentOriginTabId = tabId; + this._contentOriginFrameId = frameId; + if (this._pageType === 'popup') { + this._hotkeyHandler.forwardFrameId = (tabId === this._tabId ? frameId : null); + } + contentOriginValid = true; + } + } + if (!contentOriginValid) { + content.contentOrigin = this.getContentOrigin(); + changeHistory = true; + } + + await this._setOptionsContextIfDifferent(optionsContext); + if (this._setContentToken !== token) { return; } + + if (this._options === null) { + await this.updateOptions(); + if (this._setContentToken !== token) { return; } + } + + if (changeHistory) { + this._replaceHistoryStateNoNavigate(state, content); + } + + eventArgs.source = query; + eventArgs.content = content; + this.trigger('contentUpdating', eventArgs); + + this._definitions = definitions; + + this._updateNavigation(this._history.hasPrevious(), this._history.hasNext()); + this._setNoContentVisible(definitions.length === 0 && lookup); + + const container = this._container; + container.textContent = ''; + + for (let i = 0, ii = definitions.length; i < ii; ++i) { + if (i > 0) { + await promiseTimeout(1); + if (this._setContentToken !== token) { return; } + } + + const definition = definitions[i]; + const entry = ( + isTerms ? + this._displayGenerator.createTermEntry(definition) : + this._displayGenerator.createKanjiEntry(definition) + ); + entry.dataset.index = `${i}`; + this._definitionNodes.push(entry); + this._addEntryEventListeners(entry); + this._displayAudio.setupEntry(entry, i); + container.appendChild(entry); + if (focusEntry === i) { + this._focusEntry(i, false); + } + } + + if (typeof scrollX === 'number' || typeof scrollY === 'number') { + let {x, y} = this._windowScroll; + if (typeof scrollX === 'number') { x = scrollX; } + if (typeof scrollY === 'number') { y = scrollY; } + this._windowScroll.stop(); + this._windowScroll.to(x, y); + } + + this._displayAudio.setupEntriesComplete(); + + this._updateAdderButtons(token, isTerms, definitions); + } + + _setContentExtensionUnloaded() { + const errorExtensionUnloaded = document.querySelector('#error-extension-unloaded'); + + if (this._container !== null) { + this._container.hidden = true; + } + + if (errorExtensionUnloaded !== null) { + errorExtensionUnloaded.hidden = false; + } + + this._updateNavigation(false, false); + this._setNoContentVisible(false); + this._setTitleText(''); + this._setFullQuery(''); + } + + _clearContent() { + this._container.textContent = ''; + this._setTitleText(''); + this._setFullQuery(''); + } + + _setNoContentVisible(visible) { + const noResults = document.querySelector('#no-results'); + + if (noResults !== null) { + noResults.hidden = !visible; + } + } + + _setFullQuery(text) { + this._fullQuery = text; + this._updateQueryParser(); + } + + _updateQueryParser() { + const text = this._fullQuery; + const visible = this._isQueryParserVisible(); + this._queryParserContainer.hidden = !visible || text.length === 0; + if (visible && this._queryParser.text !== text) { + this._setQueryParserText(text); + } + } + + async _setQueryParserText(text) { + const overrideToken = this._progressIndicatorVisible.setOverride(true); + try { + await this._queryParser.setText(text); + } finally { + this._progressIndicatorVisible.clearOverride(overrideToken); + } + } + + _setTitleText(text) { + let title = this._defaultTitle; + if (text.length > 0) { + // Chrome limits title to 1024 characters + const ellipsis = '...'; + const separator = ' - '; + const maxLength = this._titleMaxLength - title.length - separator.length; + if (text.length > maxLength) { + text = `${text.substring(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`; + } + + title = `${text}${separator}${title}`; + } + document.title = title; + } + + _updateNavigation(previous, next) { + const {documentElement} = document; + if (documentElement !== null) { + documentElement.dataset.hasNavigationPrevious = `${previous}`; + documentElement.dataset.hasNavigationNext = `${next}`; + } + if (this._navigationPreviousButton !== null) { + this._navigationPreviousButton.disabled = !previous; + } + if (this._navigationNextButton !== null) { + this._navigationNextButton.disabled = !next; + } + } + + async _updateAdderButtons(token, isTerms, definitions) { + await this._updateAdderButtonsPromise; + if (this._setContentToken !== token) { return; } + + const {promise, resolve} = deferPromise(); + try { + this._updateAdderButtonsPromise = promise; + + const modes = isTerms ? ['term-kanji', 'term-kana'] : ['kanji']; + let states; + try { + if (this._options.anki.checkForDuplicates) { + const noteContext = this._getNoteContext(); + states = await this._areDefinitionsAddable(definitions, modes, noteContext); + } else { + if (!await api.isAnkiConnected()) { + throw new Error('Anki not connected'); + } + states = this._areDefinitionsAddableForcedValue(definitions, modes, true); + } + } catch (e) { + return; + } + + if (this._setContentToken !== token) { return; } + + this._updateAdderButtons2(states, modes); + } finally { + resolve(); + } + } + + _updateAdderButtons2(states, modes) { + for (let i = 0, ii = states.length; i < ii; ++i) { + const infos = states[i]; + let noteId = null; + for (let j = 0, jj = infos.length; j < jj; ++j) { + const {canAdd, noteIds} = infos[j]; + const mode = modes[j]; + const button = this._adderButtonFind(i, mode); + if (button === null) { + continue; + } + + if (Array.isArray(noteIds) && noteIds.length > 0) { + noteId = noteIds[0]; + } + button.disabled = !canAdd; + button.hidden = false; + } + if (noteId !== null) { + this._viewerButtonShow(i, noteId); + } + } + } + + _entrySetCurrent(index) { + const entryPre = this._getEntry(this._index); + if (entryPre !== null) { + entryPre.classList.remove('entry-current'); + } + + const entry = this._getEntry(index); + if (entry !== null) { + entry.classList.add('entry-current'); + } + + this._index = index; + + return entry; + } + + _focusEntry(index, smooth) { + index = Math.max(Math.min(index, this._definitions.length - 1), 0); + + const entry = this._entrySetCurrent(index); + let target = index === 0 || entry === null ? 0 : this._getElementTop(entry); + + if (this._navigationHeader !== null) { + target -= this._navigationHeader.getBoundingClientRect().height; + } + + this._windowScroll.stop(); + if (smooth) { + this._windowScroll.animate(this._windowScroll.x, target, 200); + } else { + this._windowScroll.toY(target); + } + } + + _focusEntryWithDifferentDictionary(offset, smooth) { + const offsetSign = Math.sign(offset); + if (offsetSign === 0) { return false; } + + let index = this._index; + const definitionCount = this._definitions.length; + if (index < 0 || index >= definitionCount) { return false; } + + const {dictionary} = this._definitions[index]; + for (let indexNext = index + offsetSign; indexNext >= 0 && indexNext < definitionCount; indexNext += offsetSign) { + const {dictionaryNames} = this._definitions[indexNext]; + if (dictionaryNames.length > 1 || !dictionaryNames.includes(dictionary)) { + offset -= offsetSign; + if (Math.sign(offsetSign) !== offset) { + index = indexNext; + break; + } + } + } + + if (index === this._index) { return false; } + + this._focusEntry(index, smooth); + return true; + } + + _sourceTermView() { + this._relativeTermView(false); + } + + _nextTermView() { + this._relativeTermView(true); + } + + _relativeTermView(next) { + if (next) { + return this._history.hasNext() && this._history.forward(); + } else { + return this._history.hasPrevious() && this._history.back(); + } + } + + _tryAddAnkiNoteForSelectedDefinition(mode) { + this._addAnkiNote(this._index, mode); + } + + _tryViewAnkiNoteForSelectedDefinition() { + const button = this._viewerButtonFind(this._index); + if (button !== null && !button.disabled) { + api.noteView(button.dataset.noteId); + } + } + + async _addAnkiNote(definitionIndex, mode) { + if (definitionIndex < 0 || definitionIndex >= this._definitions.length) { return; } + const definition = this._definitions[definitionIndex]; + + const button = this._adderButtonFind(definitionIndex, mode); + if (button === null || button.disabled) { return; } + + this._hideAnkiNoteErrors(true); + + const errors = []; + const overrideToken = this._progressIndicatorVisible.setOverride(true); + try { + const {anki: {suspendNewCards}} = this._options; + const noteContext = this._getNoteContext(); + const note = await this._createNote(definition, mode, noteContext, true, errors); + + let noteId = null; + let addNoteOkay = false; + try { + noteId = await api.addAnkiNote(note); + addNoteOkay = true; + } catch (e) { + errors.length = 0; + errors.push(e); + } + + if (addNoteOkay) { + if (noteId === null) { + errors.push(new Error('Note could not be added')); + } else { + if (suspendNewCards) { + try { + await api.suspendAnkiCardsForNote(noteId); + } catch (e) { + errors.push(e); + } + } + button.disabled = true; + this._viewerButtonShow(definitionIndex, noteId); + } + } + } catch (e) { + errors.push(e); + } finally { + this._progressIndicatorVisible.clearOverride(overrideToken); + } + + if (errors.length > 0) { + this._showAnkiNoteErrors(errors); + } else { + this._hideAnkiNoteErrors(true); + } + } + + _showAnkiNoteErrors(errors) { + if (this._ankiNoteNotificationEventListeners !== null) { + this._ankiNoteNotificationEventListeners.removeAllEventListeners(); + } + + if (this._ankiNoteNotification === null) { + const node = this._displayGenerator.createEmptyFooterNotification(); + this._ankiNoteNotification = new DisplayNotification(this._footerNotificationContainer, node); + this._ankiNoteNotificationEventListeners = new EventListenerCollection(); + } + + const content = this._displayGenerator.createAnkiNoteErrorsNotificationContent(errors); + for (const node of content.querySelectorAll('.anki-note-error-log-link')) { + this._ankiNoteNotificationEventListeners.addEventListener(node, 'click', () => { + console.log({ankiNoteErrors: errors}); + }, false); + } + + this._ankiNoteNotification.setContent(content); + this._ankiNoteNotification.open(); + } + + _hideAnkiNoteErrors(animate) { + if (this._ankiNoteNotification === null) { return; } + this._ankiNoteNotification.close(animate); + this._ankiNoteNotificationEventListeners.removeAllEventListeners(); + } + + async _playAudioCurrent() { + return await this._displayAudio.playAudio(this._index, 0); + } + + _getEntry(index) { + const entries = this._definitionNodes; + return index >= 0 && index < entries.length ? entries[index] : null; + } + + _getValidSentenceData(sentence) { + let {text, offset} = (isObject(sentence) ? sentence : {}); + if (typeof text !== 'string') { text = ''; } + if (typeof offset !== 'number') { offset = 0; } + return {text, offset}; + } + + _getClosestDefinitionIndex(element) { + return this._getClosestIndex(element, '.entry'); + } + + _getClosestIndex(element, selector) { + const node = element.closest(selector); + if (node === null) { return -1; } + const index = parseInt(node.dataset.index, 10); + return Number.isFinite(index) ? index : -1; + } + + _adderButtonFind(index, mode) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector(`.action-add-note[data-mode="${mode}"]`) : null; + } + + _viewerButtonFind(index) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector('.action-view-note') : null; + } + + _viewerButtonShow(index, noteId) { + const viewerButton = this._viewerButtonFind(index); + if (viewerButton === null) { + return; + } + viewerButton.disabled = false; + viewerButton.hidden = false; + viewerButton.dataset.noteId = noteId; + } + + _getElementTop(element) { + const elementRect = element.getBoundingClientRect(); + const documentRect = this._contentScrollBodyElement.getBoundingClientRect(); + return elementRect.top - documentRect.top; + } + + _getNoteContext() { + const {state} = this._history; + let {documentTitle, url, sentence} = (isObject(state) ? state : {}); + if (typeof documentTitle !== 'string') { + documentTitle = ''; + } + if (typeof url !== 'string') { + url = window.location.href; + } + sentence = this._getValidSentenceData(sentence); + return { + url, + sentence, + documentTitle + }; + } + + _historyHasState() { + return isObject(this._history.state); + } + + _updateHistoryState() { + const {state, content} = this._history; + if (!isObject(state)) { return; } + + state.focusEntry = this._index; + state.scrollX = this._windowScroll.x; + state.scrollY = this._windowScroll.y; + this._replaceHistoryStateNoNavigate(state, content); + } + + _replaceHistoryStateNoNavigate(state, content) { + const historyChangeIgnorePre = this._historyChangeIgnore; + try { + this._historyChangeIgnore = true; + this._history.replaceState(state, content); + } finally { + this._historyChangeIgnore = historyChangeIgnorePre; + } + } + + _createSearchParams(type, query, wildcards) { + const params = {}; + if (query.length < this._fullQuery.length) { + params.full = this._fullQuery; + } + params.query = query; + if (typeof type === 'string') { + params.type = type; + } + if (!wildcards) { + params.wildcards = 'off'; + } + if (this._queryParserVisibleOverride !== null) { + params['full-visible'] = `${this._queryParserVisibleOverride}`; + } + return params; + } + + _isQueryParserVisible() { + return ( + this._queryParserVisibleOverride !== null ? + this._queryParserVisibleOverride : + this._queryParserVisible + ); + } + + _closePopups() { + yomichan.trigger('closePopups'); + } + + async _getAnkiFieldTemplates(options) { + let templates = options.anki.fieldTemplates; + if (typeof templates === 'string') { return templates; } + + templates = this._ankiFieldTemplatesDefault; + if (typeof templates === 'string') { return templates; } + + templates = await api.getDefaultAnkiFieldTemplates(); + this._ankiFieldTemplatesDefault = templates; + return templates; + } + + async _areDefinitionsAddable(definitions, modes, context) { + const modeCount = modes.length; + const notePromises = []; + for (const definition of definitions) { + for (const mode of modes) { + const notePromise = this._createNote(definition, mode, context, false, null); + notePromises.push(notePromise); + } + } + const notes = await Promise.all(notePromises); + + const infos = await api.getAnkiNoteInfo(notes); + const results = []; + for (let i = 0, ii = infos.length; i < ii; i += modeCount) { + results.push(infos.slice(i, i + modeCount)); + } + return results; + } + + _areDefinitionsAddableForcedValue(definitions, modes, canAdd) { + const results = []; + const definitionCount = definitions.length; + const modeCount = modes.length; + for (let i = 0; i < definitionCount; ++i) { + const modeArray = []; + for (let j = 0; j < modeCount; ++j) { + modeArray.push({canAdd, noteIds: null}); + } + results.push(modeArray); + } + return results; + } + + async _createNote(definition, mode, context, injectMedia, errors) { + const options = this._options; + const templates = this._ankiFieldTemplates; + const { + general: {resultOutputMode, glossaryLayoutMode, compactTags}, + anki: ankiOptions + } = options; + const {tags, checkForDuplicates, duplicateScope} = ankiOptions; + const modeOptions = (mode === 'kanji') ? ankiOptions.kanji : ankiOptions.terms; + const {deck: deckName, model: modelName} = modeOptions; + const fields = Object.entries(modeOptions.fields); + + let injectedMedia = null; + if (injectMedia) { + let errors2; + ({result: injectedMedia, errors: errors2} = await this._injectAnkiNoteMedia(definition, mode, options, fields)); + if (Array.isArray(errors)) { + for (const error of errors2) { + errors.push(deserializeError(error)); + } + } + } + + return await this._ankiNoteBuilder.createNote({ + definition, + mode, + context, + templates, + deckName, + modelName, + fields, + tags, + checkForDuplicates, + duplicateScope, + resultOutputMode, + glossaryLayoutMode, + compactTags, + injectedMedia, + errors + }); + } + + async _injectAnkiNoteMedia(definition, mode, options, fields) { + const { + anki: {screenshot: {format, quality}}, + audio: {sources, customSourceUrl, customSourceType} + } = options; + + const timestamp = Date.now(); + const definitionDetails = this._getDefinitionDetailsForNote(definition); + const audioDetails = (mode !== 'kanji' && this._ankiNoteBuilder.containsMarker(fields, 'audio') ? {sources, customSourceUrl, customSourceType} : null); + const screenshotDetails = (this._ankiNoteBuilder.containsMarker(fields, 'screenshot') ? {tabId: this._contentOriginTabId, frameId: this._contentOriginFrameId, format, quality} : null); + const clipboardDetails = { + image: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-image'), + text: this._ankiNoteBuilder.containsMarker(fields, 'clipboard-text') + }; + return await api.injectAnkiNoteMedia( + timestamp, + definitionDetails, + audioDetails, + screenshotDetails, + clipboardDetails + ); + } + + _getDefinitionDetailsForNote(definition) { + const {type} = definition; + if (type === 'kanji') { + const {character} = definition; + return {type, character}; + } + + const termDetailsList = definition.expressions; + let bestIndex = -1; + for (let i = 0, ii = termDetailsList.length; i < ii; ++i) { + const {sourceTerm, expression, reading} = termDetailsList[i]; + if (expression === sourceTerm) { + bestIndex = i; + break; + } else if (reading === sourceTerm && bestIndex < 0) { + bestIndex = i; + } + } + const {expression, reading} = termDetailsList[Math.max(0, bestIndex)]; + return {type, expression, reading}; + } + + async _setOptionsContextIfDifferent(optionsContext) { + if (deepEqual(this._optionsContext, optionsContext)) { return; } + await this.setOptionsContext(optionsContext); + } + + _setContentScale(scale) { + const body = document.body; + if (body === null) { return; } + body.style.fontSize = `${scale}em`; + } + + async _updateNestedFrontend(options) { + const isSearchPage = (this._pageType === 'search'); + const isEnabled = this._childrenSupported && ( + (isSearchPage) ? + (options.scanning.enableOnSearchPage) : + (this._depth < options.scanning.popupNestingMaxDepth) + ); + + if (this._frontend === null) { + if (!isEnabled) { return; } + + try { + if (this._frontendSetupPromise === null) { + this._frontendSetupPromise = this._setupNestedFrontend(); + } + await this._frontendSetupPromise; + } catch (e) { + yomichan.logError(e); + return; + } finally { + this._frontendSetupPromise = null; + } + } + + this._frontend.setDisabledOverride(!isEnabled); + } + + async _setupNestedFrontend() { + const setupNestedPopupsOptions = { + useProxyPopup: this._parentFrameId !== null, + parentPopupId: this._parentPopupId, + parentFrameId: this._parentFrameId + }; + + await dynamicLoader.loadScripts([ + '/js/language/text-scanner.js', + '/js/comm/frame-client.js', + '/fg/js/popup.js', + '/fg/js/popup-proxy.js', + '/fg/js/popup-window.js', + '/fg/js/popup-factory.js', + '/fg/js/frame-ancestry-handler.js', + '/fg/js/frame-offset-forwarder.js', + '/fg/js/frontend.js' + ]); + + const popupFactory = new PopupFactory(this._frameId); + popupFactory.prepare(); + + Object.assign(setupNestedPopupsOptions, { + depth: this._depth + 1, + tabId: this._tabId, + frameId: this._frameId, + popupFactory, + pageType: this._pageType, + allowRootFramePopupProxy: true, + childrenSupported: this._childrenSupported, + hotkeyHandler: this._hotkeyHandler + }); + + const frontend = new Frontend(setupNestedPopupsOptions); + this._frontend = frontend; + await frontend.prepare(); + } + + async _invokeContentOrigin(action, params={}) { + if (this._contentOriginTabId === this._tabId && this._contentOriginFrameId === this._frameId) { + throw new Error('Content origin is same page'); + } + return await api.crossFrame.invokeTab(this._contentOriginTabId, this._contentOriginFrameId, action, params); + } + + _copyHostSelection() { + if (this._contentOriginFrameId === null || window.getSelection().toString()) { return false; } + this._copyHostSelectionInner(); + return true; + } + + async _copyHostSelectionInner() { + switch (this._browser) { + case 'firefox': + case 'firefox-mobile': + { + let text; + try { + text = await this._invokeContentOrigin('getSelectionText'); + } catch (e) { + break; + } + this._copyText(text); + } + break; + default: + await this._invokeContentOrigin('copySelection'); + break; + } + } + + _copyText(text) { + const parent = document.body; + if (parent === null) { return; } + + let textarea = this._copyTextarea; + if (textarea === null) { + textarea = document.createElement('textarea'); + this._copyTextarea = textarea; + } + + textarea.value = text; + parent.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + parent.removeChild(textarea); + } + + _addMultipleEventListeners(container, selector, ...args) { + for (const node of container.querySelectorAll(selector)) { + this._eventListeners.addEventListener(node, ...args); + } + } + + _addEntryEventListeners(entry) { + this._eventListeners.addEventListener(entry, 'click', this._onEntryClick.bind(this)); + this._addMultipleEventListeners(entry, '.action-add-note', 'click', this._onNoteAdd.bind(this)); + this._addMultipleEventListeners(entry, '.action-view-note', 'click', this._onNoteView.bind(this)); + this._addMultipleEventListeners(entry, '.kanji-link', 'click', this._onKanjiLookup.bind(this)); + this._addMultipleEventListeners(entry, '.debug-log-link', 'click', this._onDebugLogClick.bind(this)); + this._addMultipleEventListeners(entry, '.tag', 'click', this._onTagClick.bind(this)); + } + + _updateDefinitionTextScanner(options) { + if (!options.scanning.enablePopupSearch) { + if (this._definitionTextScanner !== null) { + this._definitionTextScanner.setEnabled(false); + } + return; + } + + if (this._definitionTextScanner === null) { + this._definitionTextScanner = new TextScanner({ + node: window, + getSearchContext: this._getSearchContext.bind(this), + documentUtil: this._documentUtil, + searchTerms: true, + searchKanji: false, + searchOnClick: true, + searchOnClickOnly: true + }); + this._definitionTextScanner.includeSelector = '.click-scannable,.click-scannable *'; + this._definitionTextScanner.excludeSelector = '.scan-disable,.scan-disable *'; + this._definitionTextScanner.prepare(); + this._definitionTextScanner.on('searched', this._onDefinitionTextScannerSearched.bind(this)); + } + + const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; + this._definitionTextScanner.setOptions({ + inputs: [{ + include: 'mouse0', + exclude: '', + types: {mouse: true, pen: false, touch: false}, + options: { + searchTerms: true, + searchKanji: true, + scanOnTouchMove: false, + scanOnPenHover: false, + scanOnPenPress: false, + scanOnPenRelease: false, + preventTouchScrolling: false + } + }], + deepContentScan: scanningOptions.deepDomScan, + selectText: false, + delay: scanningOptions.delay, + touchInputEnabled: false, + pointerEventsEnabled: false, + scanLength: scanningOptions.length, + layoutAwareScan: scanningOptions.layoutAwareScan, + preventMiddleMouse: false, + sentenceParsingOptions + }); + + this._definitionTextScanner.setEnabled(true); + } + + _onDefinitionTextScannerSearched({type, definitions, sentence, textSource, optionsContext, error}) { + if (error !== null && !yomichan.isExtensionUnloaded) { + yomichan.logError(error); + } + + if (type === null) { return; } + + const query = textSource.text(); + const url = window.location.href; + const documentTitle = document.title; + const details = { + focus: false, + history: true, + params: { + type, + query, + wildcards: 'off' + }, + state: { + focusEntry: 0, + optionsContext, + url, + sentence, + documentTitle + }, + content: { + definitions, + contentOrigin: this.getContentOrigin() + } + }; + this._definitionTextScanner.clearSelection(true); + this.setContent(details); + } + + _onFrameResizerMouseDown(e) { + if (e.button !== 0) { return; } + // Don't do e.preventDefault() here; this allows mousemove events to be processed + // if the pointer moves out of the frame. + this._startFrameResize(e); + } + + _onFrameResizerMouseUp() { + this._stopFrameResize(); + } + + _onFrameResizerWindowBlur() { + this._stopFrameResize(); + } + + _onFrameResizerMouseMove(e) { + if ((e.buttons & 0x1) === 0x0) { + this._stopFrameResize(); + } else { + if (this._frameResizeStartSize === null) { return; } + const {clientX: x, clientY: y} = e; + this._updateFrameSize(x, y); + } + } + + _getSearchContext() { + return {optionsContext: this.getOptionsContext()}; + } + + _startFrameResize(e) { + if (this._frameResizeToken !== null) { return; } + + const {clientX: x, clientY: y} = e; + const token = {}; + this._frameResizeToken = token; + this._frameResizeStartOffset = {x, y}; + this._frameResizeEventListeners.addEventListener(window, 'mouseup', this._onFrameResizerMouseUp.bind(this), false); + this._frameResizeEventListeners.addEventListener(window, 'blur', this._onFrameResizerWindowBlur.bind(this), false); + this._frameResizeEventListeners.addEventListener(window, 'mousemove', this._onFrameResizerMouseMove.bind(this), false); + + const {documentElement} = document; + if (documentElement !== null) { + documentElement.dataset.isResizing = 'true'; + } + + this._initializeFrameResize(token); + } + + async _initializeFrameResize(token) { + const size = await this._invokeContentOrigin('getFrameSize'); + if (this._frameResizeToken !== token) { return; } + this._frameResizeStartSize = size; + } + + _stopFrameResize() { + if (this._frameResizeToken === null) { return; } + + this._frameResizeEventListeners.removeAllEventListeners(); + this._frameResizeStartSize = null; + this._frameResizeStartOffset = null; + this._frameResizeToken = null; + + const {documentElement} = document; + if (documentElement !== null) { + delete documentElement.dataset.isResizing; + } + } + + async _updateFrameSize(x, y) { + const handleSize = this._frameResizeHandle.getBoundingClientRect(); + let {width, height} = this._frameResizeStartSize; + width += x - this._frameResizeStartOffset.x; + height += y - this._frameResizeStartOffset.y; + width = Math.max(Math.max(0, handleSize.width), width); + height = Math.max(Math.max(0, handleSize.height), height); + await this._invokeContentOrigin('setFrameSize', {width, height}); + } + + _updateHotkeys(options) { + this._hotkeyHandler.setHotkeys(this._pageType, options.inputs.hotkeys); + } + + async _closeTab() { + const tab = await new Promise((resolve, reject) => { + chrome.tabs.getCurrent((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + const tabId = tab.id; + await new Promise((resolve, reject) => { + chrome.tabs.remove(tabId, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + + _onHotkeyClose() { + if (this._closeSinglePopupMenu()) { return; } + this.close(); + } + + _closeAllPopupMenus() { + for (const popupMenu of PopupMenu.openMenus) { + popupMenu.close(); + } + } + + _closeSinglePopupMenu() { + for (const popupMenu of PopupMenu.openMenus) { + popupMenu.close(); + return true; + } + return false; + } + + _postProcessQuery(query) { + const queryPostProcessor = this._queryPostProcessor; + return typeof queryPostProcessor === 'function' ? queryPostProcessor(query) : query; + } +} diff --git a/ext/js/dom/document-focus-controller.js b/ext/js/dom/document-focus-controller.js new file mode 100644 index 00000000..649b5abe --- /dev/null +++ b/ext/js/dom/document-focus-controller.js @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/** + * This class is used to control the document focus when a non-body element contains the main scrollbar. + * Web browsers will not automatically focus a custom element with the scrollbar on load, which results in + * keyboard shortcuts (e.g. arrow keys) not controlling page scroll. Instead, this class will manually + * focus a dummy element inside the main content, which gives keyboard scroll focus to that element. + */ +class DocumentFocusController { + constructor() { + this._contentScrollFocusElement = document.querySelector('#content-scroll-focus'); + } + + prepare() { + window.addEventListener('focus', this._onWindowFocus.bind(this), false); + this._updateFocusedElement(false); + } + + blurElement(element) { + if (document.activeElement !== element) { return; } + element.blur(); + this._updateFocusedElement(false); + } + + // Private + + _onWindowFocus() { + this._updateFocusedElement(false); + } + + _updateFocusedElement(force) { + const target = this._contentScrollFocusElement; + if (target === null) { return; } + + const {activeElement} = document; + if ( + force || + activeElement === null || + activeElement === document.documentElement || + activeElement === document.body + ) { + // Get selection + const selection = window.getSelection(); + const selectionRanges1 = this._getSelectionRanges(selection); + + // Note: This function will cause any selected text to be deselected on Firefox. + target.focus({preventScroll: true}); + + // Restore selection + const selectionRanges2 = this._getSelectionRanges(selection); + if (!this._areRangesSame(selectionRanges1, selectionRanges2)) { + this._setSelectionRanges(selection, selectionRanges1); + } + } + } + + _getSelectionRanges(selection) { + const ranges = []; + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + ranges.push(selection.getRangeAt(i)); + } + return ranges; + } + + _setSelectionRanges(selection, ranges) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + + _areRangesSame(ranges1, ranges2) { + const ii = ranges1.length; + if (ii !== ranges2.length) { + return false; + } + + for (let i = 0; i < ii; ++i) { + const range1 = ranges1[i]; + const range2 = ranges2[i]; + try { + if ( + range1.compareBoundaryPoints(Range.START_TO_START, range2) !== 0 || + range1.compareBoundaryPoints(Range.END_TO_END, range2) !== 0 + ) { + return false; + } + } catch (e) { + return false; + } + } + + return true; + } +} diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js new file mode 100644 index 00000000..513a0c05 --- /dev/null +++ b/ext/js/dom/document-util.js @@ -0,0 +1,570 @@ +/* + * Copyright (C) 2016-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DOMTextScanner + * TextSourceElement + * TextSourceRange + */ + +class DocumentUtil { + constructor() { + this._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; + } + + getRangeFromPoint(x, y, deepContentScan) { + const elements = this._getElementsFromPoint(x, y, deepContentScan); + let imposter = null; + let imposterContainer = null; + let imposterSourceElement = null; + if (elements.length > 0) { + const element = elements[0]; + switch (element.nodeName.toUpperCase()) { + case 'IMG': + case 'BUTTON': + return new TextSourceElement(element); + case 'INPUT': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, false); + break; + case 'TEXTAREA': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, true); + break; + } + } + + const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : []); + if (range !== null) { + if (imposter !== null) { + this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); + this._setImposterStyle(imposter.style, 'pointer-events', 'none'); + } + return new TextSourceRange(range, '', imposterContainer, imposterSourceElement); + } else { + if (imposterContainer !== null) { + imposterContainer.parentNode.removeChild(imposterContainer); + } + return null; + } + } + + /** + * Extract a sentence from a document. + * @param source The text source object, either `TextSourceRange` or `TextSourceElement`. + * @param layoutAwareScan Whether or not layout-aware scan mode should be used. + * @param extent The length of the sentence to extract. + * @param terminatorMap A mapping of characters that terminate a sentence. + * Format: + * ```js + * new Map([ [character: string, [includeCharacterAtStart: boolean, includeCharacterAtEnd: boolean]], ... ]) + * ``` + * @param forwardQuoteMap A mapping of quote characters that delimit a sentence. + * Format: + * ```js + * new Map([ [character: string, [otherCharacter: string, includeCharacterAtStart: boolean]], ... ]) + * ``` + * @param backwardQuoteMap A mapping of quote characters that delimit a sentence, + * which is the inverse of forwardQuoteMap. + * Format: + * ```js + * new Map([ [character: string, [otherCharacter: string, includeCharacterAtEnd: boolean]], ... ]) + * ``` + * @returns The sentence and the offset to the original source: `{sentence: string, offset: integer}`. + */ + extractSentence(source, layoutAwareScan, extent, terminatorMap, forwardQuoteMap, backwardQuoteMap) { + // Scan text + source = source.clone(); + const startLength = source.setStartOffset(extent, layoutAwareScan); + const endLength = source.setEndOffset(extent * 2 - startLength, layoutAwareScan, true); + const text = source.text(); + const textLength = text.length; + const textEndAnchor = textLength - endLength; + let pos1 = startLength; + let pos2 = textEndAnchor; + + // Move backward + let quoteStack = []; + for (; pos1 > 0; --pos1) { + const c = text[pos1 - 1]; + if (c === '\n') { break; } + + if (quoteStack.length === 0) { + const terminatorInfo = terminatorMap.get(c); + if (typeof terminatorInfo !== 'undefined') { + if (terminatorInfo[0]) { --pos1; } + break; + } + } + + let quoteInfo = forwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + if (quoteStack.length === 0) { + if (quoteInfo[1]) { --pos1; } + break; + } else if (quoteStack[0] === c) { + quoteStack.pop(); + continue; + } + } + + quoteInfo = backwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + quoteStack.unshift(quoteInfo[0]); + } + } + + // Move forward + quoteStack = []; + for (; pos2 < textLength; ++pos2) { + const c = text[pos2]; + if (c === '\n') { break; } + + if (quoteStack.length === 0) { + const terminatorInfo = terminatorMap.get(c); + if (typeof terminatorInfo !== 'undefined') { + if (terminatorInfo[1]) { ++pos2; } + break; + } + } + + let quoteInfo = backwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + if (quoteStack.length === 0) { + if (quoteInfo[1]) { ++pos2; } + break; + } else if (quoteStack[0] === c) { + quoteStack.pop(); + continue; + } + } + + quoteInfo = forwardQuoteMap.get(c); + if (typeof quoteInfo !== 'undefined') { + quoteStack.unshift(quoteInfo[0]); + } + } + + // Trim whitespace + for (; pos1 < startLength && this._isWhitespace(text[pos1]); ++pos1) { /* NOP */ } + for (; pos2 > textEndAnchor && this._isWhitespace(text[pos2 - 1]); --pos2) { /* NOP */ } + + // Result + return { + text: text.substring(pos1, pos2), + offset: startLength - pos1 + }; + } + + static isPointInRect(x, y, rect) { + return ( + x >= rect.left && x < rect.right && + y >= rect.top && y < rect.bottom + ); + } + + static isPointInAnyRect(x, y, rects) { + for (const rect of rects) { + if (this.isPointInRect(x, y, rect)) { + return true; + } + } + return false; + } + + static isPointInSelection(x, y, selection) { + for (let i = 0; i < selection.rangeCount; ++i) { + const range = selection.getRangeAt(i); + if (this.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } + return false; + } + + static isMouseButtonPressed(mouseEvent, button) { + const mouseEventButton = mouseEvent.button; + switch (button) { + case 'primary': return mouseEventButton === 0; + case 'secondary': return mouseEventButton === 2; + case 'auxiliary': return mouseEventButton === 1; + default: return false; + } + } + + static getActiveModifiers(event) { + const modifiers = []; + if (event.altKey) { modifiers.push('alt'); } + if (event.ctrlKey) { modifiers.push('ctrl'); } + if (event.metaKey) { modifiers.push('meta'); } + if (event.shiftKey) { modifiers.push('shift'); } + return modifiers; + } + + static getActiveModifiersAndButtons(event) { + const modifiers = this.getActiveModifiers(event); + this._getActiveButtons(event, modifiers); + return modifiers; + } + + static getActiveButtons(event) { + const buttons = []; + this._getActiveButtons(event, buttons); + return buttons; + } + + static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { + const target = document; + const options = false; + const fullscreenEventNames = [ + 'fullscreenchange', + 'MSFullscreenChange', + 'mozfullscreenchange', + 'webkitfullscreenchange' + ]; + for (const eventName of fullscreenEventNames) { + if (eventListenerCollection === null) { + target.addEventListener(eventName, onFullscreenChanged, options); + } else { + eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options); + } + } + } + + static getFullscreenElement() { + return ( + document.fullscreenElement || + document.msFullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + null + ); + } + + static getNodesInRange(range) { + const end = range.endContainer; + const nodes = []; + for (let node = range.startContainer; node !== null; node = this.getNextNode(node)) { + nodes.push(node); + if (node === end) { break; } + } + return nodes; + } + + static getNextNode(node) { + let next = node.firstChild; + if (next === null) { + while (true) { + next = node.nextSibling; + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + static anyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + for (; node !== null; node = node.parentNode) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { return true; } + break; + } + } + return false; + } + + static everyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + while (true) { + if (node === null) { return false; } + if (node.nodeType === ELEMENT_NODE && node.matches(selector)) { break; } + node = node.parentNode; + } + } + return true; + } + + static isMetaKeySupported(os, browser) { + return !(browser === 'firefox' || browser === 'firefox-mobile') || os === 'mac'; + } + + static _getActiveButtons(event, array) { + let {buttons} = event; + if (typeof buttons === 'number' && buttons > 0) { + for (let i = 0; i < 6; ++i) { + const buttonFlag = (1 << i); + if ((buttons & buttonFlag) !== 0) { + array.push(`mouse${i}`); + buttons &= ~buttonFlag; + if (buttons === 0) { break; } + } + } + } + } + + // Private + + _setImposterStyle(style, propertyName, value) { + style.setProperty(propertyName, value, 'important'); + } + + _createImposter(element, isTextarea) { + const body = document.body; + if (body === null) { return [null, null]; } + + const elementStyle = window.getComputedStyle(element); + const elementRect = element.getBoundingClientRect(); + const documentRect = document.documentElement.getBoundingClientRect(); + let left = elementRect.left - documentRect.left; + let top = elementRect.top - documentRect.top; + + // Container + const container = document.createElement('div'); + const containerStyle = container.style; + this._setImposterStyle(containerStyle, 'all', 'initial'); + this._setImposterStyle(containerStyle, 'position', 'absolute'); + this._setImposterStyle(containerStyle, 'left', '0'); + this._setImposterStyle(containerStyle, 'top', '0'); + this._setImposterStyle(containerStyle, 'width', `${documentRect.width}px`); + this._setImposterStyle(containerStyle, 'height', `${documentRect.height}px`); + this._setImposterStyle(containerStyle, 'overflow', 'hidden'); + this._setImposterStyle(containerStyle, 'opacity', '0'); + this._setImposterStyle(containerStyle, 'pointer-events', 'none'); + this._setImposterStyle(containerStyle, 'z-index', '2147483646'); + + // Imposter + const imposter = document.createElement('div'); + const imposterStyle = imposter.style; + + let value = element.value; + if (value.endsWith('\n')) { value += '\n'; } + imposter.textContent = value; + + for (let i = 0, ii = elementStyle.length; i < ii; ++i) { + const property = elementStyle[i]; + this._setImposterStyle(imposterStyle, property, elementStyle.getPropertyValue(property)); + } + this._setImposterStyle(imposterStyle, 'position', 'absolute'); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'margin', '0'); + this._setImposterStyle(imposterStyle, 'pointer-events', 'auto'); + + if (isTextarea) { + if (elementStyle.overflow === 'visible') { + this._setImposterStyle(imposterStyle, 'overflow', 'auto'); + } + } else { + this._setImposterStyle(imposterStyle, 'overflow', 'hidden'); + this._setImposterStyle(imposterStyle, 'white-space', 'nowrap'); + this._setImposterStyle(imposterStyle, 'line-height', elementStyle.height); + } + + container.appendChild(imposter); + body.appendChild(container); + + // Adjust size + const imposterRect = imposter.getBoundingClientRect(); + if (imposterRect.width !== elementRect.width || imposterRect.height !== elementRect.height) { + const width = parseFloat(elementStyle.width) + (elementRect.width - imposterRect.width); + const height = parseFloat(elementStyle.height) + (elementRect.height - imposterRect.height); + this._setImposterStyle(imposterStyle, 'width', `${width}px`); + this._setImposterStyle(imposterStyle, 'height', `${height}px`); + } + if (imposterRect.x !== elementRect.x || imposterRect.y !== elementRect.y) { + left += (elementRect.left - imposterRect.left); + top += (elementRect.top - imposterRect.top); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + } + + imposter.scrollTop = element.scrollTop; + imposter.scrollLeft = element.scrollLeft; + + return [imposter, container]; + } + + _getElementsFromPoint(x, y, all) { + if (all) { + // document.elementsFromPoint can return duplicates which must be removed. + const elements = document.elementsFromPoint(x, y); + return elements.filter((e, i) => elements.indexOf(e) === i); + } + + const e = document.elementFromPoint(x, y); + return e !== null ? [e] : []; + } + + _isPointInRange(x, y, range) { + // Require a text node to start + if (range.startContainer.nodeType !== Node.TEXT_NODE) { + return false; + } + + // Scan forward + const nodePre = range.endContainer; + const offsetPre = range.endOffset; + try { + const {node, offset, content} = new DOMTextScanner(range.endContainer, range.endOffset, true, false).seek(1); + range.setEnd(node, offset); + + if (!this._isWhitespace(content) && DocumentUtil.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } finally { + range.setEnd(nodePre, offsetPre); + } + + // Scan backward + const {node, offset, content} = new DOMTextScanner(range.startContainer, range.startOffset, true, false).seek(-1); + range.setStart(node, offset); + + if (!this._isWhitespace(content) && DocumentUtil.isPointInAnyRect(x, y, range.getClientRects())) { + // This purposefully leaves the starting offset as modified and sets the range length to 0. + range.setEnd(node, offset); + return true; + } + + // No match + return false; + } + + _isWhitespace(string) { + return string.trim().length === 0; + } + + _caretRangeFromPoint(x, y) { + if (typeof document.caretRangeFromPoint === 'function') { + // Chrome, Edge + return document.caretRangeFromPoint(x, y); + } + + if (typeof document.caretPositionFromPoint === 'function') { + // Firefox + return this._caretPositionFromPoint(x, y); + } + + // No support + return null; + } + + _caretPositionFromPoint(x, y) { + const position = document.caretPositionFromPoint(x, y); + if (position === null) { + return null; + } + const node = position.offsetNode; + if (node === null) { + return null; + } + + const range = document.createRange(); + const offset = (node.nodeType === Node.TEXT_NODE ? position.offset : 0); + try { + range.setStart(node, offset); + range.setEnd(node, offset); + } catch (e) { + // Firefox throws new DOMException("The operation is insecure.") + // when trying to select a node from within a ShadowRoot. + return null; + } + return range; + } + + _caretRangeFromPointExt(x, y, elements) { + const modifications = []; + try { + let i = 0; + let startContinerPre = null; + while (true) { + const range = this._caretRangeFromPoint(x, y); + if (range === null) { + return null; + } + + const startContainer = range.startContainer; + if (startContinerPre !== startContainer) { + if (this._isPointInRange(x, y, range)) { + return range; + } + startContinerPre = startContainer; + } + + i = this._disableTransparentElement(elements, i, modifications); + if (i < 0) { + return null; + } + } + } finally { + if (modifications.length > 0) { + this._restoreElementStyleModifications(modifications); + } + } + } + + _disableTransparentElement(elements, i, modifications) { + while (true) { + if (i >= elements.length) { + return -1; + } + + const element = elements[i++]; + if (this._isElementTransparent(element)) { + const style = element.hasAttribute('style') ? element.getAttribute('style') : null; + modifications.push({element, style}); + element.style.setProperty('pointer-events', 'none', 'important'); + return i; + } + } + } + + _restoreElementStyleModifications(modifications) { + for (const {element, style} of modifications) { + if (style === null) { + element.removeAttribute('style'); + } else { + element.setAttribute('style', style); + } + } + } + + _isElementTransparent(element) { + if ( + element === document.body || + element === document.documentElement + ) { + return false; + } + const style = window.getComputedStyle(element); + return ( + parseFloat(style.opacity) <= 0 || + style.visibility === 'hidden' || + (style.backgroundImage === 'none' && this._isColorTransparent(style.backgroundColor)) + ); + } + + _isColorTransparent(cssColor) { + return this._transparentColorPattern.test(cssColor); + } +} diff --git a/ext/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js new file mode 100644 index 00000000..292b2f67 --- /dev/null +++ b/ext/js/dom/dom-data-binder.js @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * SelectorObserver + * TaskAccumulator + */ + +class DOMDataBinder { + constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) { + this._selector = selector; + this._ignoreSelectors = ignoreSelectors; + this._createElementMetadata = createElementMetadata; + this._compareElementMetadata = compareElementMetadata; + this._getValues = getValues; + this._setValues = setValues; + this._onError = onError; + this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this)); + this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this)); + this._selectorObserver = new SelectorObserver({ + selector, + ignoreSelector: (ignoreSelectors.length > 0 ? ignoreSelectors.join(',') : null), + onAdded: this._createObserver.bind(this), + onRemoved: this._removeObserver.bind(this), + onChildrenUpdated: this._onObserverChildrenUpdated.bind(this), + isStale: this._isObserverStale.bind(this) + }); + } + + observe(element) { + this._selectorObserver.observe(element, true); + } + + disconnect() { + this._selectorObserver.disconnect(); + } + + async refresh() { + await this._updateTasks.enqueue(null, {all: true}); + } + + // Private + + async _onBulkUpdate(tasks) { + let all = false; + const targets = []; + for (const [observer, task] of tasks) { + if (observer === null) { + if (task.data.all) { + all = true; + break; + } + } else { + targets.push([observer, task]); + } + } + if (all) { + targets.length = 0; + for (const observer of this._selectorObserver.datas()) { + targets.push([observer, null]); + } + } + + const args = targets.map(([observer]) => ({ + element: observer.element, + metadata: observer.metadata + })); + const responses = await this._getValues(args); + this._applyValues(targets, responses, true); + } + + async _onBulkAssign(tasks) { + const targets = tasks; + const args = targets.map(([observer, task]) => ({ + element: observer.element, + metadata: observer.metadata, + value: task.data.value + })); + const responses = await this._setValues(args); + this._applyValues(targets, responses, false); + } + + _onElementChange(observer) { + const value = this._getElementValue(observer.element); + observer.value = value; + observer.hasValue = true; + this._assignTasks.enqueue(observer, {value}); + } + + _applyValues(targets, response, ignoreStale) { + if (!Array.isArray(response)) { return; } + + for (let i = 0, ii = targets.length; i < ii; ++i) { + const [observer, task] = targets[i]; + const {error, result} = response[i]; + const stale = (task !== null && task.stale); + + if (error) { + if (typeof this._onError === 'function') { + this._onError(error, stale, observer.element, observer.metadata); + } + continue; + } + + if (stale && !ignoreStale) { continue; } + + observer.value = result; + observer.hasValue = true; + this._setElementValue(observer.element, result); + } + } + + _createObserver(element) { + const metadata = this._createElementMetadata(element); + const nodeName = element.nodeName.toUpperCase(); + const observer = { + element, + type: (nodeName === 'INPUT' ? element.type : null), + value: null, + hasValue: false, + onChange: null, + metadata + }; + observer.onChange = this._onElementChange.bind(this, observer); + + element.addEventListener('change', observer.onChange, false); + + this._updateTasks.enqueue(observer); + + return observer; + } + + _removeObserver(element, observer) { + element.removeEventListener('change', observer.onChange, false); + observer.onChange = null; + } + + _onObserverChildrenUpdated(element, observer) { + if (observer.hasValue) { + this._setElementValue(element, observer.value); + } + } + + _isObserverStale(element, observer) { + const {type, metadata} = observer; + const nodeName = element.nodeName.toUpperCase(); + return !( + type === (nodeName === 'INPUT' ? element.type : null) && + this._compareElementMetadata(metadata, this._createElementMetadata(element)) + ); + } + + _setElementValue(element, value) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + element.checked = value; + break; + case 'text': + case 'number': + element.value = value; + break; + } + break; + case 'TEXTAREA': + case 'SELECT': + element.value = value; + break; + } + + const event = new CustomEvent('settingChanged', {detail: {value}}); + element.dispatchEvent(event); + } + + _getElementValue(element) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return DOMDataBinder.convertToNumber(element.value, element); + } + break; + case 'TEXTAREA': + case 'SELECT': + return element.value; + } + return null; + } + + // Utilities + + static convertToNumber(value, constraints) { + value = parseFloat(value); + if (!Number.isFinite(value)) { return 0; } + + let {min, max, step} = constraints; + min = DOMDataBinder.convertToNumberOrNull(min); + max = DOMDataBinder.convertToNumberOrNull(max); + step = DOMDataBinder.convertToNumberOrNull(step); + if (typeof min === 'number') { value = Math.max(value, min); } + if (typeof max === 'number') { value = Math.min(value, max); } + if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; } + return value; + } + + static convertToNumberOrNull(value) { + if (typeof value !== 'number') { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + value = parseFloat(value); + } + return !Number.isNaN(value) ? value : null; + } +} diff --git a/ext/js/dom/html-template-collection.js b/ext/js/dom/html-template-collection.js new file mode 100644 index 00000000..52d5f3b0 --- /dev/null +++ b/ext/js/dom/html-template-collection.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020-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 <http://www.gnu.org/licenses/>. + */ + +class HtmlTemplateCollection { + constructor(source) { + this._templates = new Map(); + + const sourceNode = ( + typeof source === 'string' ? + new DOMParser().parseFromString(source, 'text/html') : + source + ); + + const pattern = /^([\w\W]+)-template$/; + for (const template of sourceNode.querySelectorAll('template')) { + const match = pattern.exec(template.id); + if (match === null) { continue; } + this._prepareTemplate(template); + this._templates.set(match[1], template); + } + } + + instantiate(name) { + const template = this._templates.get(name); + return document.importNode(template.content.firstChild, true); + } + + instantiateFragment(name) { + const template = this._templates.get(name); + return document.importNode(template.content, true); + } + + getAllTemplates() { + return this._templates.values(); + } + + // Private + + _prepareTemplate(template) { + if (template.dataset.removeWhitespaceText === 'true') { + this._removeWhitespaceText(template); + } + } + + _removeWhitespaceText(template) { + const {content} = template; + const {TEXT_NODE} = Node; + const iterator = document.createNodeIterator(content, NodeFilter.SHOW_TEXT); + const removeNodes = []; + while (true) { + const node = iterator.nextNode(); + if (node === null) { break; } + if (node.nodeType === TEXT_NODE && node.nodeValue.trim().length === 0) { + removeNodes.push(node); + } + } + for (const node of removeNodes) { + const {parentNode} = node; + if (parentNode !== null) { + parentNode.removeChild(node); + } + } + } +} diff --git a/ext/js/dom/panel-element.js b/ext/js/dom/panel-element.js new file mode 100644 index 00000000..1ef61d6f --- /dev/null +++ b/ext/js/dom/panel-element.js @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class PanelElement extends EventDispatcher { + constructor({node, closingAnimationDuration}) { + super(); + this._node = node; + this._closingAnimationDuration = closingAnimationDuration; + this._hiddenAnimatingClass = 'hidden-animating'; + this._mutationObserver = null; + this._visible = false; + this._closeTimer = null; + } + + get node() { + return this._node; + } + + isVisible() { + return !this._node.hidden; + } + + setVisible(value, animate=true) { + value = !!value; + if (this.isVisible() === value) { return; } + + if (this._closeTimer !== null) { + clearTimeout(this._closeTimer); + this._completeClose(true); + } + + const node = this._node; + const {classList} = node; + if (value) { + if (animate) { classList.add(this._hiddenAnimatingClass); } + getComputedStyle(node).getPropertyValue('display'); // Force update of CSS display property, allowing animation + classList.remove(this._hiddenAnimatingClass); + node.hidden = false; + node.focus(); + } else { + if (animate) { classList.add(this._hiddenAnimatingClass); } + node.hidden = true; + if (animate) { + this._closeTimer = setTimeout(() => this._completeClose(false), this._closingAnimationDuration); + } + } + } + + on(eventName, callback) { + if (eventName === 'visibilityChanged') { + if (this._mutationObserver === null) { + this._visible = this.isVisible(); + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._mutationObserver.observe(this._node, { + attributes: true, + attributeFilter: ['hidden'], + attributeOldValue: true + }); + } + } + return super.on(eventName, callback); + } + + off(eventName, callback) { + const result = super.off(eventName, callback); + if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) { + if (this._mutationObserver !== null) { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } + } + return result; + } + + // Private + + _onMutation() { + const visible = this.isVisible(); + if (this._visible === visible) { return; } + this._visible = visible; + this.trigger('visibilityChanged', {visible}); + } + + _completeClose(reopening) { + this._closeTimer = null; + this._node.classList.remove(this._hiddenAnimatingClass); + this.trigger('closeCompleted', {reopening}); + } +} diff --git a/ext/js/dom/popup-menu.js b/ext/js/dom/popup-menu.js new file mode 100644 index 00000000..9ad4e260 --- /dev/null +++ b/ext/js/dom/popup-menu.js @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class PopupMenu extends EventDispatcher { + constructor(sourceElement, containerNode) { + super(); + this._sourceElement = sourceElement; + this._containerNode = containerNode; + this._node = containerNode.querySelector('.popup-menu'); + this._bodyNode = containerNode.querySelector('.popup-menu-body'); + this._isClosed = false; + this._eventListeners = new EventListenerCollection(); + } + + get sourceElement() { + return this._sourceElement; + } + + get containerNode() { + return this._containerNode; + } + + get node() { + return this._node; + } + + get bodyNode() { + return this._bodyNode; + } + + get isClosed() { + return this._isClosed; + } + + prepare() { + const items = this._bodyNode.querySelectorAll('.popup-menu-item'); + this._setPosition(); + this._containerNode.focus(); + + this._eventListeners.addEventListener(window, 'resize', this._onWindowResize.bind(this), false); + this._eventListeners.addEventListener(this._containerNode, 'click', this._onMenuContainerClick.bind(this), false); + + const onMenuItemClick = this._onMenuItemClick.bind(this); + for (const item of items) { + this._eventListeners.addEventListener(item, 'click', onMenuItemClick, false); + } + + PopupMenu.openMenus.add(this); + + this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', { + bubbles: false, + cancelable: false, + detail: {menu: this} + })); + } + + close(cancelable=true) { + return this._close(null, 'close', cancelable); + } + + // Private + + _onMenuContainerClick(e) { + if (e.currentTarget !== e.target) { return; } + e.stopPropagation(); + e.preventDefault(); + this._close(null, 'outside', true); + } + + _onMenuItemClick(e) { + const item = e.currentTarget; + if (item.disabled) { return; } + e.stopPropagation(); + e.preventDefault(); + this._close(item, 'item', true); + } + + _onWindowResize() { + this._close(null, 'resize', true); + } + + _setPosition() { + // Get flags + let horizontal = 1; + let vertical = 1; + let horizontalCover = 1; + let verticalCover = 1; + const positionInfo = this._sourceElement.dataset.menuPosition; + if (typeof positionInfo === 'string') { + const positionInfoSet = new Set(positionInfo.split(' ')); + + if (positionInfoSet.has('left')) { + horizontal = -1; + } else if (positionInfoSet.has('right')) { + horizontal = 1; + } else if (positionInfoSet.has('h-center')) { + horizontal = 0; + } + + if (positionInfoSet.has('above')) { + vertical = -1; + } else if (positionInfoSet.has('below')) { + vertical = 1; + } else if (positionInfoSet.has('v-center')) { + vertical = 0; + } + + if (positionInfoSet.has('cover')) { + horizontalCover = 1; + verticalCover = 1; + } else if (positionInfoSet.has('no-cover')) { + horizontalCover = -1; + verticalCover = -1; + } + + if (positionInfoSet.has('h-cover')) { + horizontalCover = 1; + } else if (positionInfoSet.has('no-h-cover')) { + horizontalCover = -1; + } + + if (positionInfoSet.has('v-cover')) { + verticalCover = 1; + } else if (positionInfoSet.has('no-v-cover')) { + verticalCover = -1; + } + } + + // Position + const menu = this._node; + const fullRect = this._containerNode.getBoundingClientRect(); + const sourceRect = this._sourceElement.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + let top = menuRect.top; + let bottom = menuRect.bottom; + if (verticalCover === 1) { + const bodyRect = this._bodyNode.getBoundingClientRect(); + top = bodyRect.top; + bottom = bodyRect.bottom; + } + + let x = ( + sourceRect.left + + sourceRect.width * ((-horizontal * horizontalCover + 1) * 0.5) + + menuRect.width * ((-horizontal + 1) * -0.5) + ); + let y = ( + sourceRect.top + + (menuRect.top - top) + + sourceRect.height * ((-vertical * verticalCover + 1) * 0.5) + + (bottom - top) * ((-vertical + 1) * -0.5) + ); + + x = Math.max(0.0, Math.min(fullRect.width - menuRect.width, x)); + y = Math.max(0.0, Math.min(fullRect.height - menuRect.height, y)); + + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + } + + _close(item, cause, cancelable) { + if (this._isClosed) { return true; } + const action = (item !== null ? item.dataset.menuAction : null); + + const detail = { + menu: this, + item, + action, + cause + }; + const result = this._sourceElement.dispatchEvent(new CustomEvent('menuClose', {bubbles: false, cancelable, detail})); + if (cancelable && !result) { return false; } + + PopupMenu.openMenus.delete(this); + + this._isClosed = true; + this._eventListeners.removeAllEventListeners(); + if (this._containerNode.parentNode !== null) { + this._containerNode.parentNode.removeChild(this._containerNode); + } + + this.trigger('close', detail); + return true; + } +} + +Object.defineProperty(PopupMenu, 'openMenus', { + configurable: false, + enumerable: true, + writable: false, + value: new Set() +}); diff --git a/ext/js/dom/selector-observer.js b/ext/js/dom/selector-observer.js new file mode 100644 index 00000000..2f3fa49e --- /dev/null +++ b/ext/js/dom/selector-observer.js @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/** + * Class which is used to observe elements matching a selector in specific element. + */ +class SelectorObserver { + /** + * Creates a new instance. + * @param selector A string CSS selector used to find elements. + * @param ignoreSelector A string CSS selector used to filter elements, or null for no filtering. + * @param onAdded A function which is invoked for each element that is added that matches the selector. + * The signature is (element) => data. + * @param onRemoved A function which is invoked for each element that is removed, or null. + * The signature is (element, data) => void. + * @param onChildrenUpdated A function which is invoked for each element which has its children updated, or null. + * The signature is (element, data) => void. + * @param isStale A function which checks if the data is stale for a given element, or null. + * If the element is stale, it will be removed and potentially re-added. + * The signature is (element, data) => bool. + */ + constructor({selector, ignoreSelector=null, onAdded=null, onRemoved=null, onChildrenUpdated=null, isStale=null}) { + this._selector = selector; + this._ignoreSelector = ignoreSelector; + this._onAdded = onAdded; + this._onRemoved = onRemoved; + this._onChildrenUpdated = onChildrenUpdated; + this._isStale = isStale; + this._observingElement = null; + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._elementMap = new Map(); // Map([element => observer]...) + this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...) + this._isObserving = false; + } + + /** + * Returns whether or not an element is currently being observed. + * @returns True if an element is being observed, false otherwise. + */ + get isObserving() { + return this._observingElement !== null; + } + + /** + * Starts DOM mutation observing the target element. + * @param element The element to observe changes in. + * @param attributes A boolean for whether or not attribute changes should be observed. + * @throws An error if element is null. + * @throws An error if an element is already being observed. + */ + observe(element, attributes=false) { + if (element === null) { + throw new Error('Invalid element'); + } + if (this.isObserving) { + throw new Error('Instance is already observing an element'); + } + + this._observingElement = element; + this._mutationObserver.observe(element, { + attributes: !!attributes, + childList: true, + subtree: true + }); + + this._onMutation([{ + type: 'childList', + target: element.parentNode, + addedNodes: [element], + removedNodes: [] + }]); + } + + /** + * Stops observing the target element. + */ + disconnect() { + if (!this.isObserving) { return; } + + this._mutationObserver.disconnect(); + this._observingElement = null; + + for (const observer of this._elementMap.values()) { + this._removeObserver(observer); + } + } + + /** + * Returns an iterable list of [element, data] pairs. + * @yields A sequence of [element, data] pairs. + */ + *entries() { + for (const [element, {data}] of this._elementMap) { + yield [element, data]; + } + } + + /** + * Returns an iterable list of data for every element. + * @yields A sequence of data values. + */ + *datas() { + for (const {data} of this._elementMap.values()) { + yield data; + } + } + + // Private + + _onMutation(mutationList) { + for (const mutation of mutationList) { + switch (mutation.type) { + case 'childList': + this._onChildListMutation(mutation); + break; + case 'attributes': + this._onAttributeMutation(mutation); + break; + } + } + } + + _onChildListMutation({addedNodes, removedNodes, target}) { + const selector = this._selector; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + for (const node of removedNodes) { + const observers = this._elementAncestorMap.get(node); + if (typeof observers === 'undefined') { continue; } + for (const observer of observers) { + this._removeObserver(observer); + } + } + + for (const node of addedNodes) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { + this._createObserver(node); + } + for (const childNode of node.querySelectorAll(selector)) { + this._createObserver(childNode); + } + } + + if ( + this._onChildrenUpdated !== null && + (addedNodes.length !== 0 || addedNodes.length !== 0) + ) { + for (let node = target; node !== null; node = node.parentNode) { + const observer = this._elementMap.get(node); + if (typeof observer !== 'undefined') { + this._onObserverChildrenUpdated(observer); + } + } + } + } + + _onAttributeMutation({target}) { + const selector = this._selector; + const observers = this._elementAncestorMap.get(target); + if (typeof observers !== 'undefined') { + for (const observer of observers) { + const element = observer.element; + if ( + !element.matches(selector) || + this._shouldIgnoreElement(element) || + this._isObserverStale(observer) + ) { + this._removeObserver(observer); + } + } + } + + if (target.matches(selector)) { + this._createObserver(target); + } + } + + _createObserver(element) { + if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; } + + const data = this._onAdded(element); + const ancestors = this._getAncestors(element); + const observer = {element, ancestors, data}; + + this._elementMap.set(element, observer); + + for (const ancestor of ancestors) { + let observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { + observers = new Set(); + this._elementAncestorMap.set(ancestor, observers); + } + observers.add(observer); + } + } + + _removeObserver(observer) { + const {element, ancestors, data} = observer; + + this._elementMap.delete(element); + + for (const ancestor of ancestors) { + const observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { continue; } + + observers.delete(observer); + if (observers.size === 0) { + this._elementAncestorMap.delete(ancestor); + } + } + + if (this._onRemoved !== null) { + this._onRemoved(element, data); + } + } + + _onObserverChildrenUpdated(observer) { + this._onChildrenUpdated(observer.element, observer.data); + } + + _isObserverStale(observer) { + return (this._isStale !== null && this._isStale(observer.element, observer.data)); + } + + _shouldIgnoreElement(element) { + return (this._ignoreSelector !== null && element.matches(this._ignoreSelector)); + } + + _getAncestors(node) { + const root = this._observingElement; + const results = []; + while (true) { + results.push(node); + if (node === root) { break; } + node = node.parentNode; + if (node === null) { break; } + } + return results; + } +} diff --git a/ext/js/dom/window-scroll.js b/ext/js/dom/window-scroll.js new file mode 100644 index 00000000..33577795 --- /dev/null +++ b/ext/js/dom/window-scroll.js @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2019-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 <https://www.gnu.org/licenses/>. + */ + +class WindowScroll { + constructor(node) { + this._node = node; + this._animationRequestId = null; + this._animationStartTime = 0; + this._animationStartX = 0; + this._animationStartY = 0; + this._animationEndTime = 0; + this._animationEndX = 0; + this._animationEndY = 0; + this._requestAnimationFrameCallback = this._onAnimationFrame.bind(this); + } + + get x() { + return this._node !== null ? this._node.scrollLeft : window.scrollX || window.pageXOffset; + } + + get y() { + return this._node !== null ? this._node.scrollTop : window.scrollY || window.pageYOffset; + } + + toY(y) { + this.to(this.x, y); + } + + toX(x) { + this.to(x, this.y); + } + + to(x, y) { + this.stop(); + this._scroll(x, y); + } + + animate(x, y, time) { + this._animationStartX = this.x; + this._animationStartY = this.y; + this._animationStartTime = window.performance.now(); + this._animationEndX = x; + this._animationEndY = y; + this._animationEndTime = this._animationStartTime + time; + this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); + } + + stop() { + if (this._animationRequestId === null) { + return; + } + + window.cancelAnimationFrame(this._animationRequestId); + this._animationRequestId = null; + } + + // Private + + _onAnimationFrame(time) { + if (time >= this._animationEndTime) { + this._scroll(this._animationEndX, this._animationEndY); + this._animationRequestId = null; + return; + } + + const t = this._easeInOutCubic((time - this._animationStartTime) / (this._animationEndTime - this._animationStartTime)); + this._scroll( + this._lerp(this._animationStartX, this._animationEndX, t), + this._lerp(this._animationStartY, this._animationEndY, t) + ); + + this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); + } + + _easeInOutCubic(t) { + if (t < 0.5) { + return (4.0 * t * t * t); + } else { + t = 1.0 - t; + return 1.0 - (4.0 * t * t * t); + } + } + + _lerp(start, end, percent) { + return (end - start) * percent + start; + } + + _scroll(x, y) { + if (this._node !== null) { + this._node.scrollLeft = x; + this._node.scrollTop = y; + } else { + window.scroll(x, y); + } + } +} diff --git a/ext/js/general/cache-map.js b/ext/js/general/cache-map.js new file mode 100644 index 00000000..c7d72e6b --- /dev/null +++ b/ext/js/general/cache-map.js @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/** + * Class which caches a map of values, keeping the most recently accessed values. + */ +class CacheMap { + /** + * Creates a new CacheMap. + * @param maxSize The maximum number of entries able to be stored in the cache. + */ + constructor(maxSize) { + if (!( + typeof maxSize === 'number' && + Number.isFinite(maxSize) && + maxSize >= 0 && + Math.floor(maxSize) === maxSize + )) { + throw new Error('Invalid maxCount'); + } + + this._maxSize = maxSize; + this._map = new Map(); + this._listFirst = this._createNode(null, null); + this._listLast = this._createNode(null, null); + this._resetEndNodes(); + } + + /** + * Returns the number of items in the cache. + */ + get size() { + return this._map.size; + } + + /** + * Returns the maximum number of items that can be added to the cache. + */ + get maxSize() { + return this._maxSize; + } + + /** + * Returns whether or not an element exists at the given key. + * @param key The key of the element. + * @returns `true` if an element with the specified key exists, `false` otherwise. + */ + has(key) { + return this._map.has(key); + } + + /** + * Gets an element at the given key, if it exists. Otherwise, returns undefined. + * @param key The key of the element. + * @returns The existing value at the key, if any; `undefined` otherwise. + */ + get(key) { + const node = this._map.get(key); + if (typeof node === 'undefined') { return void 0; } + this._updateRecency(node); + return node.value; + } + + /** + * Sets a value at a given key. + * @param key The key of the element. + * @param value The value to store in the cache. + */ + set(key, value) { + let node = this._map.get(key); + if (typeof node !== 'undefined') { + this._updateRecency(node); + node.value = value; + } else { + if (this._maxSize <= 0) { return; } + + node = this._createNode(key, value); + this._addNode(node, this._listFirst); + this._map.set(key, node); + + // Remove + for (let removeCount = this._map.size - this._maxSize; removeCount > 0; --removeCount) { + node = this._listLast.previous; + this._removeNode(node); + this._map.delete(node.key); + } + } + } + + /** + * Clears the cache. + */ + clear() { + this._map.clear(); + this._resetEndNodes(); + } + + // Private + + _updateRecency(node) { + this._removeNode(node); + this._addNode(node, this._listFirst); + } + + _createNode(key, value) { + return {key, value, previous: null, next: null}; + } + + _addNode(node, previous) { + const next = previous.next; + node.next = next; + node.previous = previous; + previous.next = node; + next.previous = node; + } + + _removeNode(node) { + node.next.previous = node.previous; + node.previous.next = node.next; + } + + _resetEndNodes() { + this._listFirst.next = this._listLast; + this._listLast.previous = this._listFirst; + } +} diff --git a/ext/js/general/object-property-accessor.js b/ext/js/general/object-property-accessor.js new file mode 100644 index 00000000..95773029 --- /dev/null +++ b/ext/js/general/object-property-accessor.js @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2016-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 <https://www.gnu.org/licenses/>. + */ + +/** + * Class used to get and mutate generic properties of an object by using path strings. + */ +class ObjectPropertyAccessor { + /** + * Create a new accessor for a specific object. + * @param target The object which the getter and mutation methods are applied to. + * @returns A new ObjectPropertyAccessor instance. + */ + constructor(target) { + this._target = target; + } + + /** + * Gets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param pathLength How many parts of the pathArray to use. + * This parameter is optional and defaults to the length of pathArray. + * @returns The value found at the path. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + get(pathArray, pathLength) { + let target = this._target; + const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length; + for (let i = 0; i < ii; ++i) { + const key = pathArray[i]; + if (!ObjectPropertyAccessor.hasProperty(target, key)) { + throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray.slice(0, i + 1))}`); + } + target = target[key]; + } + return target; + } + + /** + * Sets the value at the specified path. + * @param pathArray The path to the property on the target object. + * @param value The value to assign to the property. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + set(pathArray, value) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; + if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { + throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); + } + + target[key] = value; + } + + /** + * Deletes the property of the target object at the specified path. + * @param pathArray The path to the property on the target object. + * @throws An error is thrown if pathArray is not valid for the target object. + */ + delete(pathArray) { + const ii = pathArray.length - 1; + if (ii < 0) { throw new Error('Invalid path'); } + + const target = this.get(pathArray, ii); + const key = pathArray[ii]; + if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { + throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); + } + + if (Array.isArray(target)) { + throw new Error('Invalid type'); + } + + delete target[key]; + } + + /** + * Swaps two properties of an object or array. + * @param pathArray1 The path to the first property on the target object. + * @param pathArray2 The path to the second property on the target object. + * @throws An error is thrown if pathArray1 or pathArray2 is not valid for the target object, + * or if the swap cannot be performed. + */ + swap(pathArray1, pathArray2) { + const ii1 = pathArray1.length - 1; + if (ii1 < 0) { throw new Error('Invalid path 1'); } + const target1 = this.get(pathArray1, ii1); + const key1 = pathArray1[ii1]; + if (!ObjectPropertyAccessor.isValidPropertyType(target1, key1)) { throw new Error(`Invalid path 1: ${ObjectPropertyAccessor.getPathString(pathArray1)}`); } + + const ii2 = pathArray2.length - 1; + if (ii2 < 0) { throw new Error('Invalid path 2'); } + const target2 = this.get(pathArray2, ii2); + const key2 = pathArray2[ii2]; + if (!ObjectPropertyAccessor.isValidPropertyType(target2, key2)) { throw new Error(`Invalid path 2: ${ObjectPropertyAccessor.getPathString(pathArray2)}`); } + + const value1 = target1[key1]; + const value2 = target2[key2]; + + target1[key1] = value2; + try { + target2[key2] = value1; + } catch (e) { + // Revert + try { + target1[key1] = value1; + } catch (e2) { + // NOP + } + throw e; + } + } + + /** + * Converts a path string to a path array. + * @param pathArray The path array to convert. + * @returns A string representation of pathArray. + */ + static getPathString(pathArray) { + const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + let pathString = ''; + let first = true; + for (let part of pathArray) { + switch (typeof part) { + case 'number': + if (Math.floor(part) !== part || part < 0) { + throw new Error('Invalid index'); + } + part = `[${part}]`; + break; + case 'string': + if (!regexShort.test(part)) { + const escapedPart = part.replace(/["\\]/g, '\\$&'); + part = `["${escapedPart}"]`; + } else { + if (!first) { + part = `.${part}`; + } + } + break; + default: + throw new Error(`Invalid type: ${typeof part}`); + } + pathString += part; + first = false; + } + return pathString; + } + + /** + * Converts a path array to a path string. For the most part, the format of this string + * matches Javascript's notation for property access. + * @param pathString The path string to convert. + * @returns An array representation of pathString. + */ + static getPathArray(pathString) { + const pathArray = []; + let state = 'empty'; + let quote = 0; + let value = ''; + let escaped = false; + for (const c of pathString) { + const v = c.codePointAt(0); + switch (state) { + case 'empty': // Empty + case 'id-start': // Expecting identifier start + if (v === 0x5b) { // '[' + if (state === 'id-start') { + throw new Error(`Unexpected character: ${c}`); + } + state = 'open-bracket'; + } else if ( + (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] + (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] + v === 0x5f // '_' + ) { + state = 'id'; + value += c; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + case 'id': // Identifier + if ( + (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] + (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] + (v >= 0x30 && v <= 0x39) || // ['0', '9'] + v === 0x5f // '_' + ) { + value += c; + } else if (v === 0x5b) { // '[' + pathArray.push(value); + value = ''; + state = 'open-bracket'; + } else if (v === 0x2e) { // '.' + pathArray.push(value); + value = ''; + state = 'id-start'; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + case 'open-bracket': // Open bracket + if (v === 0x22 || v === 0x27) { // '"' or '\'' + quote = v; + state = 'string'; + } else if (v >= 0x30 && v <= 0x39) { // ['0', '9'] + state = 'number'; + value += c; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + case 'string': // Quoted string + if (escaped) { + value += c; + escaped = false; + } else if (v === 0x5c) { // '\\' + escaped = true; + } else if (v !== quote) { + value += c; + } else { + state = 'close-bracket'; + } + break; + case 'number': // Number + if (v >= 0x30 && v <= 0x39) { // ['0', '9'] + value += c; + } else if (v === 0x5d) { // ']' + pathArray.push(Number.parseInt(value, 10)); + value = ''; + state = 'next'; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + case 'close-bracket': // Expecting closing bracket after quoted string + if (v === 0x5d) { // ']' + pathArray.push(value); + value = ''; + state = 'next'; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + case 'next': // Expecting . or [ + if (v === 0x5b) { // '[' + state = 'open-bracket'; + } else if (v === 0x2e) { // '.' + state = 'id-start'; + } else { + throw new Error(`Unexpected character: ${c}`); + } + break; + } + } + switch (state) { + case 'empty': + case 'next': + break; + case 'id': + pathArray.push(value); + value = ''; + break; + default: + throw new Error('Path not terminated correctly'); + } + return pathArray; + } + + /** + * Checks whether an object or array has the specified property. + * @param object The object to test. + * @param property The property to check for existence. + * This value should be a string if the object is a non-array object. + * For arrays, it should be an integer. + * @returns true if the property exists, otherwise false. + */ + static hasProperty(object, property) { + switch (typeof property) { + case 'string': + return ( + typeof object === 'object' && + object !== null && + !Array.isArray(object) && + Object.prototype.hasOwnProperty.call(object, property) + ); + case 'number': + return ( + Array.isArray(object) && + property >= 0 && + property < object.length && + property === Math.floor(property) + ); + default: + return false; + } + } + + /** + * Checks whether a property is valid for the given object + * @param object The object to test. + * @param property The property to check for existence. + * @returns true if the property is correct for the given object type, otherwise false. + * For arrays, this means that the property should be a positive integer. + * For non-array objects, the property should be a string. + */ + static isValidPropertyType(object, property) { + switch (typeof property) { + case 'string': + return ( + typeof object === 'object' && + object !== null && + !Array.isArray(object) + ); + case 'number': + return ( + Array.isArray(object) && + property >= 0 && + property === Math.floor(property) + ); + default: + return false; + } + } +} diff --git a/ext/js/general/task-accumulator.js b/ext/js/general/task-accumulator.js new file mode 100644 index 00000000..82691b43 --- /dev/null +++ b/ext/js/general/task-accumulator.js @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class TaskAccumulator { + constructor(runTasks) { + this._deferPromise = null; + this._activePromise = null; + this._tasks = []; + this._tasksActive = []; + this._uniqueTasks = new Map(); + this._uniqueTasksActive = new Map(); + this._runTasksBind = this._runTasks.bind(this); + this._tasksCompleteBind = this._tasksComplete.bind(this); + this._runTasks = runTasks; + } + + enqueue(key, data) { + if (this._deferPromise === null) { + const promise = this._activePromise !== null ? this._activePromise : Promise.resolve(); + this._deferPromise = promise.then(this._runTasksBind); + } + + const task = {data, stale: false}; + if (key !== null) { + const activeTaskInfo = this._uniqueTasksActive.get(key); + if (typeof activeTaskInfo !== 'undefined') { + activeTaskInfo.stale = true; + } + + this._uniqueTasks.set(key, task); + } else { + this._tasks.push(task); + } + + return this._deferPromise; + } + + _runTasks() { + this._deferPromise = null; + + // Swap + [this._tasks, this._tasksActive] = [this._tasksActive, this._tasks]; + [this._uniqueTasks, this._uniqueTasksActive] = [this._uniqueTasksActive, this._uniqueTasks]; + + const promise = this._runTasksAsync(); + this._activePromise = promise.then(this._tasksCompleteBind); + return this._activePromise; + } + + async _runTasksAsync() { + try { + const allTasks = [ + ...this._tasksActive.map((taskInfo) => [null, taskInfo]), + ...this._uniqueTasksActive.entries() + ]; + await this._runTasks(allTasks); + } catch (e) { + yomichan.logError(e); + } + } + + _tasksComplete() { + this._tasksActive.length = 0; + this._uniqueTasksActive.clear(); + this._activePromise = null; + } +} diff --git a/ext/js/input/hotkey-handler.js b/ext/js/input/hotkey-handler.js new file mode 100644 index 00000000..423410b7 --- /dev/null +++ b/ext/js/input/hotkey-handler.js @@ -0,0 +1,267 @@ +/* + * 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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * api + */ + +/** + * Class which handles hotkey events and actions. + */ +class HotkeyHandler extends EventDispatcher { + /** + * Creates a new instance of the class. + */ + constructor() { + super(); + this._actions = new Map(); + this._hotkeys = new Map(); + this._hotkeyRegistrations = new Map(); + this._eventListeners = new EventListenerCollection(); + this._isPrepared = false; + this._hasEventListeners = false; + this._forwardFrameId = null; + } + + /** + * Gets the frame ID used for forwarding hotkeys. + */ + get forwardFrameId() { + return this._forwardFrameId; + } + + /** + * Sets the frame ID used for forwarding hotkeys. + */ + set forwardFrameId(value) { + this._forwardFrameId = value; + this._updateHotkeyRegistrations(); + } + + /** + * Begins listening to key press events in order to detect hotkeys. + */ + prepare() { + this._isPrepared = true; + this._updateEventHandlers(); + api.crossFrame.registerHandlers([ + ['hotkeyHandler.forwardHotkey', {async: false, handler: this._onMessageForwardHotkey.bind(this)}] + ]); + } + + /** + * Registers a set of actions that this hotkey handler supports. + * @param actions An array of `[name, handler]` entries, where `name` is a string and `handler` is a function. + */ + registerActions(actions) { + for (const [name, handler] of actions) { + this._actions.set(name, handler); + } + } + + /** + * Registers a set of hotkeys for a given scope. + * @param scope The scope that the hotkey definitions must be for in order to be activated. + * @param hotkeys An array of hotkey definitions of the format `{action, key, modifiers, scopes, enabled}`. + * * `action` - a string indicating which action to perform. + * * `key` - a keyboard key code indicating which key needs to be pressed. + * * `modifiers` - an array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`. + * * `scopes` - an array of scopes for which the hotkey is valid. If this array does not contain `this.scope`, the hotkey will not be registered. + * * `enabled` - a boolean indicating whether the hotkey is currently enabled. + */ + registerHotkeys(scope, hotkeys) { + let registrations = this._hotkeyRegistrations.get(scope); + if (typeof registrations === 'undefined') { + registrations = []; + this._hotkeyRegistrations.set(scope, registrations); + } + registrations.push(...hotkeys); + this._updateHotkeyRegistrations(); + } + + /** + * Removes all registered hotkeys for a given scope. + */ + clearHotkeys(scope) { + const registrations = this._hotkeyRegistrations.get(scope); + if (typeof registrations !== 'undefined') { + registrations.length = 0; + } + this._updateHotkeyRegistrations(); + } + + /** + * Assigns a set of hotkeys for a given scope. This is an optimized shorthand for calling + * `clearHotkeys`, then calling `registerHotkeys`. + * @see registerHotkeys for argument information. + */ + setHotkeys(scope, hotkeys) { + let registrations = this._hotkeyRegistrations.get(scope); + if (typeof registrations === 'undefined') { + registrations = []; + this._hotkeyRegistrations.set(scope, registrations); + } else { + registrations.length = 0; + } + registrations.push(...hotkeys); + this._updateHotkeyRegistrations(); + } + + /** + * Adds a single event listener to a specific event. + * @param eventName The string representing the event's name. + * @param callback The event listener callback to add. + */ + on(eventName, callback) { + const result = super.on(eventName, callback); + this._updateHasEventListeners(); + this._updateEventHandlers(); + return result; + } + + /** + * Removes a single event listener from a specific event. + * @param eventName The string representing the event's name. + * @param callback The event listener callback to add. + * @returns `true` if the callback was removed, `false` otherwise. + */ + off(eventName, callback) { + const result = super.off(eventName, callback); + this._updateHasEventListeners(); + this._updateEventHandlers(); + return result; + } + + /** + * Attempts to simulate an action for a given combination of key and modifiers. + * @param key A keyboard key code indicating which key needs to be pressed. + * @param modifiers An array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`. + * @returns `true` if an action was performed, `false` otherwise. + */ + simulate(key, modifiers) { + const hotkeyInfo = this._hotkeys.get(key); + return ( + typeof hotkeyInfo !== 'undefined' && + this._invokeHandlers(key, modifiers, hotkeyInfo, false) + ); + } + + // Message handlers + + _onMessageForwardHotkey({key, modifiers}) { + return this.simulate(key, modifiers); + } + + // Private + + _onKeyDown(e) { + const key = e.code; + const hotkeyInfo = this._hotkeys.get(key); + if (typeof hotkeyInfo !== 'undefined') { + const eventModifiers = DocumentUtil.getActiveModifiers(e); + const canForward = (this._forwardFrameId !== null); + if (this._invokeHandlers(key, eventModifiers, hotkeyInfo, canForward)) { + e.preventDefault(); + return; + } + } + this.trigger('keydownNonHotkey', e); + } + + _invokeHandlers(key, modifiers, hotkeyInfo, canForward) { + for (const {modifiers: handlerModifiers, action} of hotkeyInfo.handlers) { + if (!this._areSame(handlerModifiers, modifiers)) { continue; } + + const actionHandler = this._actions.get(action); + if (typeof actionHandler !== 'undefined') { + const result = actionHandler(); + if (result !== false) { + return true; + } + } + } + + if (canForward && hotkeyInfo.forward) { + this._forwardHotkey(key, modifiers); + return true; + } + + return false; + } + + _areSame(set, array) { + if (set.size !== array.length) { return false; } + for (const value of array) { + if (!set.has(value)) { + return false; + } + } + return true; + } + + _updateHotkeyRegistrations() { + if (this._hotkeys.size === 0 && this._hotkeyRegistrations.size === 0) { return; } + + const canForward = (this._forwardFrameId !== null); + this._hotkeys.clear(); + for (const [scope, registrations] of this._hotkeyRegistrations.entries()) { + for (const {action, key, modifiers, scopes, enabled} of registrations) { + if (!(enabled && key !== null && action !== '')) { continue; } + + const correctScope = scopes.includes(scope); + if (!correctScope && !canForward) { continue; } + + let hotkeyInfo = this._hotkeys.get(key); + if (typeof hotkeyInfo === 'undefined') { + hotkeyInfo = {handlers: [], forward: false}; + this._hotkeys.set(key, hotkeyInfo); + } + + if (correctScope) { + hotkeyInfo.handlers.push({modifiers: new Set(modifiers), action}); + } else { + hotkeyInfo.forward = true; + } + } + } + this._updateEventHandlers(); + } + + _updateHasEventListeners() { + this._hasEventListeners = this.hasListeners('keydownNonHotkey'); + } + + _updateEventHandlers() { + if (this._isPrepared && (this._hotkeys.size > 0 || this._hasEventListeners)) { + if (this._eventListeners.size !== 0) { return; } + this._eventListeners.addEventListener(document, 'keydown', this._onKeyDown.bind(this), false); + } else { + this._eventListeners.removeAllEventListeners(); + } + } + + async _forwardHotkey(key, modifiers) { + const frameId = this._forwardFrameId; + if (frameId === null) { throw new Error('No forwarding target'); } + try { + await api.crossFrame.invoke(frameId, 'hotkeyHandler.forwardHotkey', {key, modifiers}); + } catch (e) { + // NOP + } + } +} diff --git a/ext/js/input/hotkey-help-controller.js b/ext/js/input/hotkey-help-controller.js new file mode 100644 index 00000000..8137b50b --- /dev/null +++ b/ext/js/input/hotkey-help-controller.js @@ -0,0 +1,125 @@ +/* + * 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 <http://www.gnu.org/licenses/>. + */ + +/* global + * HotkeyUtil + * api + */ + +class HotkeyHelpController { + constructor() { + this._hotkeyUtil = new HotkeyUtil(); + this._localActionHotseys = new Map(); + this._globalActionHotkeys = new Map(); + this._replacementPattern = /\{0\}/g; + } + + async prepare() { + const {platform: {os}} = await api.getEnvironmentInfo(); + this._hotkeyUtil.os = os; + await this._setupGlobalCommands(this._globalActionHotkeys); + } + + setOptions(options) { + const hotkeys = options.inputs.hotkeys; + const hotkeyMap = this._localActionHotseys; + hotkeyMap.clear(); + for (const {enabled, action, key, modifiers} of hotkeys) { + if (!enabled || key === null || action === '' || hotkeyMap.has(action)) { continue; } + hotkeyMap.set(action, this._hotkeyUtil.getInputDisplayValue(key, modifiers)); + } + } + + setupNode(node) { + const globalPrexix = 'global:'; + const replacementPattern = this._replacementPattern; + for (const node2 of node.querySelectorAll('[data-hotkey]')) { + const data = JSON.parse(node2.dataset.hotkey); + let [action, attributes, values] = data; + if (!Array.isArray(attributes)) { attributes = [attributes]; } + const multipleValues = Array.isArray(values); + + const actionIsGlobal = action.startsWith(globalPrexix); + if (actionIsGlobal) { action = action.substring(globalPrexix.length); } + + const defaultAttributeValues = this._getDefaultAttributeValues(node2, data, attributes); + + const hotkey = (actionIsGlobal ? this._globalActionHotkeys : this._localActionHotseys).get(action); + + for (let i = 0, ii = attributes.length; i < ii; ++i) { + const attribute = attributes[i]; + let value = null; + if (typeof hotkey !== 'undefined') { + value = (multipleValues ? values[i] : values); + value = value.replace(replacementPattern, hotkey); + } else { + value = defaultAttributeValues[i]; + } + + if (typeof value === 'string') { + node2.setAttribute(attribute, value); + } else { + node2.removeAttribute(attribute); + } + } + } + } + + // Private + + async _setupGlobalCommands(commandMap) { + const commands = await new Promise((resolve, reject) => { + if (!(isObject(chrome.commands) && typeof chrome.commands.getAll === 'function')) { + resolve([]); + return; + } + + chrome.commands.getAll((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + + commandMap.clear(); + for (const {name, shortcut} of commands) { + if (shortcut.length === 0) { continue; } + const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut); + commandMap.set(name, this._hotkeyUtil.getInputDisplayValue(key, modifiers)); + } + return commandMap; + } + + _getDefaultAttributeValues(node, data, attributes) { + if (data.length > 3) { + return data[3]; + } + + const defaultAttributeValues = []; + for (let i = 0, ii = attributes.length; i < ii; ++i) { + const attribute = attributes[i]; + const value = node.hasAttribute(attribute) ? node.getAttribute(attribute) : null; + defaultAttributeValues.push(value); + } + data[3] = defaultAttributeValues; + node.dataset.hotkey = JSON.stringify(data); + return defaultAttributeValues; + } +} diff --git a/ext/js/input/hotkey-util.js b/ext/js/input/hotkey-util.js new file mode 100644 index 00000000..ea3daf97 --- /dev/null +++ b/ext/js/input/hotkey-util.js @@ -0,0 +1,297 @@ +/* + * 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 <http://www.gnu.org/licenses/>. + */ + +/** + * Utility class to help display hotkeys and convert to/from commands. + */ +class HotkeyUtil { + /** + * Creates a new instance. + */ + constructor(os=null) { + this._os = os; + this._inputSeparator = ' + '; + this._modifierKeyNames = new Map(); + this._mouseInputNamePattern = /^mouse(\d+)$/; + this._modifierPriorities = new Map([ + ['meta', -4], + ['ctrl', -3], + ['alt', -2], + ['shift', -1] + ]); + this._stringComparer = new Intl.Collator('en-US'); // Invariant locale + + this._updateModifierKeyNames(); + } + + /** + * Gets the operating system for this instance. + * The operating system is used to display system-localized modifier key names. + */ + get os() { + return this._os; + } + + /** + * Sets the operating system for this instance. + * @param value The value to assign. + * Valid values are: win, mac, linux, openbsd, cros, android. + */ + set os(value) { + if (this._os === value) { return; } + this._os = value; + this._updateModifierKeyNames(); + } + + /** + * Gets a display string for a key and a set of modifiers. + * @param key The key code string, or `null` for no key. + * @param modifiers An array of modifiers. + * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. + * @returns A user-friendly string for the combination of key and modifiers. + */ + getInputDisplayValue(key, modifiers) { + const separator = this._inputSeparator; + let displayValue = ''; + let first = true; + for (const modifier of modifiers) { + if (first) { + first = false; + } else { + displayValue += separator; + } + displayValue += this.getModifierDisplayValue(modifier); + } + if (typeof key === 'string') { + if (!first) { displayValue += separator; } + displayValue += this.getKeyDisplayValue(key); + } + return displayValue; + } + + /** + * Gets a display string for a single modifier. + * @param modifier A string representing a modifier. + * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. + * @returns A user-friendly string for the modifier. + */ + getModifierDisplayValue(modifier) { + const match = this._mouseInputNamePattern.exec(modifier); + if (match !== null) { + return `Mouse ${match[1]}`; + } + + const name = this._modifierKeyNames.get(modifier); + return (typeof name !== 'undefined' ? name : modifier); + } + + /** + * Gets a display string for a key. + * @param key The key code string, or `null` for no key. + * @returns A user-friendly string for the combination of key and modifiers, or `null` if key was already `null`. + */ + getKeyDisplayValue(key) { + if (typeof key === 'string' && key.length === 4 && key.startsWith('Key')) { + key = key.substring(3); + } + return key; + } + + /** + * Gets a display string for a single modifier. + * @param modifier A string representing a modifier. + * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. + * @returns `'mouse'` if the modifier represents a mouse button, `'key'` otherwise. + */ + getModifierType(modifier) { + return (this._mouseInputNamePattern.test(modifier) ? 'mouse' : 'key'); + } + + /** + * Converts an extension command string into a standard input. + * @param command An extension command string. + * @returns An object `{key, modifiers}`, where key is a string (or `null`) representing the key, and modifiers is an array of modifier keys. + */ + convertCommandToInput(command) { + let key = null; + const modifiers = new Set(); + if (typeof command === 'string' && command.length > 0) { + const parts = command.split('+'); + const ii = parts.length - 1; + key = this._convertCommandKeyToInputKey(parts[ii]); + for (let i = 0; i < ii; ++i) { + modifiers.add(this._convertCommandModifierToInputModifier(parts[i])); + } + } + return {key, modifiers: this.sortModifiers([...modifiers])}; + } + + /** + * Gets a command string for a specified input. + * @param key The key code string, or `null` for no key. + * @param modifiers An array of modifier keys. + * Valid values are: ctrl, alt, shift, meta. + * @returns An extension command string representing the input. + */ + convertInputToCommand(key, modifiers) { + const separator = '+'; + let command = ''; + let first = true; + for (const modifier of modifiers) { + if (first) { + first = false; + } else { + command += separator; + } + command += this._convertInputModifierToCommandModifier(modifier); + } + if (typeof key === 'string') { + if (!first) { command += separator; } + command += this._convertInputKeyToCommandKey(key); + } + return command; + } + + /** + * Sorts an array of modifiers. + * @param modifiers An array of modifiers. + * Valid values are: ctrl, alt, shift, meta. + * @returns A sorted array of modifiers. The array instance is the same as the input array. + */ + sortModifiers(modifiers) { + const pattern = this._mouseInputNamePattern; + const keyPriorities = this._modifierPriorities; + const stringComparer = this._stringComparer; + + const count = modifiers.length; + const modifierInfos = []; + for (let i = 0; i < count; ++i) { + const modifier = modifiers[i]; + const match = pattern.exec(modifier); + let info; + if (match !== null) { + info = [modifier, 1, Number.parseInt(match[1], 10), i]; + } else { + let priority = keyPriorities.get(modifier); + if (typeof priority === 'undefined') { priority = 0; } + info = [modifier, 0, priority, i]; + } + modifierInfos.push(info); + } + + modifierInfos.sort((a, b) => { + let i = a[1] - b[1]; + if (i !== 0) { return i; } + + i = a[2] - b[2]; + if (i !== 0) { return i; } + + i = stringComparer.compare(a[0], b[0]); + if (i !== 0) { return i; } + + i = a[3] - b[3]; + return i; + }); + + for (let i = 0; i < count; ++i) { + modifiers[i] = modifierInfos[i][0]; + } + + return modifiers; + } + + // Private + + _getModifierKeyNames(os) { + switch (os) { + case 'win': + return [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Windows'] + ]; + case 'mac': + return [ + ['alt', 'Opt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Cmd'] + ]; + case 'linux': + case 'openbsd': + case 'cros': + case 'android': + return [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Super'] + ]; + default: // 'unknown', etc + return [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Meta'] + ]; + } + } + + _updateModifierKeyNames() { + const map = this._modifierKeyNames; + map.clear(); + for (const [key, value] of this._getModifierKeyNames(this._os)) { + map.set(key, value); + } + } + + _convertCommandKeyToInputKey(key) { + if (key.length === 1) { + key = `Key${key}`; + } + return key; + } + + _convertCommandModifierToInputModifier(modifier) { + switch (modifier) { + case 'Ctrl': return (this._os === 'mac' ? 'meta' : 'ctrl'); + case 'Alt': return 'alt'; + case 'Shift': return 'shift'; + case 'MacCtrl': return 'ctrl'; + case 'Command': return 'meta'; + default: return modifier; + } + } + + _convertInputKeyToCommandKey(key) { + if (key.length === 4 && key.startsWith('Key')) { + key = key.substring(3); + } + return key; + } + + _convertInputModifierToCommandModifier(modifier) { + switch (modifier) { + case 'ctrl': return (this._os === 'mac' ? 'MacCtrl' : 'Ctrl'); + case 'alt': return 'Alt'; + case 'shift': return 'Shift'; + case 'meta': return 'Command'; + default: return modifier; + } + } +} diff --git a/ext/js/language/dictionary-data-util.js b/ext/js/language/dictionary-data-util.js new file mode 100644 index 00000000..70a51e89 --- /dev/null +++ b/ext/js/language/dictionary-data-util.js @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class DictionaryDataUtil { + static groupTermTags(definition) { + const {expressions} = definition; + const expressionsLength = expressions.length; + const uniqueCheck = (expressionsLength > 1); + const resultsMap = new Map(); + const results = []; + for (let i = 0; i < expressionsLength; ++i) { + const {termTags, expression, reading} = expressions[i]; + for (const tag of termTags) { + if (uniqueCheck) { + const {name, category, notes, dictionary} = tag; + const key = this._createMapKey([name, category, notes, dictionary]); + const index = resultsMap.get(key); + if (typeof index !== 'undefined') { + const existingItem = results[index]; + existingItem.expressions.push({index: i, expression, reading}); + continue; + } + resultsMap.set(key, results.length); + } + + const item = { + tag, + expressions: [{index: i, expression, reading}] + }; + results.push(item); + } + } + return results; + } + + static groupTermFrequencies(frequencies) { + const map1 = new Map(); + for (const {dictionary, expression, reading, hasReading, frequency} of frequencies) { + let map2 = map1.get(dictionary); + if (typeof map2 === 'undefined') { + map2 = new Map(); + map1.set(dictionary, map2); + } + + const readingKey = hasReading ? reading : null; + const key = this._createMapKey([expression, readingKey]); + let frequencyData = map2.get(key); + if (typeof frequencyData === 'undefined') { + frequencyData = {expression, reading: readingKey, frequencies: new Set()}; + map2.set(key, frequencyData); + } + + frequencyData.frequencies.add(frequency); + } + return this._createFrequencyGroupsFromMap(map1); + } + + static groupKanjiFrequencies(frequencies) { + const map1 = new Map(); + for (const {dictionary, character, frequency} of frequencies) { + let map2 = map1.get(dictionary); + if (typeof map2 === 'undefined') { + map2 = new Map(); + map1.set(dictionary, map2); + } + + let frequencyData = map2.get(character); + if (typeof frequencyData === 'undefined') { + frequencyData = {character, frequencies: new Set()}; + map2.set(character, frequencyData); + } + + frequencyData.frequencies.add(frequency); + } + return this._createFrequencyGroupsFromMap(map1); + } + + static getPitchAccentInfos(definition) { + if (definition.type === 'kanji') { return []; } + + const results = new Map(); + const allExpressions = new Set(); + const allReadings = new Set(); + + for (let {expression, reading, pitches: expressionPitches} of definition.expressions) { + if (reading.length === 0) { reading = expression; } + allExpressions.add(expression); + allReadings.add(reading); + + for (const {pitches, dictionary} of expressionPitches) { + let dictionaryResults = results.get(dictionary); + if (typeof dictionaryResults === 'undefined') { + dictionaryResults = []; + results.set(dictionary, dictionaryResults); + } + + for (const {position, tags} of pitches) { + let pitchAccentInfo = this._findExistingPitchAccentInfo(reading, position, tags, dictionaryResults); + if (pitchAccentInfo === null) { + pitchAccentInfo = {expressions: new Set(), reading, position, tags}; + dictionaryResults.push(pitchAccentInfo); + } + pitchAccentInfo.expressions.add(expression); + } + } + } + + const multipleReadings = (allReadings.size > 1); + for (const dictionaryResults of results.values()) { + for (const result of dictionaryResults) { + const exclusiveExpressions = []; + const exclusiveReadings = []; + const resultExpressions = result.expressions; + if (!this._areSetsEqual(resultExpressions, allExpressions)) { + exclusiveExpressions.push(...this._getSetIntersection(resultExpressions, allExpressions)); + } + if (multipleReadings) { + exclusiveReadings.push(result.reading); + } + result.expressions = [...resultExpressions]; + result.exclusiveExpressions = exclusiveExpressions; + result.exclusiveReadings = exclusiveReadings; + } + } + + const results2 = []; + for (const [dictionary, pitches] of results.entries()) { + results2.push({dictionary, pitches}); + } + return results2; + } + + // Private + + static _createFrequencyGroupsFromMap(map) { + const results = []; + for (const [dictionary, map2] of map.entries()) { + const frequencyDataArray = []; + for (const frequencyData of map2.values()) { + frequencyData.frequencies = [...frequencyData.frequencies]; + frequencyDataArray.push(frequencyData); + } + results.push({dictionary, frequencyData: frequencyDataArray}); + } + return results; + } + + static _findExistingPitchAccentInfo(reading, position, tags, pitchAccentInfoList) { + for (const pitchInfo of pitchAccentInfoList) { + if ( + pitchInfo.reading === reading && + pitchInfo.position === position && + this._areTagListsEqual(pitchInfo.tags, tags) + ) { + return pitchInfo; + } + } + return null; + } + + static _areTagListsEqual(tagList1, tagList2) { + const ii = tagList1.length; + if (tagList2.length !== ii) { return false; } + + for (let i = 0; i < ii; ++i) { + const tag1 = tagList1[i]; + const tag2 = tagList2[i]; + if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) { + return false; + } + } + + return true; + } + + static _areSetsEqual(set1, set2) { + if (set1.size !== set2.size) { + return false; + } + + for (const value of set1) { + if (!set2.has(value)) { + return false; + } + } + + return true; + } + + static _getSetIntersection(set1, set2) { + const result = []; + for (const value of set1) { + if (set2.has(value)) { + result.push(value); + } + } + return result; + } + + static _createMapKey(array) { + return JSON.stringify(array); + } +} diff --git a/ext/js/language/japanese-util.js b/ext/js/language/japanese-util.js new file mode 100644 index 00000000..c2ce9627 --- /dev/null +++ b/ext/js/language/japanese-util.js @@ -0,0 +1,610 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +const JapaneseUtil = (() => { + const ITERATION_MARK_CODE_POINT = 0x3005; + const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; + const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; + const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5; + const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6; + const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; + + const HIRAGANA_RANGE = [0x3040, 0x309f]; + const KATAKANA_RANGE = [0x30a0, 0x30ff]; + + const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096]; + const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6]; + + const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; + + const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf]; + const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef]; + const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f]; + const CJK_UNIFIED_IDEOGRAPHS_RANGES = [ + CJK_UNIFIED_IDEOGRAPHS_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE, + CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE + ]; + + // Japanese character ranges, roughly ordered in order of expected frequency + const JAPANESE_RANGES = [ + HIRAGANA_RANGE, + KATAKANA_RANGE, + + ...CJK_UNIFIED_IDEOGRAPHS_RANGES, + + [0xff66, 0xff9f], // Halfwidth katakana + + [0x30fb, 0x30fc], // Katakana punctuation + [0xff61, 0xff65], // Kana punctuation + [0x3000, 0x303f], // CJK punctuation + + [0xff10, 0xff19], // Fullwidth numbers + [0xff21, 0xff3a], // Fullwidth upper case Latin letters + [0xff41, 0xff5a], // Fullwidth lower case Latin letters + + [0xff01, 0xff0f], // Fullwidth punctuation 1 + [0xff1a, 0xff1f], // Fullwidth punctuation 2 + [0xff3b, 0xff3f], // Fullwidth punctuation 3 + [0xff5b, 0xff60], // Fullwidth punctuation 4 + [0xffe0, 0xffee] // Currency markers + ]; + + const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + + const HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] + ]); + + const VOWEL_TO_KANA_MAPPING = new Map([ + ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'], + ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'], + ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'], + ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'], + ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'], + ['', 'のノ'] + ]); + + const KANA_TO_VOWEL_MAPPING = (() => { + const map = new Map(); + for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) { + for (const character of characters) { + map.set(character, vowel); + } + } + return map; + })(); + + + function isCodePointInRange(codePoint, [min, max]) { + return (codePoint >= min && codePoint <= max); + } + + function isCodePointInRanges(codePoint, ranges) { + for (const [min, max] of ranges) { + if (codePoint >= min && codePoint <= max) { + return true; + } + } + return false; + } + + function getProlongedHiragana(previousCharacter) { + switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) { + case 'a': return 'あ'; + case 'i': return 'い'; + case 'u': return 'う'; + case 'e': return 'え'; + case 'o': return 'う'; + default: return null; + } + } + + + // eslint-disable-next-line no-shadow + class JapaneseUtil { + constructor(wanakana=null) { + this._wanakana = wanakana; + } + + // Character code testing functions + + isCodePointKanji(codePoint) { + return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES); + } + + isCodePointKana(codePoint) { + return isCodePointInRanges(codePoint, KANA_RANGES); + } + + isCodePointJapanese(codePoint) { + return isCodePointInRanges(codePoint, JAPANESE_RANGES); + } + + // String testing functions + + isStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) { + return false; + } + } + return true; + } + + isStringPartiallyJapanese(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) { + return true; + } + } + return false; + } + + // Mora functions + + isMoraPitchHigh(moraIndex, pitchAccentPosition) { + switch (pitchAccentPosition) { + case 0: return (moraIndex > 0); + case 1: return (moraIndex < 1); + default: return (moraIndex > 0 && moraIndex < pitchAccentPosition); + } + } + + getKanaMorae(text) { + const morae = []; + let i; + for (const c of text) { + if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { + morae[i - 1] += c; + } else { + morae.push(c); + } + } + return morae; + } + + // Conversion functions + + convertToKana(text) { + return this._getWanakana().toKana(text); + } + + convertKatakanaToHiragana(text) { + let result = ''; + const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = char.codePointAt(0); + if (codePoint === KATAKANA_SMALL_KA_CODE_POINT || codePoint === KATAKANA_SMALL_KE_CODE_POINT) { + // No change + } else if (codePoint === KANA_PROLONGED_SOUND_MARK_CODE_POINT) { + if (result.length > 0) { + const char2 = getProlongedHiragana(result[result.length - 1]); + if (char2 !== null) { char = char2; } + } + } else if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); + } + result += char; + } + return result; + } + + convertHiraganaToKatakana(text) { + let result = ''; + const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = char.codePointAt(0); + if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); + } + result += char; + } + return result; + } + + convertToRomaji(text) { + const wanakana = this._getWanakana(); + return wanakana.toRomaji(text); + } + + convertReading(expression, reading, readingMode) { + switch (readingMode) { + case 'hiragana': + return this.convertKatakanaToHiragana(reading); + case 'katakana': + return this.convertHiraganaToKatakana(reading); + case 'romaji': + if (reading) { + return this.convertToRomaji(reading); + } else { + if (this.isStringEntirelyKana(expression)) { + return this.convertToRomaji(expression); + } + } + return reading; + case 'none': + return ''; + default: + return reading; + } + } + + convertNumericToFullWidth(text) { + let result = ''; + for (const char of text) { + let c = char.codePointAt(0); + if (c >= 0x30 && c <= 0x39) { // ['0', '9'] + c += 0xff10 - 0x30; // 0xff10 = '0' full width + result += String.fromCodePoint(c); + } else { + result += char; + } + } + return result; + } + + convertHalfWidthKanaToFullWidth(text, sourceMap=null) { + let result = ''; + + // This function is safe to use charCodeAt instead of codePointAt, since all + // the relevant characters are represented with a single UTF-16 character code. + for (let i = 0, ii = text.length; i < ii; ++i) { + const c = text[i]; + const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c); + if (typeof mapping !== 'string') { + result += c; + continue; + } + + let index = 0; + switch (text.charCodeAt(i + 1)) { + case 0xff9e: // dakuten + index = 1; + break; + case 0xff9f: // handakuten + index = 2; + break; + } + + let c2 = mapping[index]; + if (index > 0) { + if (c2 === '-') { // invalid + index = 0; + c2 = mapping[0]; + } else { + ++i; + } + } + + if (sourceMap !== null && index > 0) { + sourceMap.combine(result.length, 1); + } + result += c2; + } + + return result; + } + + convertAlphabeticToKana(text, sourceMap=null) { + let part = ''; + let result = ''; + + for (const char of text) { + // Note: 0x61 is the character code for 'a' + let c = char.codePointAt(0); + if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] + c += (0x61 - 0x41); + } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] + // NOP; c += (0x61 - 0x61); + } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth + c += (0x61 - 0xff21); + } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth + c += (0x61 - 0xff41); + } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash + c = 0x2d; // '-' + } else { + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + part = ''; + } + result += char; + continue; + } + part += String.fromCodePoint(c); + } + + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + } + return result; + } + + // Furigana distribution + + distributeFurigana(expression, reading) { + if (!reading || reading === expression) { + // Same + return [this._createFuriganaSegment(expression, '')]; + } + + const groups = []; + let groupPre = null; + let isKanaPre = null; + for (const c of expression) { + const codePoint = c.codePointAt(0); + const isKana = !(this.isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT); + if (isKana === isKanaPre) { + groupPre.text += c; + } else { + groupPre = {isKana, text: c, textNormalized: null}; + groups.push(groupPre); + isKanaPre = isKana; + } + } + for (const group of groups) { + if (group.isKana) { + group.textNormalized = this.convertKatakanaToHiragana(group.text); + } + } + + const readingNormalized = this.convertKatakanaToHiragana(reading); + const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0); + if (segments !== null) { + return segments; + } + + // Fallback + return [this._createFuriganaSegment(expression, reading)]; + } + + distributeFuriganaInflected(expression, reading, source) { + let stemLength = 0; + const shortest = Math.min(source.length, expression.length); + const sourceHiragana = this.convertKatakanaToHiragana(source); + const expressionHiragana = this.convertKatakanaToHiragana(expression); + while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) { + ++stemLength; + } + const offset = source.length - stemLength; + + const stemExpression = source.substring(0, source.length - offset); + const stemReading = reading.substring( + 0, + offset === 0 ? reading.length : reading.length - expression.length + stemLength + ); + const result = this.distributeFurigana(stemExpression, stemReading); + + if (stemLength !== source.length) { + result.push(this._createFuriganaSegment(source.substring(stemLength), '')); + } + + return result; + } + + // Miscellaneous + + collapseEmphaticSequences(text, fullCollapse, sourceMap=null) { + let result = ''; + let collapseCodePoint = -1; + const hasSourceMap = (sourceMap !== null); + for (const char of text) { + const c = char.codePointAt(0); + if ( + c === HIRAGANA_SMALL_TSU_CODE_POINT || + c === KATAKANA_SMALL_TSU_CODE_POINT || + c === KANA_PROLONGED_SOUND_MARK_CODE_POINT + ) { + if (collapseCodePoint !== c) { + collapseCodePoint = c; + if (!fullCollapse) { + result += char; + continue; + } + } + } else { + collapseCodePoint = -1; + result += char; + continue; + } + + if (hasSourceMap) { + sourceMap.combine(Math.max(0, result.length - 1), 1); + } + } + return result; + } + + // Private + + _createFuriganaSegment(text, furigana) { + return {text, furigana}; + } + + _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) { + const groupCount = groups.length - groupsStart; + if (groupCount <= 0) { + return []; + } + + const group = groups[groupsStart]; + const {isKana, text} = group; + const textLength = text.length; + if (isKana) { + const {textNormalized} = group; + if (readingNormalized.startsWith(textNormalized)) { + const segments = this._segmentizeFurigana( + reading.substring(textLength), + readingNormalized.substring(textLength), + groups, + groupsStart + 1 + ); + if (segments !== null) { + const furigana = reading.startsWith(text) ? '' : reading.substring(0, textLength); + segments.unshift(this._createFuriganaSegment(text, furigana)); + return segments; + } + } + return null; + } else { + let result = null; + for (let i = reading.length; i >= textLength; --i) { + const segments = this._segmentizeFurigana( + reading.substring(i), + readingNormalized.substring(i), + groups, + groupsStart + 1 + ); + if (segments !== null) { + if (result !== null) { + // More than one way to segmentize the tail; mark as ambiguous + return null; + } + const furigana = reading.substring(0, i); + segments.unshift(this._createFuriganaSegment(text, furigana)); + result = segments; + } + // There is only one way to segmentize the last non-kana group + if (groupCount === 1) { + break; + } + } + return result; + } + } + + _getWanakana() { + const wanakana = this._wanakana; + if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); } + return wanakana; + } + + _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { + const wanakana = this._getWanakana(); + const result = wanakana.toHiragana(text); + + // Generate source mapping + if (sourceMap !== null) { + let i = 0; + let resultPos = 0; + const ii = text.length; + while (i < ii) { + // Find smallest matching substring + let iNext = i + 1; + let resultPosNext = result.length; + while (iNext < ii) { + const t = wanakana.toHiragana(text.substring(0, iNext)); + if (t === result.substring(0, t.length)) { + resultPosNext = t.length; + break; + } + ++iNext; + } + + // Merge characters + const removals = iNext - i - 1; + if (removals > 0) { + sourceMap.combine(sourceMapStart, removals); + } + ++sourceMapStart; + + // Empty elements + const additions = resultPosNext - resultPos - 1; + for (let j = 0; j < additions; ++j) { + sourceMap.insert(sourceMapStart, 0); + ++sourceMapStart; + } + + i = iNext; + resultPos = resultPosNext; + } + } + + return result; + } + } + + + return JapaneseUtil; +})(); diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js new file mode 100644 index 00000000..7672b69d --- /dev/null +++ b/ext/js/language/text-scanner.js @@ -0,0 +1,982 @@ +/* + * Copyright (C) 2019-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * api + */ + +class TextScanner extends EventDispatcher { + constructor({ + node, + documentUtil, + getSearchContext, + ignoreElements=null, + ignorePoint=null, + searchTerms=false, + searchKanji=false, + searchOnClick=false, + searchOnClickOnly=false + }) { + super(); + this._node = node; + this._documentUtil = documentUtil; + this._getSearchContext = getSearchContext; + this._ignoreElements = ignoreElements; + this._ignorePoint = ignorePoint; + this._searchTerms = searchTerms; + this._searchKanji = searchKanji; + this._searchOnClick = searchOnClick; + this._searchOnClickOnly = searchOnClickOnly; + + this._isPrepared = false; + this._includeSelector = null; + this._excludeSelector = null; + + this._inputInfoCurrent = null; + this._scanTimerPromise = null; + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._pendingLookup = false; + + this._deepContentScan = false; + this._selectText = false; + this._delay = 0; + this._touchInputEnabled = false; + this._pointerEventsEnabled = false; + this._scanLength = 1; + this._layoutAwareScan = false; + this._preventMiddleMouse = false; + this._sentenceScanExtent = 0; + this._sentenceTerminatorMap = new Map(); + this._sentenceForwardQuoteMap = new Map(); + this._sentenceBackwardQuoteMap = new Map(); + this._inputs = []; + + this._enabled = false; + this._enabledValue = false; + this._eventListeners = new EventListenerCollection(); + + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + this._penPointerPressed = false; + this._penPointerReleased = false; + this._pointerIdTypeMap = new Map(); + + this._canClearSelection = true; + } + + get canClearSelection() { + return this._canClearSelection; + } + + set canClearSelection(value) { + this._canClearSelection = value; + } + + get includeSelector() { + return this._includeSelector; + } + + set includeSelector(value) { + this._includeSelector = value; + } + + get excludeSelector() { + return this._excludeSelector; + } + + set excludeSelector(value) { + this._excludeSelector = value; + } + + prepare() { + this._isPrepared = true; + this.setEnabled(this._enabled); + } + + setEnabled(enabled) { + this._enabled = enabled; + + const value = enabled && this._isPrepared; + if (this._enabledValue === value) { return; } + + this._eventListeners.removeAllEventListeners(); + this._primaryTouchIdentifier = null; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + this._preventScroll = false; + this._penPointerPressed = false; + this._penPointerReleased = false; + this._pointerIdTypeMap.clear(); + + this._enabledValue = value; + + if (value) { + this._hookEvents(); + } else { + this.clearSelection(true); + } + } + + setOptions({ + inputs, + deepContentScan, + selectText, + delay, + touchInputEnabled, + pointerEventsEnabled, + scanLength, + layoutAwareScan, + preventMiddleMouse, + sentenceParsingOptions + }) { + if (Array.isArray(inputs)) { + this._inputs = inputs.map(({ + include, + exclude, + types, + options: { + searchTerms, + searchKanji, + scanOnTouchMove, + scanOnPenHover, + scanOnPenPress, + scanOnPenRelease, + preventTouchScrolling + } + }) => ({ + include: this._getInputArray(include), + exclude: this._getInputArray(exclude), + types: this._getInputTypeSet(types), + options: { + searchTerms, + searchKanji, + scanOnTouchMove, + scanOnPenHover, + scanOnPenPress, + scanOnPenRelease, + preventTouchScrolling + } + })); + } + if (typeof deepContentScan === 'boolean') { + this._deepContentScan = deepContentScan; + } + if (typeof selectText === 'boolean') { + this._selectText = selectText; + } + if (typeof delay === 'number') { + this._delay = delay; + } + if (typeof touchInputEnabled === 'boolean') { + this._touchInputEnabled = touchInputEnabled; + } + if (typeof pointerEventsEnabled === 'boolean') { + this._pointerEventsEnabled = pointerEventsEnabled; + } + if (typeof scanLength === 'number') { + this._scanLength = scanLength; + } + if (typeof layoutAwareScan === 'boolean') { + this._layoutAwareScan = layoutAwareScan; + } + if (typeof preventMiddleMouse === 'boolean') { + this._preventMiddleMouse = preventMiddleMouse; + } + if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) { + const {scanExtent, enableTerminationCharacters, terminationCharacters} = sentenceParsingOptions; + const hasTerminationCharacters = (typeof terminationCharacters === 'object' && Array.isArray(terminationCharacters)); + if (typeof scanExtent === 'number') { + this._sentenceScanExtent = sentenceParsingOptions.scanExtent; + } + if (typeof enableTerminationCharacters === 'boolean' || hasTerminationCharacters) { + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + sentenceTerminatorMap.clear(); + sentenceForwardQuoteMap.clear(); + sentenceBackwardQuoteMap.clear(); + if (enableTerminationCharacters !== false && hasTerminationCharacters) { + for (const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} of terminationCharacters) { + if (!enabled) { continue; } + if (character2 === null) { + sentenceTerminatorMap.set(character1, [includeCharacterAtStart, includeCharacterAtEnd]); + } else { + sentenceForwardQuoteMap.set(character1, [character2, includeCharacterAtStart]); + sentenceBackwardQuoteMap.set(character2, [character1, includeCharacterAtEnd]); + } + } + } + } + } + } + + getTextSourceContent(textSource, length, layoutAwareScan) { + const clonedTextSource = textSource.clone(); + + clonedTextSource.setEndOffset(length, layoutAwareScan); + + const includeSelector = this._includeSelector; + const excludeSelector = this._excludeSelector; + if (includeSelector !== null || excludeSelector !== null) { + this._constrainTextSource(clonedTextSource, includeSelector, excludeSelector, layoutAwareScan); + } + + return clonedTextSource.text(); + } + + hasSelection() { + return (this._textSourceCurrent !== null); + } + + clearSelection(passive) { + if (!this._canClearSelection) { return; } + if (this._textSourceCurrent !== null) { + if (this._textSourceCurrentSelected) { + this._textSourceCurrent.deselect(); + } + this._textSourceCurrent = null; + this._textSourceCurrentSelected = false; + this._inputInfoCurrent = null; + } + this.trigger('clearSelection', {passive}); + } + + getCurrentTextSource() { + return this._textSourceCurrent; + } + + setCurrentTextSource(textSource) { + this._textSourceCurrent = textSource; + if (this._selectText) { + this._textSourceCurrent.select(); + this._textSourceCurrentSelected = true; + } else { + this._textSourceCurrentSelected = false; + } + } + + async searchLast() { + if (this._textSourceCurrent !== null && this._inputInfoCurrent !== null) { + await this._search(this._textSourceCurrent, this._searchTerms, this._searchKanji, this._inputInfoCurrent); + return true; + } + return false; + } + + async search(textSource, inputDetail) { + const inputInfo = this._createInputInfo(null, 'script', 'script', true, [], [], inputDetail); + return await this._search(textSource, this._searchTerms, this._searchKanji, inputInfo); + } + + // Private + + _createOptionsContextForInput(baseOptionsContext, inputInfo) { + const optionsContext = clone(baseOptionsContext); + const {modifiers, modifierKeys} = inputInfo; + optionsContext.modifiers = [...modifiers]; + optionsContext.modifierKeys = [...modifierKeys]; + return optionsContext; + } + + async _search(textSource, searchTerms, searchKanji, inputInfo) { + let definitions = null; + let sentence = null; + let type = null; + let error = null; + let searched = false; + let optionsContext = null; + let detail = null; + + try { + if (this._textSourceCurrent !== null && this._textSourceCurrent.hasSameStart(textSource)) { + return null; + } + + ({optionsContext, detail} = await this._getSearchContext()); + optionsContext = this._createOptionsContextForInput(optionsContext, inputInfo); + + searched = true; + + const result = await this._findDefinitions(textSource, searchTerms, searchKanji, optionsContext); + if (result !== null) { + ({definitions, sentence, type} = result); + this._inputInfoCurrent = inputInfo; + this.setCurrentTextSource(textSource); + } + } catch (e) { + error = e; + } + + if (!searched) { return null; } + + const results = { + textScanner: this, + type, + definitions, + sentence, + inputInfo, + textSource, + optionsContext, + detail, + error + }; + this.trigger('searched', results); + return results; + } + + _onMouseOver(e) { + if (this._ignoreElements !== null && this._ignoreElements().includes(e.target)) { + this._scanTimerClear(); + } + } + + _onMouseMove(e) { + this._scanTimerClear(); + + const inputInfo = this._getMatchingInputGroupFromEvent('mouse', 'mouseMove', e); + if (inputInfo === null) { return; } + + this._searchAtFromMouseMove(e.clientX, e.clientY, inputInfo); + } + + _onMouseDown(e) { + if (this._preventNextMouseDown) { + this._preventNextMouseDown = false; + this._preventNextClick = true; + e.preventDefault(); + e.stopPropagation(); + return false; + } + + switch (e.button) { + case 0: // Primary + this._scanTimerClear(); + this.clearSelection(false); + break; + case 1: // Middle + if (this._preventMiddleMouse) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + break; + } + } + + _onMouseOut() { + this._scanTimerClear(); + } + + _onClick(e) { + if (this._searchOnClick) { + const modifiers = DocumentUtil.getActiveModifiersAndButtons(e); + const modifierKeys = DocumentUtil.getActiveModifiers(e); + const inputInfo = this._createInputInfo(null, 'mouse', 'click', false, modifiers, modifierKeys); + this._searchAt(e.clientX, e.clientY, inputInfo); + } + + if (this._preventNextClick) { + this._preventNextClick = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + _onAuxClick() { + this._preventNextContextMenu = false; + } + + _onContextMenu(e) { + if (this._preventNextContextMenu) { + this._preventNextContextMenu = false; + e.preventDefault(); + e.stopPropagation(); + return false; + } + } + + _onTouchStart(e) { + if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) { + return; + } + + const {clientX, clientY, identifier} = e.changedTouches[0]; + this._onPrimaryTouchStart(e, clientX, clientY, identifier); + } + + _onPrimaryTouchStart(e, x, y, identifier) { + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + + if (DocumentUtil.isPointInSelection(x, y, window.getSelection())) { + return; + } + + this._primaryTouchIdentifier = identifier; + + this._searchAtFromTouchStart(e, x, y); + } + + _onTouchEnd(e) { + if ( + this._primaryTouchIdentifier === null || + this._getTouch(e.changedTouches, this._primaryTouchIdentifier) === null + ) { + return; + } + + this._onPrimaryTouchEnd(); + } + + _onPrimaryTouchEnd() { + this._primaryTouchIdentifier = null; + this._preventScroll = false; + this._preventNextClick = false; + // Don't revert context menu and mouse down prevention, since these events can occur after the touch has ended. + // I.e. this._preventNextContextMenu and this._preventNextMouseDown should not be assigned to false. + } + + _onTouchCancel(e) { + this._onTouchEnd(e); + } + + _onTouchMove(e) { + if (!this._preventScroll || !e.cancelable || this._primaryTouchIdentifier === null) { + return; + } + + const primaryTouch = this._getTouch(e.changedTouches, this._primaryTouchIdentifier); + if (primaryTouch === null) { + return; + } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); + if (inputInfo === null) { return; } + + if (inputInfo.input.options.scanOnTouchMove) { + this._searchAt(primaryTouch.clientX, primaryTouch.clientY, inputInfo); + } + + e.preventDefault(); // Disable scroll + } + + _onPointerOver(e) { + const {pointerType, pointerId, isPrimary} = e; + if (pointerType === 'pen') { + this._pointerIdTypeMap.set(pointerId, pointerType); + } + + if (!isPrimary) { return; } + switch (pointerType) { + case 'mouse': return this._onMousePointerOver(e); + case 'touch': return this._onTouchPointerOver(e); + case 'pen': return this._onPenPointerOver(e); + } + } + + _onPointerDown(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerDown(e); + case 'touch': return this._onTouchPointerDown(e); + case 'pen': return this._onPenPointerDown(e); + } + } + + _onPointerMove(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerMove(e); + case 'touch': return this._onTouchPointerMove(e); + case 'pen': return this._onPenPointerMove(e); + } + } + + _onPointerUp(e) { + if (!e.isPrimary) { return; } + switch (this._getPointerEventType(e)) { + case 'mouse': return this._onMousePointerUp(e); + case 'touch': return this._onTouchPointerUp(e); + case 'pen': return this._onPenPointerUp(e); + } + } + + _onPointerCancel(e) { + this._pointerIdTypeMap.delete(e.pointerId); + if (!e.isPrimary) { return; } + switch (e.pointerType) { + case 'mouse': return this._onMousePointerCancel(e); + case 'touch': return this._onTouchPointerCancel(e); + case 'pen': return this._onPenPointerCancel(e); + } + } + + _onPointerOut(e) { + this._pointerIdTypeMap.delete(e.pointerId); + if (!e.isPrimary) { return; } + switch (e.pointerType) { + case 'mouse': return this._onMousePointerOut(e); + case 'touch': return this._onTouchPointerOut(e); + case 'pen': return this._onPenPointerOut(e); + } + } + + _onMousePointerOver(e) { + return this._onMouseOver(e); + } + + _onMousePointerDown(e) { + return this._onMouseDown(e); + } + + _onMousePointerMove(e) { + return this._onMouseMove(e); + } + + _onMousePointerUp() { + // NOP + } + + _onMousePointerCancel(e) { + return this._onMouseOut(e); + } + + _onMousePointerOut(e) { + return this._onMouseOut(e); + } + + _onTouchPointerOver() { + // NOP + } + + _onTouchPointerDown(e) { + const {clientX, clientY, pointerId} = e; + return this._onPrimaryTouchStart(e, clientX, clientY, pointerId); + } + + _onTouchPointerMove(e) { + if (!this._preventScroll || !e.cancelable) { + return; + } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); + if (inputInfo === null || !inputInfo.input.options.scanOnTouchMove) { return; } + + this._searchAt(e.clientX, e.clientY, inputInfo); + } + + _onTouchPointerUp() { + return this._onPrimaryTouchEnd(); + } + + _onTouchPointerCancel() { + return this._onPrimaryTouchEnd(); + } + + _onTouchPointerOut() { + // NOP + } + + _onTouchMovePreventScroll(e) { + if (!this._preventScroll) { return; } + + if (e.cancelable) { + e.preventDefault(); + } else { + this._preventScroll = false; + } + } + + _onPenPointerOver(e) { + this._penPointerPressed = false; + this._penPointerReleased = false; + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerOver', false); + } + + _onPenPointerDown(e) { + this._penPointerPressed = true; + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerDown', true); + } + + _onPenPointerMove(e) { + if (this._penPointerPressed && (!this._preventScroll || !e.cancelable)) { return; } + this._searchAtFromPen(e, e.clientX, e.clientY, 'pointerMove', true); + } + + _onPenPointerUp() { + this._penPointerPressed = false; + this._penPointerReleased = true; + this._preventScroll = false; + } + + _onPenPointerCancel(e) { + this._onPenPointerOut(e); + } + + _onPenPointerOut() { + this._penPointerPressed = false; + this._penPointerReleased = false; + this._preventScroll = false; + this._preventNextContextMenu = false; + this._preventNextMouseDown = false; + this._preventNextClick = false; + } + + async _scanTimerWait() { + const delay = this._delay; + const promise = promiseTimeout(delay, true); + this._scanTimerPromise = promise; + try { + return await promise; + } finally { + if (this._scanTimerPromise === promise) { + this._scanTimerPromise = null; + } + } + } + + _scanTimerClear() { + if (this._scanTimerPromise !== null) { + this._scanTimerPromise.resolve(false); + this._scanTimerPromise = null; + } + } + + _arePointerEventsSupported() { + return (this._pointerEventsEnabled && typeof PointerEvent !== 'undefined'); + } + + _hookEvents() { + let eventListenerInfos; + if (this._searchOnClickOnly) { + eventListenerInfos = this._getMouseClickOnlyEventListeners(); + } else if (this._arePointerEventsSupported()) { + eventListenerInfos = this._getPointerEventListeners(); + } else { + eventListenerInfos = this._getMouseEventListeners(); + if (this._touchInputEnabled) { + eventListenerInfos.push(...this._getTouchEventListeners()); + } + } + + for (const args of eventListenerInfos) { + this._eventListeners.addEventListener(...args); + } + } + + _getPointerEventListeners() { + return [ + [this._node, 'pointerover', this._onPointerOver.bind(this)], + [this._node, 'pointerdown', this._onPointerDown.bind(this)], + [this._node, 'pointermove', this._onPointerMove.bind(this)], + [this._node, 'pointerup', this._onPointerUp.bind(this)], + [this._node, 'pointercancel', this._onPointerCancel.bind(this)], + [this._node, 'pointerout', this._onPointerOut.bind(this)], + [this._node, 'touchmove', this._onTouchMovePreventScroll.bind(this), {passive: false}], + [this._node, 'mousedown', this._onMouseDown.bind(this)], + [this._node, 'click', this._onClick.bind(this)], + [this._node, 'auxclick', this._onAuxClick.bind(this)] + ]; + } + + _getMouseEventListeners() { + return [ + [this._node, 'mousedown', this._onMouseDown.bind(this)], + [this._node, 'mousemove', this._onMouseMove.bind(this)], + [this._node, 'mouseover', this._onMouseOver.bind(this)], + [this._node, 'mouseout', this._onMouseOut.bind(this)], + [this._node, 'click', this._onClick.bind(this)] + ]; + } + + _getMouseClickOnlyEventListeners() { + return [ + [this._node, 'click', this._onClick.bind(this)] + ]; + } + _getTouchEventListeners() { + return [ + [this._node, 'auxclick', this._onAuxClick.bind(this)], + [this._node, 'touchstart', this._onTouchStart.bind(this)], + [this._node, 'touchend', this._onTouchEnd.bind(this)], + [this._node, 'touchcancel', this._onTouchCancel.bind(this)], + [this._node, 'touchmove', this._onTouchMove.bind(this), {passive: false}], + [this._node, 'contextmenu', this._onContextMenu.bind(this)] + ]; + } + + _getTouch(touchList, identifier) { + for (const touch of touchList) { + if (touch.identifier === identifier) { + return touch; + } + } + return null; + } + + async _findDefinitions(textSource, searchTerms, searchKanji, optionsContext) { + if (textSource === null) { + return null; + } + if (searchTerms) { + const results = await this._findTerms(textSource, optionsContext); + if (results !== null) { return results; } + } + if (searchKanji) { + const results = await this._findKanji(textSource, optionsContext); + if (results !== null) { return results; } + } + return null; + } + + async _findTerms(textSource, optionsContext) { + const scanLength = this._scanLength; + const sentenceScanExtent = this._sentenceScanExtent; + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + const layoutAwareScan = this._layoutAwareScan; + const searchText = this.getTextSourceContent(textSource, scanLength, layoutAwareScan); + if (searchText.length === 0) { return null; } + + const {definitions, length} = await api.termsFind(searchText, {}, optionsContext); + if (definitions.length === 0) { return null; } + + textSource.setEndOffset(length, layoutAwareScan); + const sentence = this._documentUtil.extractSentence( + textSource, + layoutAwareScan, + sentenceScanExtent, + sentenceTerminatorMap, + sentenceForwardQuoteMap, + sentenceBackwardQuoteMap + ); + + return {definitions, sentence, type: 'terms'}; + } + + async _findKanji(textSource, optionsContext) { + const sentenceScanExtent = this._sentenceScanExtent; + const sentenceTerminatorMap = this._sentenceTerminatorMap; + const sentenceForwardQuoteMap = this._sentenceForwardQuoteMap; + const sentenceBackwardQuoteMap = this._sentenceBackwardQuoteMap; + const layoutAwareScan = this._layoutAwareScan; + const searchText = this.getTextSourceContent(textSource, 1, layoutAwareScan); + if (searchText.length === 0) { return null; } + + const definitions = await api.kanjiFind(searchText, optionsContext); + if (definitions.length === 0) { return null; } + + textSource.setEndOffset(1, layoutAwareScan); + const sentence = this._documentUtil.extractSentence( + textSource, + layoutAwareScan, + sentenceScanExtent, + sentenceTerminatorMap, + sentenceForwardQuoteMap, + sentenceBackwardQuoteMap + ); + + return {definitions, sentence, type: 'kanji'}; + } + + async _searchAt(x, y, inputInfo) { + if (this._pendingLookup) { return; } + + try { + const sourceInput = inputInfo.input; + let searchTerms = this._searchTerms; + let searchKanji = this._searchKanji; + if (sourceInput !== null) { + if (searchTerms && !sourceInput.options.searchTerms) { searchTerms = false; } + if (searchKanji && !sourceInput.options.searchKanji) { searchKanji = false; } + } + + this._pendingLookup = true; + this._scanTimerClear(); + + if (typeof this._ignorePoint === 'function' && await this._ignorePoint(x, y)) { + return; + } + + const textSource = this._documentUtil.getRangeFromPoint(x, y, this._deepContentScan); + try { + await this._search(textSource, searchTerms, searchKanji, inputInfo); + } finally { + if (textSource !== null) { + textSource.cleanup(); + } + } + } catch (e) { + yomichan.logError(e); + } finally { + this._pendingLookup = false; + } + } + + async _searchAtFromMouseMove(x, y, inputInfo) { + if (this._pendingLookup) { return; } + + if (inputInfo.passive) { + if (!await this._scanTimerWait()) { + // Aborted + return; + } + } + + await this._searchAt(x, y, inputInfo); + } + + async _searchAtFromTouchStart(e, x, y) { + if (this._pendingLookup) { return; } + + const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchStart', e); + if (inputInfo === null) { return; } + + const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; + const preventScroll = inputInfo.input.options.preventTouchScrolling; + + await this._searchAt(x, y, inputInfo); + + if ( + this._textSourceCurrent !== null && + !this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious) + ) { + this._preventScroll = preventScroll; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; + } + } + + async _searchAtFromPen(e, x, y, eventType, prevent) { + if (this._pendingLookup) { return; } + + const inputInfo = this._getMatchingInputGroupFromEvent('pen', eventType, e); + if (inputInfo === null) { return; } + + const {input: {options}} = inputInfo; + if ( + (!options.scanOnPenRelease && this._penPointerReleased) || + !(this._penPointerPressed ? options.scanOnPenPress : options.scanOnPenHover) + ) { + return; + } + + const preventScroll = inputInfo.input.options.preventTouchScrolling; + + await this._searchAt(x, y, inputInfo); + + if ( + prevent && + this._textSourceCurrent !== null + ) { + this._preventScroll = preventScroll; + this._preventNextContextMenu = true; + this._preventNextMouseDown = true; + this._preventNextClick = true; + } + } + + _getMatchingInputGroupFromEvent(pointerType, eventType, event) { + const modifiers = DocumentUtil.getActiveModifiersAndButtons(event); + const modifierKeys = DocumentUtil.getActiveModifiers(event); + return this._getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys); + } + + _getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys) { + let fallbackIndex = -1; + const modifiersSet = new Set(modifiers); + for (let i = 0, ii = this._inputs.length; i < ii; ++i) { + const input = this._inputs[i]; + const {include, exclude, types} = input; + if (!types.has(pointerType)) { continue; } + if (this._setHasAll(modifiersSet, include) && (exclude.length === 0 || !this._setHasAll(modifiersSet, exclude))) { + if (include.length > 0) { + return this._createInputInfo(input, pointerType, eventType, false, modifiers, modifierKeys); + } else if (fallbackIndex < 0) { + fallbackIndex = i; + } + } + } + + return ( + fallbackIndex >= 0 ? + this._createInputInfo(this._inputs[fallbackIndex], pointerType, eventType, true, modifiers, modifierKeys) : + null + ); + } + + _createInputInfo(input, pointerType, eventType, passive, modifiers, modifierKeys, detail) { + return {input, pointerType, eventType, passive, modifiers, modifierKeys, detail}; + } + + _setHasAll(set, values) { + for (const value of values) { + if (!set.has(value)) { + return false; + } + } + return true; + } + + _getInputArray(value) { + return ( + typeof value === 'string' ? + value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0) : + [] + ); + } + + _getInputTypeSet({mouse, touch, pen}) { + const set = new Set(); + if (mouse) { set.add('mouse'); } + if (touch) { set.add('touch'); } + if (pen) { set.add('pen'); } + return set; + } + + _getPointerEventType(e) { + // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events. + const cachedPointerType = this._pointerIdTypeMap.get(e.pointerId); + return (typeof cachedPointerType !== 'undefined' ? cachedPointerType : e.pointerType); + } + + _constrainTextSource(textSource, includeSelector, excludeSelector, layoutAwareScan) { + let length = textSource.text().length; + while (length > 0) { + const nodes = textSource.getNodesInRange(); + if ( + (includeSelector !== null && !DocumentUtil.everyNodeMatchesSelector(nodes, includeSelector)) || + (excludeSelector !== null && DocumentUtil.anyNodeMatchesSelector(nodes, excludeSelector)) + ) { + --length; + textSource.setEndOffset(length, layoutAwareScan); + } else { + break; + } + } + } +} diff --git a/ext/js/media/audio-system.js b/ext/js/media/audio-system.js new file mode 100644 index 00000000..cf63511f --- /dev/null +++ b/ext/js/media/audio-system.js @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2019-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * TextToSpeechAudio + */ + +class AudioSystem { + constructor() { + this._fallbackAudio = null; + } + + prepare() { + // speechSynthesis.getVoices() will not be populated unless some API call is made. + if (typeof speechSynthesis === 'undefined') { return; } + + const eventListeners = new EventListenerCollection(); + const onVoicesChanged = () => { eventListeners.removeAllEventListeners(); }; + eventListeners.addEventListener(speechSynthesis, 'voiceschanged', onVoicesChanged, false); + } + + getFallbackAudio() { + if (this._fallbackAudio === null) { + this._fallbackAudio = new Audio('/data/audio/button.mp3'); + } + return this._fallbackAudio; + } + + createAudio(url, source) { + return new Promise((resolve, reject) => { + const audio = new Audio(url); + audio.addEventListener('loadeddata', () => { + if (!this._isAudioValid(audio, source)) { + reject(new Error('Could not retrieve audio')); + } else { + resolve(audio); + } + }); + audio.addEventListener('error', () => reject(audio.error)); + }); + } + + createTextToSpeechAudio(text, voiceUri) { + const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); + if (voice === null) { + throw new Error('Invalid text-to-speech voice'); + } + return new TextToSpeechAudio(text, voice); + } + + // Private + + _isAudioValid(audio, source) { + switch (source) { + case 'jpod101': + { + const duration = audio.duration; + return ( + duration !== 5.694694 && // Invalid audio (Chrome) + duration !== 5.720718 // Invalid audio (Firefox) + ); + } + default: + return true; + } + } + + _getTextToSpeechVoiceFromVoiceUri(voiceUri) { + try { + for (const voice of speechSynthesis.getVoices()) { + if (voice.voiceURI === voiceUri) { + return voice; + } + } + } catch (e) { + // NOP + } + return null; + } +} diff --git a/ext/js/media/media-loader.js b/ext/js/media/media-loader.js new file mode 100644 index 00000000..5974e31a --- /dev/null +++ b/ext/js/media/media-loader.js @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * api + */ + +class MediaLoader { + constructor() { + this._token = {}; + this._mediaCache = new Map(); + this._loadMediaData = []; + } + + async loadMedia(path, dictionaryName, onLoad, onUnload) { + const token = this._token; + const data = {onUnload, loaded: false}; + + this._loadMediaData.push(data); + + const media = await this.getMedia(path, dictionaryName); + if (token !== this._token) { return; } + + onLoad(media.url); + data.loaded = true; + } + + unloadAll() { + for (const {onUnload, loaded} of this._loadMediaData) { + if (typeof onUnload === 'function') { + onUnload(loaded); + } + } + this._loadMediaData = []; + + for (const map of this._mediaCache.values()) { + for (const {url} of map.values()) { + if (url !== null) { + URL.revokeObjectURL(url); + } + } + } + this._mediaCache.clear(); + + this._token = {}; + } + + async getMedia(path, dictionaryName) { + let cachedData; + let dictionaryCache = this._mediaCache.get(dictionaryName); + if (typeof dictionaryCache !== 'undefined') { + cachedData = dictionaryCache.get(path); + } else { + dictionaryCache = new Map(); + this._mediaCache.set(dictionaryName, dictionaryCache); + } + + if (typeof cachedData === 'undefined') { + cachedData = { + promise: null, + data: null, + url: null + }; + dictionaryCache.set(path, cachedData); + cachedData.promise = this._getMediaData(path, dictionaryName, cachedData); + } + + return cachedData.promise; + } + + async _getMediaData(path, dictionaryName, cachedData) { + const token = this._token; + const data = (await api.getMedia([{path, dictionaryName}]))[0]; + if (token === this._token && data !== null) { + const contentArrayBuffer = this._base64ToArrayBuffer(data.content); + const blob = new Blob([contentArrayBuffer], {type: data.mediaType}); + const url = URL.createObjectURL(blob); + cachedData.data = data; + cachedData.url = url; + } + return cachedData; + } + + _base64ToArrayBuffer(content) { + const binaryContent = window.atob(content); + const length = binaryContent.length; + const array = new Uint8Array(length); + for (let i = 0; i < length; ++i) { + array[i] = binaryContent.charCodeAt(i); + } + return array.buffer; + } +} diff --git a/ext/js/media/text-to-speech-audio.js b/ext/js/media/text-to-speech-audio.js new file mode 100644 index 00000000..a32916f4 --- /dev/null +++ b/ext/js/media/text-to-speech-audio.js @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +class TextToSpeechAudio { + constructor(text, voice) { + this._text = text; + this._voice = voice; + this._utterance = null; + this._volume = 1; + } + + get currentTime() { + return 0; + } + + set currentTime(value) { + // NOP + } + + get volume() { + return this._volume; + } + + set volume(value) { + this._volume = value; + if (this._utterance !== null) { + this._utterance.volume = value; + } + } + + async play() { + try { + if (this._utterance === null) { + this._utterance = new SpeechSynthesisUtterance(typeof this._text === 'string' ? this._text : ''); + this._utterance.lang = 'ja-JP'; + this._utterance.volume = this._volume; + this._utterance.voice = this._voice; + } + + speechSynthesis.cancel(); + speechSynthesis.speak(this._utterance); + } catch (e) { + // NOP + } + } + + pause() { + try { + speechSynthesis.cancel(); + } catch (e) { + // NOP + } + } +} diff --git a/ext/js/script/dynamic-loader-sentinel.js b/ext/js/script/dynamic-loader-sentinel.js new file mode 100644 index 00000000..d91a7fef --- /dev/null +++ b/ext/js/script/dynamic-loader-sentinel.js @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +yomichan.trigger('dynamicLoaderSentinel', {script: document.currentScript}); diff --git a/ext/js/script/dynamic-loader.js b/ext/js/script/dynamic-loader.js new file mode 100644 index 00000000..0464f151 --- /dev/null +++ b/ext/js/script/dynamic-loader.js @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +/* global + * api + */ + +const dynamicLoader = (() => { + const injectedStylesheets = new Map(); + const injectedStylesheetsWithParent = new WeakMap(); + + function getInjectedStylesheet(id, parentNode) { + if (parentNode === null) { + return injectedStylesheets.get(id); + } + const map = injectedStylesheetsWithParent.get(parentNode); + return typeof map !== 'undefined' ? map.get(id) : void 0; + } + + function setInjectedStylesheet(id, parentNode, value) { + if (parentNode === null) { + injectedStylesheets.set(id, value); + return; + } + let map = injectedStylesheetsWithParent.get(parentNode); + if (typeof map === 'undefined') { + map = new Map(); + injectedStylesheetsWithParent.set(parentNode, map); + } + map.set(id, value); + } + + async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) { + if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) { + // Permissions error will occur if trying to use the WebExtension API to inject into an extension page + useWebExtensionApi = false; + } + + let styleNode = getInjectedStylesheet(id, parentNode); + if (typeof styleNode !== 'undefined') { + if (styleNode === null) { + // Previously injected via WebExtension API + throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); + } + } else { + styleNode = null; + } + + if (type === 'file-content') { + value = await api.getStylesheetContent(value); + type = 'code'; + useWebExtensionApi = false; + } + + if (useWebExtensionApi) { + // Inject via WebExtension API + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + + setInjectedStylesheet(id, parentNode, null); + await api.injectStylesheet(type, value); + return null; + } + + // Create node in document + let parentNode2 = parentNode; + if (parentNode2 === null) { + parentNode2 = document.head; + if (parentNode2 === null) { + throw new Error('No parent node'); + } + } + + // Create or reuse node + const isFile = (type === 'file'); + const tagName = isFile ? 'link' : 'style'; + if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + styleNode = document.createElement(tagName); + } + + // Update node style + if (isFile) { + styleNode.rel = 'stylesheet'; + styleNode.href = value; + } else { + styleNode.textContent = value; + } + + // Update parent + if (styleNode.parentNode !== parentNode2) { + parentNode2.appendChild(styleNode); + } + + // Add to map + setInjectedStylesheet(id, parentNode, styleNode); + return styleNode; + } + + function loadScripts(urls) { + return new Promise((resolve, reject) => { + const parent = document.body; + if (parent === null) { + reject(new Error('Missing body')); + return; + } + + for (const url of urls) { + const node = parent.querySelector(`script[src='${escapeCSSAttribute(url)}']`); + if (node !== null) { continue; } + + const script = document.createElement('script'); + script.async = false; + script.src = url; + parent.appendChild(script); + } + + loadScriptSentinel(parent, resolve, reject); + }); + } + + function loadScriptSentinel(parent, resolve, reject) { + const script = document.createElement('script'); + + const sentinelEventName = 'dynamicLoaderSentinel'; + const sentinelEventCallback = (e) => { + if (e.script !== script) { return; } + yomichan.off(sentinelEventName, sentinelEventCallback); + parent.removeChild(script); + resolve(); + }; + yomichan.on(sentinelEventName, sentinelEventCallback); + + try { + script.async = false; + script.src = '/js/script/dynamic-loader-sentinel.js'; + parent.appendChild(script); + } catch (e) { + yomichan.off(sentinelEventName, sentinelEventCallback); + reject(e); + } + } + + function escapeCSSAttribute(value) { + return value.replace(/['\\]/g, (character) => `\\${character}`); + } + + + return { + loadStyle, + loadScripts + }; +})(); diff --git a/ext/js/yomichan.js b/ext/js/yomichan.js new file mode 100644 index 00000000..61301e30 --- /dev/null +++ b/ext/js/yomichan.js @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2020-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 <https://www.gnu.org/licenses/>. + */ + +// Set up chrome alias if it's not available (Edge Legacy) +if ((() => { + let hasChrome = false; + let hasBrowser = false; + try { + hasChrome = (typeof chrome === 'object' && chrome !== null && typeof chrome.runtime !== 'undefined'); + } catch (e) { + // NOP + } + try { + hasBrowser = (typeof browser === 'object' && browser !== null && typeof browser.runtime !== 'undefined'); + } catch (e) { + // NOP + } + return (hasBrowser && !hasChrome); +})()) { + chrome = browser; +} + +const yomichan = (() => { + class Yomichan extends EventDispatcher { + constructor() { + super(); + + this._extensionName = 'Yomichan'; + try { + const manifest = chrome.runtime.getManifest(); + this._extensionName = `${manifest.name} v${manifest.version}`; + } catch (e) { + // NOP + } + + this._isExtensionUnloaded = false; + this._isTriggeringExtensionUnloaded = false; + this._isReady = false; + + const {promise, resolve} = deferPromise(); + this._isBackendReadyPromise = promise; + this._isBackendReadyPromiseResolve = resolve; + + this._messageHandlers = new Map([ + ['isReady', {async: false, handler: this._onMessageIsReady.bind(this)}], + ['backendReady', {async: false, handler: this._onMessageBackendReady.bind(this)}], + ['getUrl', {async: false, handler: this._onMessageGetUrl.bind(this)}], + ['optionsUpdated', {async: false, handler: this._onMessageOptionsUpdated.bind(this)}], + ['databaseUpdated', {async: false, handler: this._onMessageDatabaseUpdated.bind(this)}], + ['zoomChanged', {async: false, handler: this._onMessageZoomChanged.bind(this)}] + ]); + } + + // Public + + get isExtensionUnloaded() { + return this._isExtensionUnloaded; + } + + prepare() { + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + } + + backendReady() { + this.sendMessage({action: 'requestBackendReadySignal'}); + return this._isBackendReadyPromise; + } + + ready() { + this._isReady = true; + this.sendMessage({action: 'yomichanReady'}); + } + + isExtensionUrl(url) { + try { + return url.startsWith(chrome.runtime.getURL('/')); + } catch (e) { + return false; + } + } + + getTemporaryListenerResult(eventHandler, userCallback, timeout=null) { + if (!( + typeof eventHandler.addListener === 'function' && + typeof eventHandler.removeListener === 'function' + )) { + throw new Error('Event handler type not supported'); + } + + return new Promise((resolve, reject) => { + const runtimeMessageCallback = ({action, params}, sender, sendResponse) => { + let timeoutId = null; + if (timeout !== null) { + timeoutId = setTimeout(() => { + timeoutId = null; + eventHandler.removeListener(runtimeMessageCallback); + reject(new Error(`Listener timed out in ${timeout} ms`)); + }, timeout); + } + + const cleanupResolve = (value) => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + eventHandler.removeListener(runtimeMessageCallback); + sendResponse(); + resolve(value); + }; + + userCallback({action, params}, {resolve: cleanupResolve, sender}); + }; + + eventHandler.addListener(runtimeMessageCallback); + }); + } + + logWarning(error) { + this.log(error, 'warn'); + } + + logError(error) { + this.log(error, 'error'); + } + + log(error, level, context=null) { + if (!isObject(context)) { + context = this._getLogContext(); + } + + let errorString; + try { + errorString = error.toString(); + if (/^\[object \w+\]$/.test(errorString)) { + errorString = JSON.stringify(error); + } + } catch (e) { + errorString = `${error}`; + } + + let errorStack; + try { + errorStack = (typeof error.stack === 'string' ? error.stack.trimRight() : ''); + } catch (e) { + errorStack = ''; + } + + let errorData; + try { + errorData = error.data; + } catch (e) { + // NOP + } + + if (errorStack.startsWith(errorString)) { + errorString = errorStack; + } else if (errorStack.length > 0) { + errorString += `\n${errorStack}`; + } + + let message = `${this._extensionName} has encountered a problem.`; + message += `\nOriginating URL: ${context.url}\n`; + message += errorString; + if (typeof errorData !== 'undefined') { + message += `\nData: ${JSON.stringify(errorData, null, 4)}`; + } + message += '\n\nIssues can be reported at https://github.com/FooSoft/yomichan/issues'; + + switch (level) { + case 'info': console.info(message); break; + case 'debug': console.debug(message); break; + case 'warn': console.warn(message); break; + case 'error': console.error(message); break; + default: console.log(message); break; + } + + this.trigger('log', {error, level, context}); + } + + sendMessage(...args) { + try { + return chrome.runtime.sendMessage(...args); + } catch (e) { + this.triggerExtensionUnloaded(); + throw e; + } + } + + connect(...args) { + try { + return chrome.runtime.connect(...args); + } catch (e) { + this.triggerExtensionUnloaded(); + throw e; + } + } + + getMessageResponseResult(response) { + let error = chrome.runtime.lastError; + if (error) { + throw new Error(error.message); + } + if (!isObject(response)) { + throw new Error('Tab did not respond'); + } + error = response.error; + if (error) { + throw deserializeError(error); + } + return response.result; + } + + invokeMessageHandler({handler, async}, params, callback, ...extraArgs) { + try { + let promiseOrResult = handler(params, ...extraArgs); + if (async === 'dynamic') { + ({async, result: promiseOrResult} = promiseOrResult); + } + if (async) { + promiseOrResult.then( + (result) => { callback({result}); }, + (error) => { callback({error: serializeError(error)}); } + ); + return true; + } else { + callback({result: promiseOrResult}); + return false; + } + } catch (error) { + callback({error: serializeError(error)}); + return false; + } + } + + triggerExtensionUnloaded() { + this._isExtensionUnloaded = true; + if (this._isTriggeringExtensionUnloaded) { return; } + try { + this._isTriggeringExtensionUnloaded = true; + this.trigger('extensionUnloaded'); + } finally { + this._isTriggeringExtensionUnloaded = false; + } + } + + // Private + + _getUrl() { + return location.href; + } + + _getLogContext() { + return {url: this._getUrl()}; + } + + _onMessage({action, params}, sender, callback) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + return this.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onMessageIsReady() { + return this._isReady; + } + + _onMessageBackendReady() { + if (this._isBackendReadyPromiseResolve === null) { return; } + this._isBackendReadyPromiseResolve(); + this._isBackendReadyPromiseResolve = null; + } + + _onMessageGetUrl() { + return {url: this._getUrl()}; + } + + _onMessageOptionsUpdated({source}) { + this.trigger('optionsUpdated', {source}); + } + + _onMessageDatabaseUpdated({type, cause}) { + this.trigger('databaseUpdated', {type, cause}); + } + + _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { + this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor}); + } + } + + return new Yomichan(); +})(); + +yomichan.prepare(); |