diff options
-rw-r--r-- | ext/bg/background.html | 1 | ||||
-rw-r--r-- | ext/bg/js/api.js | 5 | ||||
-rw-r--r-- | ext/bg/js/backend-api-forwarder.js | 45 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 6 | ||||
-rw-r--r-- | ext/bg/js/dictionary.js | 11 | ||||
-rw-r--r-- | ext/bg/js/options.js | 5 | ||||
-rw-r--r-- | ext/bg/js/search-frontend.js | 51 | ||||
-rw-r--r-- | ext/bg/js/settings.js | 6 | ||||
-rw-r--r-- | ext/bg/js/translator.js | 17 | ||||
-rw-r--r-- | ext/bg/search.html | 1 | ||||
-rw-r--r-- | ext/bg/settings.html | 13 | ||||
-rw-r--r-- | ext/fg/float.html | 2 | ||||
-rw-r--r-- | ext/fg/js/api.js | 4 | ||||
-rw-r--r-- | ext/fg/js/document.js | 4 | ||||
-rw-r--r-- | ext/fg/js/float.js | 4 | ||||
-rw-r--r-- | ext/fg/js/frontend-api-receiver.js | 62 | ||||
-rw-r--r-- | ext/fg/js/frontend-api-sender.js | 127 | ||||
-rw-r--r-- | ext/fg/js/frontend.js | 64 | ||||
-rw-r--r-- | ext/fg/js/popup-nested.js | 51 | ||||
-rw-r--r-- | ext/fg/js/popup-proxy-host.js | 134 | ||||
-rw-r--r-- | ext/fg/js/popup-proxy.js | 113 | ||||
-rw-r--r-- | ext/fg/js/popup.js | 112 | ||||
-rw-r--r-- | ext/fg/js/source.js | 44 | ||||
-rw-r--r-- | ext/fg/js/util.js | 6 | ||||
-rw-r--r-- | ext/manifest.json | 2 |
25 files changed, 833 insertions, 57 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html index 5978f10f..90a56024 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -15,6 +15,7 @@ <script src="/bg/js/anki.js"></script> <script src="/bg/js/api.js"></script> <script src="/bg/js/audio.js"></script> + <script src="/bg/js/backend-api-forwarder.js"></script> <script src="/bg/js/database.js"></script> <script src="/bg/js/deinflector.js"></script> <script src="/bg/js/dictionary.js"></script> diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index 4b2bacd7..b8ef4362 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -205,3 +205,8 @@ function apiForward(action, params, sender) { chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response)); }); } + +function apiFrameInformationGet(sender) { + const frameId = sender.frameId; + return Promise.resolve({frameId}); +} diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js new file mode 100644 index 00000000..979afd16 --- /dev/null +++ b/ext/bg/js/backend-api-forwarder.js @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 BackendApiForwarder { + constructor() { + chrome.runtime.onConnect.addListener(this.onConnect.bind(this)); + } + + onConnect(port) { + if (port.name !== 'backend-api-forwarder') { return; } + + let tabId; + if (!( + port.sender && + port.sender.tab && + (typeof (tabId = port.sender.tab.id)) === 'number' + )) { + port.disconnect(); + return; + } + + const forwardPort = chrome.tabs.connect(tabId, {name: 'frontend-api-receiver'}); + + port.onMessage.addListener(message => forwardPort.postMessage(message)); + forwardPort.onMessage.addListener(message => port.postMessage(message)); + port.onDisconnect.addListener(() => forwardPort.disconnect()); + forwardPort.onDisconnect.addListener(() => port.disconnect()); + } +} diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index d95cb82d..39fd4288 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -22,6 +22,8 @@ class Backend { this.translator = new Translator(); this.anki = new AnkiNull(); this.options = null; + + this.apiForwarder = new BackendApiForwarder(); } async prepare() { @@ -125,6 +127,10 @@ class Backend { forward: ({action, params}) => { forward(apiForward(action, params, sender), callback); + }, + + frameInformationGet: () => { + forward(apiFrameInformationGet(sender), callback); } }; diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index 49afc368..498eafcd 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -140,16 +140,17 @@ function dictTermsCompressTags(definitions) { function dictTermsGroup(definitions, dictionaries) { const groups = {}; for (const definition of definitions) { - const key = [definition.source, definition.expression].concat(definition.reasons); + const key = [definition.source, definition.expression]; + key.push(...definition.reasons); if (definition.reading) { key.push(definition.reading); } - const group = groups[key]; - if (group) { - group.push(definition); + const keyString = key.toString(); + if (groups.hasOwnProperty(keyString)) { + groups[keyString].push(definition); } else { - groups[key] = [definition]; + groups[keyString] = [definition]; } } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 7d993987..df95aae9 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -219,7 +219,10 @@ function optionsSetDefaults(options) { delay: 20, length: 10, modifier: 'shift', - deepDomScan: false + deepDomScan: false, + popupNestingMaxDepth: 0, + enableOnPopupExpressions: false, + enableOnSearchPage: true }, dictionaries: {}, diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js new file mode 100644 index 00000000..840a1ea8 --- /dev/null +++ b/ext/bg/js/search-frontend.js @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + + +async function searchFrontendSetup() { + const options = await apiOptionsGet(); + if (!options.scanning.enableOnSearchPage) { return; } + + const scriptSrcs = [ + '/fg/js/api.js', + '/fg/js/frontend-api-receiver.js', + '/fg/js/popup.js', + '/fg/js/util.js', + '/fg/js/popup-proxy-host.js', + '/fg/js/frontend.js' + ]; + for (const src of scriptSrcs) { + const script = document.createElement('script'); + script.async = false; + script.src = src; + document.body.appendChild(script); + } + + const styleSrcs = [ + '/fg/css/client.css' + ]; + for (const src of styleSrcs) { + const style = document.createElement('link'); + style.rel = 'stylesheet'; + style.type = 'text/css'; + style.href = src; + document.head.appendChild(style); + } +} + +searchFrontendSetup(); diff --git a/ext/bg/js/settings.js b/ext/bg/js/settings.js index f5d669b2..83f4528c 100644 --- a/ext/bg/js/settings.js +++ b/ext/bg/js/settings.js @@ -48,9 +48,12 @@ async function formRead() { optionsNew.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); optionsNew.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); optionsNew.scanning.deepDomScan = $('#deep-dom-scan').prop('checked'); + optionsNew.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked'); + optionsNew.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); optionsNew.scanning.delay = parseInt($('#scan-delay').val(), 10); optionsNew.scanning.length = parseInt($('#scan-length').val(), 10); optionsNew.scanning.modifier = $('#scan-modifier-key').val(); + optionsNew.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); optionsNew.anki.enable = $('#anki-enable').prop('checked'); optionsNew.anki.tags = $('#card-tags').val().split(/[,; ]+/); @@ -189,9 +192,12 @@ async function onReady() { $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); $('#auto-hide-results').prop('checked', options.scanning.autoHideResults); $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); + $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions); + $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); $('#scan-delay').val(options.scanning.delay); $('#scan-length').val(options.scanning.length); $('#scan-modifier-key').val(options.scanning.modifier); + $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); $('#dict-purge-link').click(utilAsync(onDictionaryPurge)); $('#dict-file').change(utilAsync(onDictionaryImport)); diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index c6af1f4e..c89b43ff 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -186,7 +186,7 @@ class Translator { let deinflections = await this.findTermDeinflections(text, titles, cache); const textHiragana = jpKatakanaToHiragana(text); if (text !== textHiragana) { - deinflections = deinflections.concat(await this.findTermDeinflections(textHiragana, titles, cache)); + deinflections.push(...await this.findTermDeinflections(textHiragana, titles, cache)); } let definitions = []; @@ -235,7 +235,7 @@ class Translator { let deinflections = []; for (let i = text.length; i > 0; --i) { const textSlice = text.slice(0, i); - deinflections = deinflections.concat(await this.deinflector.deinflect(textSlice, definer)); + deinflections.push(...await this.deinflector.deinflect(textSlice, definer)); } return deinflections; @@ -247,7 +247,7 @@ class Translator { const titles = Object.keys(dictionaries); for (const c of text) { if (!processed[c]) { - definitions = definitions.concat(await this.database.findKanji(c, titles)); + definitions.push(...await this.database.findKanji(c, titles)); processed[c] = true; } } @@ -277,7 +277,7 @@ class Translator { async buildTermFrequencies(definition, titles) { let terms = []; if (definition.expressions) { - terms = terms.concat(definition.expressions); + terms.push(...definition.expressions); } else { terms.push(definition); } @@ -299,7 +299,7 @@ class Translator { async expandTags(names, title) { const tags = []; for (const name of names) { - const base = name.split(':')[0]; + const base = Translator.getNameBase(name); const meta = await this.database.findTagForTitle(base, title); const tag = {name}; @@ -318,7 +318,7 @@ class Translator { async expandStats(items, title) { const stats = {}; for (const name in items) { - const base = name.split(':')[0]; + const base = Translator.getNameBase(name); const meta = await this.database.findTagForTitle(base, title); const group = stats[meta.category] = stats[meta.category] || []; @@ -346,4 +346,9 @@ class Translator { return stats; } + + static getNameBase(name) { + const pos = name.indexOf(':'); + return (pos >= 0 ? name.substr(0, pos) : name); + } } diff --git a/ext/bg/search.html b/ext/bg/search.html index 05c0daab..38c5a4e9 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -51,5 +51,6 @@ <script src="/mixed/js/japanese.js"></script> <script src="/bg/js/search.js"></script> + <script src="/bg/js/search-frontend.js"></script> </body> </html> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index cc140023..85b7ee5f 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -193,6 +193,14 @@ </div> <div class="checkbox options-advanced"> + <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of popup expressions</label> + </div> + + <div class="checkbox"> + <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label> + </div> + + <div class="checkbox options-advanced"> <label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label> </div> @@ -215,6 +223,11 @@ <option value="shift">Shift</option> </select> </div> + + <div class="form-group options-advanced"> + <label for="popup-nesting-max-depth">Maximum nested popup depth</label> + <input type="number" min="0" id="popup-nesting-max-depth" class="form-control"> + </div> </div> <div> diff --git a/ext/fg/float.html b/ext/fg/float.html index 0133e653..465db589 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -43,5 +43,7 @@ <script src="/mixed/js/display.js"></script> <script src="/fg/js/float.js"></script> + + <script src="/fg/js/popup-nested.js"></script> </body> </html> diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js index 99ad307c..6bcb0dbb 100644 --- a/ext/fg/js/api.js +++ b/ext/fg/js/api.js @@ -64,3 +64,7 @@ function apiScreenshotGet(options) { function apiForward(action, params) { return utilInvoke('forward', {action, params}); } + +function apiFrameInformationGet() { + return utilInvoke('frameInformationGet'); +} diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index bd876e5d..60b1b9bd 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -152,7 +152,7 @@ function docSentenceExtract(source, extent) { if (quoteStack.length > 0 && c === quoteStack[0]) { quoteStack.pop(); } else if (c in quotesBwd) { - quoteStack = [quotesBwd[c]].concat(quoteStack); + quoteStack.unshift(quotesBwd[c]); } } @@ -181,7 +181,7 @@ function docSentenceExtract(source, extent) { if (quoteStack.length > 0 && c === quoteStack[0]) { quoteStack.pop(); } else if (c in quotesFwd) { - quoteStack = [quotesFwd[c]].concat(quoteStack); + quoteStack.unshift(quotesFwd[c]); } } diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index c0ec8a15..3c521714 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -72,6 +72,10 @@ class DisplayFloat extends Display { if (css) { this.setStyle(css); } + }, + + popupNestedInitialize: ({id, depth, parentFrameId}) => { + popupNestedInitialize(id, depth, parentFrameId); } }; diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js new file mode 100644 index 00000000..687e5c3c --- /dev/null +++ b/ext/fg/js/frontend-api-receiver.js @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 FrontendApiReceiver { + constructor(source='', handlers={}) { + this.source = source; + this.handlers = handlers; + + chrome.runtime.onConnect.addListener(this.onConnect.bind(this)); + } + + onConnect(port) { + if (port.name !== 'frontend-api-receiver') { return; } + + port.onMessage.addListener(this.onMessage.bind(this, port)); + } + + onMessage(port, {id, action, params, target, senderId}) { + if ( + target !== this.source || + !this.handlers.hasOwnProperty(action) + ) { + return; + } + + this.sendAck(port, id, senderId); + + const handler = this.handlers[action]; + handler(params).then( + result => { + this.sendResult(port, id, senderId, {result}); + }, + e => { + const error = typeof e.toString === 'function' ? e.toString() : e; + this.sendResult(port, id, senderId, {error}); + }); + } + + sendAck(port, id, senderId) { + port.postMessage({type: 'ack', id, senderId}); + } + + sendResult(port, id, senderId, data) { + port.postMessage({type: 'result', id, senderId, data}); + } +} diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js new file mode 100644 index 00000000..a1cb02c4 --- /dev/null +++ b/ext/fg/js/frontend-api-sender.js @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 FrontendApiSender { + constructor() { + this.senderId = FrontendApiSender.generateId(16); + this.ackTimeout = 3000; // 3 seconds + this.responseTimeout = 10000; // 10 seconds + this.callbacks = {}; + this.disconnected = false; + this.nextId = 0; + + this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); + this.port.onDisconnect.addListener(this.onDisconnect.bind(this)); + this.port.onMessage.addListener(this.onMessage.bind(this)); + } + + invoke(action, params, target) { + if (this.disconnected) { + return Promise.reject('Disconnected'); + } + + const id = `${this.nextId}`; + ++this.nextId; + + return new Promise((resolve, reject) => { + const info = {id, resolve, reject, ack: false, timer: null}; + this.callbacks[id] = info; + info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout); + + this.port.postMessage({id, action, params, target, senderId: this.senderId}); + }); + } + + onMessage({type, id, data, senderId}) { + if (senderId !== this.senderId) { return; } + switch (type) { + case 'ack': + this.onAck(id); + break; + case 'result': + this.onResult(id, data); + break; + } + } + + onDisconnect() { + this.disconnected = true; + + const ids = Object.keys(this.callbacks); + for (const id of ids) { + this.onError(id, 'Disconnected'); + } + } + + onAck(id) { + if (!this.callbacks.hasOwnProperty(id)) { + console.warn(`ID ${id} not found for ack`); + return; + } + + const info = this.callbacks[id]; + if (info.ack) { + console.warn(`Request ${id} already ack'd`); + return; + } + + info.ack = true; + clearTimeout(info.timer); + info.timer = setTimeout(() => this.onError(id, 'Timeout (response)'), this.responseTimeout); + } + + onResult(id, data) { + if (!this.callbacks.hasOwnProperty(id)) { + console.warn(`ID ${id} not found`); + return; + } + + const info = this.callbacks[id]; + if (!info.ack) { + console.warn(`Request ${id} not ack'd`); + return; + } + + delete this.callbacks[id]; + clearTimeout(info.timer); + info.timer = null; + + if (typeof data.error === 'string') { + info.reject(data.error); + } else { + info.resolve(data.result); + } + } + + onError(id, reason) { + if (!this.callbacks.hasOwnProperty(id)) { return; } + const info = this.callbacks[id]; + delete this.callbacks[id]; + info.timer = null; + info.reject(reason); + } + + static generateId(length) { + let id = ''; + for (let i = 0; i < length; ++i) { + id += Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); + } + return id; + } +} diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 39403bbd..079a7ef2 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -18,14 +18,15 @@ class Frontend { - constructor() { - this.popup = new Popup(); + constructor(popup, ignoreNodes) { + this.popup = popup; this.popupTimer = null; this.mouseDownLeft = false; this.mouseDownMiddle = false; this.textSourceLast = null; this.pendingLookup = false; this.options = null; + this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null); this.primaryTouchIdentifier = null; this.contextMenuChecking = false; @@ -36,6 +37,17 @@ class Frontend { this.scrollPrevent = false; } + static create() { + const initializationData = window.frontendInitializationData; + const isNested = (initializationData !== null && typeof initializationData === 'object'); + const {id, parentFrameId, ignoreNodes} = isNested ? initializationData : {}; + + const popup = isNested ? new PopupProxy(id, parentFrameId) : PopupProxyHost.instance.createPopup(null); + const frontend = new Frontend(popup, ignoreNodes); + frontend.prepare(); + return frontend; + } + async prepare() { try { this.options = await apiOptionsGet(); @@ -44,6 +56,7 @@ class Frontend { window.addEventListener('mousedown', this.onMouseDown.bind(this)); window.addEventListener('mousemove', this.onMouseMove.bind(this)); window.addEventListener('mouseover', this.onMouseOver.bind(this)); + window.addEventListener('mouseout', this.onMouseOut.bind(this)); window.addEventListener('mouseup', this.onMouseUp.bind(this)); window.addEventListener('resize', this.onResize.bind(this)); @@ -137,6 +150,10 @@ class Frontend { } } + onMouseOut(e) { + this.popupTimerClear(); + } + onFrameMessage(e) { const handlers = { popupClose: () => { @@ -259,9 +276,8 @@ class Frontend { const handler = handlers[action]; if (handler) { handler(params); + callback(); } - - callback(); } onError(error) { @@ -281,7 +297,7 @@ class Frontend { } async searchAt(point, type) { - if (this.pendingLookup || this.popup.containsPoint(point)) { + if (this.pendingLookup || await this.popup.containsPoint(point)) { return; } @@ -324,9 +340,14 @@ class Frontend { } async searchTerms(textSource, focus) { - textSource.setEndOffset(this.options.scanning.length); + this.setTextSourceScanLength(textSource, this.options.scanning.length); - const {definitions, length} = await apiTermsFind(textSource.text()); + const searchText = textSource.text(); + if (searchText.length === 0) { + return; + } + + const {definitions, length} = await apiTermsFind(searchText); if (definitions.length === 0) { return false; } @@ -352,9 +373,14 @@ class Frontend { } async searchKanji(textSource, focus) { - textSource.setEndOffset(1); + this.setTextSourceScanLength(textSource, 1); - const definitions = await apiKanjiFind(textSource.text()); + const searchText = textSource.text(); + if (searchText.length === 0) { + return; + } + + const definitions = await apiKanjiFind(searchText); if (definitions.length === 0) { return false; } @@ -480,7 +506,23 @@ class Frontend { } return false; } + + setTextSourceScanLength(textSource, length) { + textSource.setEndOffset(length); + if (this.ignoreNodes === null || !textSource.range) { + return; + } + + length = textSource.text().length; + while (textSource.range && length > 0) { + const nodes = TextSourceRange.getNodesInRange(textSource.range); + if (!TextSourceRange.anyNodeMatchesSelector(nodes, this.ignoreNodes)) { + break; + } + --length; + textSource.setEndOffset(length); + } + } } -window.yomichan_frontend = new Frontend(); -window.yomichan_frontend.prepare(); +window.yomichan_frontend = Frontend.create(); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js new file mode 100644 index 00000000..e0376bb2 --- /dev/null +++ b/ext/fg/js/popup-nested.js @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + + +let popupNestedInitialized = false; + +async function popupNestedInitialize(id, depth, parentFrameId) { + if (popupNestedInitialized) { + return; + } + popupNestedInitialized = true; + + const options = await apiOptionsGet(); + const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth; + + if (!(typeof popupNestingMaxDepth === 'number' && typeof depth === 'number' && depth < popupNestingMaxDepth)) { + return; + } + + const ignoreNodes = options.scanning.enableOnPopupExpressions ? [] : [ '.expression', '.expression *' ]; + + window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes}; + + const scriptSrcs = [ + '/fg/js/frontend-api-sender.js', + '/fg/js/popup.js', + '/fg/js/popup-proxy.js', + '/fg/js/frontend.js' + ]; + for (const src of scriptSrcs) { + const script = document.createElement('script'); + script.async = false; + script.src = src; + document.body.appendChild(script); + } +} diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js new file mode 100644 index 00000000..fa61aeb4 --- /dev/null +++ b/ext/fg/js/popup-proxy-host.js @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 PopupProxyHost { + constructor() { + this.popups = {}; + this.nextId = 0; + this.apiReceiver = null; + this.frameIdPromise = null; + } + + static create() { + const popupProxyHost = new PopupProxyHost(); + popupProxyHost.prepare(); + return popupProxyHost; + } + + async prepare() { + this.frameIdPromise = apiFrameInformationGet(); + const {frameId} = await this.frameIdPromise; + if (typeof frameId !== 'number') { return; } + + this.apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, { + createNestedPopup: ({parentId}) => this.createNestedPopup(parentId), + show: ({id, elementRect, options}) => this.show(id, elementRect, options), + showOrphaned: ({id, elementRect, options}) => this.show(id, elementRect, options), + hide: ({id}) => this.hide(id), + setVisible: ({id, visible}) => this.setVisible(id, visible), + containsPoint: ({id, point}) => this.containsPoint(id, point), + termsShow: ({id, elementRect, definitions, options, context}) => this.termsShow(id, elementRect, definitions, options, context), + kanjiShow: ({id, elementRect, definitions, options, context}) => this.kanjiShow(id, elementRect, definitions, options, context), + clearAutoPlayTimer: ({id}) => this.clearAutoPlayTimer(id) + }); + } + + createPopup(parentId) { + const parent = (typeof parentId === 'string' && this.popups.hasOwnProperty(parentId) ? this.popups[parentId] : null); + const depth = (parent !== null ? parent.depth + 1 : 0); + const id = `${this.nextId}`; + ++this.nextId; + const popup = new Popup(id, depth, this.frameIdPromise); + if (parent !== null) { + popup.parent = parent; + parent.child = popup; + } + this.popups[id] = popup; + return popup; + } + + async createNestedPopup(parentId) { + return this.createPopup(parentId).id; + } + + getPopup(id) { + if (!this.popups.hasOwnProperty(id)) { + throw 'Invalid popup ID'; + } + + return this.popups[id]; + } + + jsonRectToDOMRect(popup, jsonRect) { + let x = jsonRect.x; + let y = jsonRect.y; + if (popup.parent !== null) { + const popupRect = popup.parent.container.getBoundingClientRect(); + x += popupRect.x; + y += popupRect.y; + } + return new DOMRect(x, y, jsonRect.width, jsonRect.height); + } + + async show(id, elementRect, options) { + const popup = this.getPopup(id); + elementRect = this.jsonRectToDOMRect(popup, elementRect); + return await popup.show(elementRect, options); + } + + async showOrphaned(id, elementRect, options) { + const popup = this.getPopup(id); + elementRect = this.jsonRectToDOMRect(popup, elementRect); + return await popup.showOrphaned(elementRect, options); + } + + async hide(id) { + const popup = this.getPopup(id); + return popup.hide(); + } + + async setVisible(id, visible) { + const popup = this.getPopup(id); + return popup.setVisible(visible); + } + + async containsPoint(id, point) { + const popup = this.getPopup(id); + return await popup.containsPoint(point); + } + + async termsShow(id, elementRect, definitions, options, context) { + const popup = this.getPopup(id); + elementRect = this.jsonRectToDOMRect(popup, elementRect); + return await popup.termsShow(elementRect, definitions, options, context); + } + + async kanjiShow(id, elementRect, definitions, options, context) { + const popup = this.getPopup(id); + elementRect = this.jsonRectToDOMRect(popup, elementRect); + return await popup.kanjiShow(elementRect, definitions, options, context); + } + + async clearAutoPlayTimer(id) { + const popup = this.getPopup(id); + return popup.clearAutoPlayTimer(); + } +} + +PopupProxyHost.instance = PopupProxyHost.create(); diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js new file mode 100644 index 00000000..f6295079 --- /dev/null +++ b/ext/fg/js/popup-proxy.js @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 PopupProxy { + constructor(parentId, parentFrameId) { + this.parentId = parentId; + this.parentFrameId = parentFrameId; + this.id = null; + this.idPromise = null; + this.parent = null; + this.child = null; + this.depth = 0; + + this.container = null; + + this.apiSender = new FrontendApiSender(); + } + + getPopupId() { + if (this.idPromise === null) { + this.idPromise = this.getPopupIdAsync(); + } + return this.idPromise; + } + + async getPopupIdAsync() { + const id = await this.invokeHostApi('createNestedPopup', {parentId: this.parentId}); + this.id = id; + return id; + } + + async show(elementRect, options) { + const id = await this.getPopupId(); + elementRect = PopupProxy.DOMRectToJson(elementRect); + return await this.invokeHostApi('show', {id, elementRect, options}); + } + + async showOrphaned(elementRect, options) { + const id = await this.getPopupId(); + elementRect = PopupProxy.DOMRectToJson(elementRect); + return await this.invokeHostApi('showOrphaned', {id, elementRect, options}); + } + + async hide() { + if (this.id === null) { + return; + } + return await this.invokeHostApi('hide', {id: this.id}); + } + + async setVisible(visible) { + const id = await this.getPopupId(); + return await this.invokeHostApi('setVisible', {id, visible}); + } + + async containsPoint(point) { + if (this.id === null) { + return false; + } + return await this.invokeHostApi('containsPoint', {id: this.id, point}); + } + + async termsShow(elementRect, definitions, options, context) { + const id = await this.getPopupId(); + elementRect = PopupProxy.DOMRectToJson(elementRect); + return await this.invokeHostApi('termsShow', {id, elementRect, definitions, options, context}); + } + + async kanjiShow(elementRect, definitions, options, context) { + const id = await this.getPopupId(); + elementRect = PopupProxy.DOMRectToJson(elementRect); + return await this.invokeHostApi('kanjiShow', {id, elementRect, definitions, options, context}); + } + + async clearAutoPlayTimer() { + if (this.id === null) { + return; + } + return await this.invokeHostApi('clearAutoPlayTimer', {id: this.id}); + } + + invokeHostApi(action, params={}) { + if (typeof this.parentFrameId !== 'number') { + return Promise.reject('Invalid frame'); + } + return this.apiSender.invoke(action, params, `popup-proxy-host#${this.parentFrameId}`); + } + + static DOMRectToJson(domRect) { + return { + x: domRect.x, + y: domRect.y, + width: domRect.width, + height: domRect.height + }; + } +} diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 86ce575d..1b15977b 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -18,7 +18,13 @@ class Popup { - constructor() { + constructor(id, depth, frameIdPromise) { + this.id = id; + this.depth = depth; + this.frameIdPromise = frameIdPromise; + this.frameId = null; + this.parent = null; + this.child = null; this.container = document.createElement('iframe'); this.container.id = 'yomichan-float'; this.container.addEventListener('mousedown', e => e.stopPropagation()); @@ -26,26 +32,46 @@ class Popup { this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html')); this.container.style.width = '0px'; this.container.style.height = '0px'; - this.injected = null; + this.injectPromise = null; + this.isInjected = false; } inject(options) { - if (!this.injected) { - this.injected = new Promise((resolve, reject) => { - this.container.addEventListener('load', () => { - this.invokeApi('setOptions', { - general: { - customPopupCss: options.general.customPopupCss - } - }); - resolve(); - }); - this.observeFullscreen(); - this.onFullscreenChanged(); - }); + if (this.injectPromise === null) { + this.injectPromise = this.createInjectPromise(options); + } + return this.injectPromise; + } + + async createInjectPromise(options) { + try { + const {frameId} = await this.frameIdPromise; + if (typeof frameId === 'number') { + this.frameId = frameId; + } + } catch (e) { + // NOP } - return this.injected; + return new Promise((resolve) => { + const parentFrameId = (typeof this.frameId === 'number' ? this.frameId : null); + this.container.addEventListener('load', () => { + this.invokeApi('popupNestedInitialize', { + id: this.id, + depth: this.depth, + parentFrameId + }); + this.invokeApi('setOptions', { + general: { + customPopupCss: options.general.customPopupCss + } + }); + resolve(); + }); + this.observeFullscreen(); + this.onFullscreenChanged(); + this.isInjected = true; + }); } async show(elementRect, writingMode, options) { @@ -77,6 +103,8 @@ class Popup { container.style.width = `${width}px`; container.style.height = `${height}px`; container.style.visibility = 'visible'; + + this.hideChildren(); } static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) { @@ -178,12 +206,28 @@ class Popup { } hide() { + this.hideChildren(); + this.hideContainer(); + this.focusParent(); + } + + hideChildren() { + // recursively hides all children + if (this.child && !this.child.isContainerHidden()) { + this.child.hide(); + } + } + + hideContainer() { this.container.style.visibility = 'hidden'; - this.container.blur(); + } + + isContainerHidden() { + return (this.container.style.visibility === 'hidden'); } isVisible() { - return this.injected && this.container.style.visibility !== 'hidden'; + return this.isInjected && this.container.style.visibility !== 'hidden'; } setVisible(visible) { @@ -194,19 +238,27 @@ class Popup { } } - containsPoint(point) { - if (!this.isVisible()) { - return false; + focusParent() { + if (this.parent && this.parent.container) { + // Chrome doesn't like focusing iframe without contentWindow. + this.parent.container.contentWindow.focus(); + } else { + // Firefox doesn't like focusing window without first blurring the iframe. + // this.container.contentWindow.blur() doesn't work on Firefox for some reason. + this.container.blur(); + // This is needed for Chrome. + window.focus(); } + } - const rect = this.container.getBoundingClientRect(); - const contained = - point.x >= rect.left && - point.y >= rect.top && - point.x < rect.right && - point.y < rect.bottom; - - return contained; + async containsPoint({x, y}) { + for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) { + const rect = popup.container.getBoundingClientRect(); + if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { + return true; + } + } + return false; } async termsShow(elementRect, writingMode, definitions, options, context) { @@ -220,7 +272,7 @@ class Popup { } clearAutoPlayTimer() { - if (this.injected) { + if (this.isInjected) { this.invokeApi('clearAutoPlayTimer'); } } diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 1febab7e..18a1a976 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -215,6 +215,50 @@ class TextSourceRange { const writingMode = style.writingMode; return typeof writingMode === 'string' ? writingMode : 'horizontal-tb'; } + + static getNodesInRange(range) { + const end = range.endContainer; + const nodes = []; + for (let node = range.startContainer; node !== null; node = TextSourceRange.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 (node === null) { break; } + + node = next; + } + } + return next; + } + + static anyNodeMatchesSelector(nodeList, selector) { + for (const node of nodeList) { + if (TextSourceRange.nodeMatchesSelector(node, selector)) { + return true; + } + } + return false; + } + + static nodeMatchesSelector(node, selector) { + for (; node !== null; node = node.parentNode) { + if (node.nodeType === Node.ELEMENT_NODE) { + return node.matches(selector); + } + } + return false; + } } diff --git a/ext/fg/js/util.js b/ext/fg/js/util.js index 7518beb5..dc99274e 100644 --- a/ext/fg/js/util.js +++ b/ext/fg/js/util.js @@ -24,9 +24,10 @@ function utilAsync(func) { } function utilInvoke(action, params={}) { + const data = {action, params}; return new Promise((resolve, reject) => { try { - chrome.runtime.sendMessage({action, params}, (response) => { + chrome.runtime.sendMessage(data, (response) => { utilCheckLastError(chrome.runtime.lastError); if (response !== null && typeof response === 'object') { if (response.error) { @@ -35,7 +36,8 @@ function utilInvoke(action, params={}) { resolve(response.result); } } else { - reject(`Unexpected response of type ${typeof response}`); + const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; + reject(`${message} (${JSON.stringify(data)})`); } }); } catch (e) { diff --git a/ext/manifest.json b/ext/manifest.json index 62eed6ec..06d13dd1 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -21,9 +21,11 @@ "mixed/js/extension.js", "fg/js/api.js", "fg/js/document.js", + "fg/js/frontend-api-receiver.js", "fg/js/popup.js", "fg/js/source.js", "fg/js/util.js", + "fg/js/popup-proxy-host.js", "fg/js/frontend.js" ], "css": ["fg/css/client.css"], |