Merge pull request #185 from toasted-nutbread/recursive-popups

Recursive popups
This commit is contained in:
siikamiika 2019-09-05 09:25:42 +03:00 committed by GitHub
commit 9cd0101b62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 814 additions and 44 deletions

View File

@ -15,6 +15,7 @@
<script src="/bg/js/anki.js"></script> <script src="/bg/js/anki.js"></script>
<script src="/bg/js/api.js"></script> <script src="/bg/js/api.js"></script>
<script src="/bg/js/audio.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/database.js"></script>
<script src="/bg/js/deinflector.js"></script> <script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script> <script src="/bg/js/dictionary.js"></script>

View File

@ -205,3 +205,8 @@ function apiForward(action, params, sender) {
chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response)); chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response));
}); });
} }
function apiFrameInformationGet(sender) {
const frameId = sender.frameId;
return Promise.resolve({frameId});
}

View File

@ -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());
}
}

View File

@ -22,6 +22,8 @@ class Backend {
this.translator = new Translator(); this.translator = new Translator();
this.anki = new AnkiNull(); this.anki = new AnkiNull();
this.options = null; this.options = null;
this.apiForwarder = new BackendApiForwarder();
} }
async prepare() { async prepare() {
@ -125,6 +127,10 @@ class Backend {
forward: ({action, params}) => { forward: ({action, params}) => {
forward(apiForward(action, params, sender), callback); forward(apiForward(action, params, sender), callback);
},
frameInformationGet: () => {
forward(apiFrameInformationGet(sender), callback);
} }
}; };

View File

@ -219,7 +219,10 @@ function optionsSetDefaults(options) {
delay: 20, delay: 20,
length: 10, length: 10,
modifier: 'shift', modifier: 'shift',
deepDomScan: false deepDomScan: false,
popupNestingMaxDepth: 0,
enableOnPopupExpressions: false,
enableOnSearchPage: true
}, },
dictionaries: {}, dictionaries: {},

View File

@ -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();

View File

@ -48,9 +48,12 @@ async function formRead() {
optionsNew.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); optionsNew.scanning.alphanumeric = $('#search-alphanumeric').prop('checked');
optionsNew.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); optionsNew.scanning.autoHideResults = $('#auto-hide-results').prop('checked');
optionsNew.scanning.deepDomScan = $('#deep-dom-scan').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.delay = parseInt($('#scan-delay').val(), 10);
optionsNew.scanning.length = parseInt($('#scan-length').val(), 10); optionsNew.scanning.length = parseInt($('#scan-length').val(), 10);
optionsNew.scanning.modifier = $('#scan-modifier-key').val(); 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.enable = $('#anki-enable').prop('checked');
optionsNew.anki.tags = $('#card-tags').val().split(/[,; ]+/); optionsNew.anki.tags = $('#card-tags').val().split(/[,; ]+/);
@ -189,9 +192,12 @@ async function onReady() {
$('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric);
$('#auto-hide-results').prop('checked', options.scanning.autoHideResults); $('#auto-hide-results').prop('checked', options.scanning.autoHideResults);
$('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); $('#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-delay').val(options.scanning.delay);
$('#scan-length').val(options.scanning.length); $('#scan-length').val(options.scanning.length);
$('#scan-modifier-key').val(options.scanning.modifier); $('#scan-modifier-key').val(options.scanning.modifier);
$('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth);
$('#dict-purge-link').click(utilAsync(onDictionaryPurge)); $('#dict-purge-link').click(utilAsync(onDictionaryPurge));
$('#dict-file').change(utilAsync(onDictionaryImport)); $('#dict-file').change(utilAsync(onDictionaryImport));

View File

@ -51,5 +51,6 @@
<script src="/mixed/js/japanese.js"></script> <script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/search.js"></script> <script src="/bg/js/search.js"></script>
<script src="/bg/js/search-frontend.js"></script>
</body> </body>
</html> </html>

View File

@ -192,6 +192,14 @@
<label><input type="checkbox" id="auto-hide-results"> Automatically hide results</label> <label><input type="checkbox" id="auto-hide-results"> Automatically hide results</label>
</div> </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"> <div class="checkbox options-advanced">
<label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label> <label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label>
</div> </div>
@ -215,6 +223,11 @@
<option value="shift">Shift</option> <option value="shift">Shift</option>
</select> </select>
</div> </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>
<div> <div>

View File

@ -43,5 +43,7 @@
<script src="/mixed/js/display.js"></script> <script src="/mixed/js/display.js"></script>
<script src="/fg/js/float.js"></script> <script src="/fg/js/float.js"></script>
<script src="/fg/js/popup-nested.js"></script>
</body> </body>
</html> </html>

View File

@ -64,3 +64,7 @@ function apiScreenshotGet(options) {
function apiForward(action, params) { function apiForward(action, params) {
return utilInvoke('forward', {action, params}); return utilInvoke('forward', {action, params});
} }
function apiFrameInformationGet() {
return utilInvoke('frameInformationGet');
}

View File

@ -72,6 +72,10 @@ class DisplayFloat extends Display {
if (css) { if (css) {
this.setStyle(css); this.setStyle(css);
} }
},
popupNestedInitialize: ({id, depth, parentFrameId}) => {
popupNestedInitialize(id, depth, parentFrameId);
} }
}; };

View File

@ -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});
}
}

View File

@ -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;
}
}

View File

@ -18,14 +18,15 @@
class Frontend { class Frontend {
constructor() { constructor(popup, ignoreNodes) {
this.popup = new Popup(); this.popup = popup;
this.popupTimer = null; this.popupTimer = null;
this.mouseDownLeft = false; this.mouseDownLeft = false;
this.mouseDownMiddle = false; this.mouseDownMiddle = false;
this.textSourceLast = null; this.textSourceLast = null;
this.pendingLookup = false; this.pendingLookup = false;
this.options = null; this.options = null;
this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
this.primaryTouchIdentifier = null; this.primaryTouchIdentifier = null;
this.contextMenuChecking = false; this.contextMenuChecking = false;
@ -36,6 +37,17 @@ class Frontend {
this.scrollPrevent = false; 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() { async prepare() {
try { try {
this.options = await apiOptionsGet(); this.options = await apiOptionsGet();
@ -44,6 +56,7 @@ class Frontend {
window.addEventListener('mousedown', this.onMouseDown.bind(this)); window.addEventListener('mousedown', this.onMouseDown.bind(this));
window.addEventListener('mousemove', this.onMouseMove.bind(this)); window.addEventListener('mousemove', this.onMouseMove.bind(this));
window.addEventListener('mouseover', this.onMouseOver.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('mouseup', this.onMouseUp.bind(this));
window.addEventListener('resize', this.onResize.bind(this)); window.addEventListener('resize', this.onResize.bind(this));
@ -137,6 +150,10 @@ class Frontend {
} }
} }
onMouseOut(e) {
this.popupTimerClear();
}
onFrameMessage(e) { onFrameMessage(e) {
const handlers = { const handlers = {
popupClose: () => { popupClose: () => {
@ -259,10 +276,9 @@ class Frontend {
const handler = handlers[action]; const handler = handlers[action];
if (handler) { if (handler) {
handler(params); handler(params);
}
callback(); callback();
} }
}
onError(error) { onError(error) {
console.log(error); console.log(error);
@ -281,7 +297,7 @@ class Frontend {
} }
async searchAt(point, type) { async searchAt(point, type) {
if (this.pendingLookup || this.popup.containsPoint(point)) { if (this.pendingLookup || await this.popup.containsPoint(point)) {
return; return;
} }
@ -324,9 +340,14 @@ class Frontend {
} }
async searchTerms(textSource, focus) { 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) { if (definitions.length === 0) {
return false; return false;
} }
@ -352,9 +373,14 @@ class Frontend {
} }
async searchKanji(textSource, focus) { 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) { if (definitions.length === 0) {
return false; return false;
} }
@ -480,7 +506,23 @@ class Frontend {
} }
return false; return false;
} }
setTextSourceScanLength(textSource, length) {
textSource.setEndOffset(length);
if (this.ignoreNodes === null || !textSource.range) {
return;
} }
window.yomichan_frontend = new Frontend(); length = textSource.text().length;
window.yomichan_frontend.prepare(); 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 = Frontend.create();

51
ext/fg/js/popup-nested.js Normal file
View File

@ -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);
}
}

View File

@ -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();

113
ext/fg/js/popup-proxy.js Normal file
View File

@ -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
};
}
}

View File

@ -18,7 +18,13 @@
class Popup { 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 = document.createElement('iframe');
this.container.id = 'yomichan-float'; this.container.id = 'yomichan-float';
this.container.addEventListener('mousedown', e => e.stopPropagation()); this.container.addEventListener('mousedown', e => e.stopPropagation());
@ -26,13 +32,35 @@ class Popup {
this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html')); this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html'));
this.container.style.width = '0px'; this.container.style.width = '0px';
this.container.style.height = '0px'; this.container.style.height = '0px';
this.injected = null; this.injectPromise = null;
this.isInjected = false;
} }
inject(options) { inject(options) {
if (!this.injected) { if (this.injectPromise === null) {
this.injected = new Promise((resolve, reject) => { 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 new Promise((resolve) => {
const parentFrameId = (typeof this.frameId === 'number' ? this.frameId : null);
this.container.addEventListener('load', () => { this.container.addEventListener('load', () => {
this.invokeApi('popupNestedInitialize', {
id: this.id,
depth: this.depth,
parentFrameId
});
this.invokeApi('setOptions', { this.invokeApi('setOptions', {
general: { general: {
customPopupCss: options.general.customPopupCss customPopupCss: options.general.customPopupCss
@ -42,12 +70,10 @@ class Popup {
}); });
this.observeFullscreen(); this.observeFullscreen();
this.onFullscreenChanged(); this.onFullscreenChanged();
this.isInjected = true;
}); });
} }
return this.injected;
}
async show(elementRect, writingMode, options) { async show(elementRect, writingMode, options) {
await this.inject(options); await this.inject(options);
@ -77,6 +103,8 @@ class Popup {
container.style.width = `${width}px`; container.style.width = `${width}px`;
container.style.height = `${height}px`; container.style.height = `${height}px`;
container.style.visibility = 'visible'; container.style.visibility = 'visible';
this.hideChildren();
} }
static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) { static getPositionForHorizontalText(elementRect, width, height, maxWidth, maxHeight, optionsGeneral) {
@ -178,12 +206,28 @@ class Popup {
} }
hide() { 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.style.visibility = 'hidden';
this.container.blur(); }
isContainerHidden() {
return (this.container.style.visibility === 'hidden');
} }
isVisible() { isVisible() {
return this.injected && this.container.style.visibility !== 'hidden'; return this.isInjected && this.container.style.visibility !== 'hidden';
} }
setVisible(visible) { setVisible(visible) {
@ -194,19 +238,27 @@ class Popup {
} }
} }
containsPoint(point) { focusParent() {
if (!this.isVisible()) { if (this.parent && this.parent.container) {
return false; // 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(); async containsPoint({x, y}) {
const contained = for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) {
point.x >= rect.left && const rect = popup.container.getBoundingClientRect();
point.y >= rect.top && if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
point.x < rect.right && return true;
point.y < rect.bottom; }
}
return contained; return false;
} }
async termsShow(elementRect, writingMode, definitions, options, context) { async termsShow(elementRect, writingMode, definitions, options, context) {
@ -220,7 +272,7 @@ class Popup {
} }
clearAutoPlayTimer() { clearAutoPlayTimer() {
if (this.injected) { if (this.isInjected) {
this.invokeApi('clearAutoPlayTimer'); this.invokeApi('clearAutoPlayTimer');
} }
} }

View File

@ -232,6 +232,50 @@ class TextSourceRange {
const writingMode = style.writingMode; const writingMode = style.writingMode;
return typeof writingMode === 'string' ? writingMode : 'horizontal-tb'; 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;
}
} }

View File

@ -24,9 +24,10 @@ function utilAsync(func) {
} }
function utilInvoke(action, params={}) { function utilInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
chrome.runtime.sendMessage({action, params}, (response) => { chrome.runtime.sendMessage(data, (response) => {
utilCheckLastError(chrome.runtime.lastError); utilCheckLastError(chrome.runtime.lastError);
if (response !== null && typeof response === 'object') { if (response !== null && typeof response === 'object') {
if (response.error) { if (response.error) {
@ -35,7 +36,8 @@ function utilInvoke(action, params={}) {
resolve(response.result); resolve(response.result);
} }
} else { } 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) { } catch (e) {

View File

@ -21,9 +21,11 @@
"mixed/js/extension.js", "mixed/js/extension.js",
"fg/js/api.js", "fg/js/api.js",
"fg/js/document.js", "fg/js/document.js",
"fg/js/frontend-api-receiver.js",
"fg/js/popup.js", "fg/js/popup.js",
"fg/js/source.js", "fg/js/source.js",
"fg/js/util.js", "fg/js/util.js",
"fg/js/popup-proxy-host.js",
"fg/js/frontend.js" "fg/js/frontend.js"
], ],
"css": ["fg/css/client.css"], "css": ["fg/css/client.css"],