diff --git a/src/calibre/gui2/tweak_book/preview.py b/src/calibre/gui2/tweak_book/preview.py index 59e0e8c58d..f84ea54a78 100644 --- a/src/calibre/gui2/tweak_book/preview.py +++ b/src/calibre/gui2/tweak_book/preview.py @@ -14,10 +14,9 @@ from functools import partial from threading import Thread from PyQt5.Qt import ( - QApplication, QBuffer, QByteArray, QFile, QIcon, QMenu, QObject, QSize, QTimer, - QToolBar, QUrl, QVBoxLayout, QWidget, pyqtSignal, pyqtSlot + QApplication, QBuffer, QByteArray, QIcon, QMenu, QSize, QTimer, + QToolBar, QUrl, QVBoxLayout, QWidget, pyqtSignal ) -from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtWebEngineCore import QWebEngineUrlSchemeHandler from PyQt5.QtWebEngineWidgets import ( QWebEnginePage, QWebEngineProfile, QWebEngineScript, QWebEngineView @@ -31,7 +30,7 @@ from calibre.ebooks.oeb.base import OEB_DOCS, XHTML_MIME, serialize from calibre.ebooks.oeb.polish.parsing import parse from calibre.gui2 import NO_URL_FORMATTING, error_dialog, open_url from calibre.gui2.tweak_book import TOP, actions, current_container, editors, tprefs -from calibre.gui2.webengine import create_script, insert_scripts, secure_webengine +from calibre.gui2.webengine import create_script, insert_scripts, secure_webengine, Bridge, from_js, to_js from calibre.gui2.widgets2 import HistoryLineEdit2 from calibre.utils.ipc.simple_worker import offload_worker from polyglot.builtins import unicode_type @@ -259,15 +258,8 @@ def create_profile(): compile_editor() js = P('editor.js', data=True, allow_user_override=False) cparser = P('csscolorparser.js', data=True, allow_user_override=False) - qwebchannel_js = QFile(':/qtwebchannel/qwebchannel.js') - if not qwebchannel_js.open(QBuffer.ReadOnly): - raise RuntimeError( - 'Failed to load qwebchannel.js with error: %s' % qwebchannel_js.errorString()) - qwebchannel_js = bytes(qwebchannel_js.readAll()).decode('utf-8') - qwebchannel_js += 'window.QWebChannel = QWebChannel;' insert_scripts(ans, - create_script('qwebchannel.js', qwebchannel_js), create_script('csscolorparser.js', cparser), create_script('editor.js', js), ) @@ -281,33 +273,17 @@ def create_profile(): return ans -class Bridge(QObject): +class PreviewBridge(Bridge): - sync_requested = pyqtSignal(object, object, object) - split_requested = pyqtSignal(object, object) - go_to_sourceline_address = pyqtSignal(int, 'QStringList') - go_to_anchor = pyqtSignal('QString') - set_split_mode = pyqtSignal(int) + request_sync = from_js(object, object, object) + request_split = from_js(object, object) + + go_to_sourceline_address = to_js() + go_to_anchor = to_js() + set_split_mode = to_js() def __init__(self, parent=None): - QObject.__init__(self, parent) - - @pyqtSlot('QString', 'QString', 'QJsonArray') - def request_sync(self, tag_name, href, sourceline_address): - address = [sourceline_address[0].toInt(), [x.toString() for x in sourceline_address[1].toArray()]] - try: - self.sync_requested.emit(tag_name, href, address) - except (TypeError, ValueError, OverflowError, AttributeError): - pass - - @pyqtSlot('QJsonArray', 'QJsonArray') - def request_split(self, loc, totals): - actions['split-in-preview'].setChecked(False) - loc, totals = [x.toInt() for x in loc], [x.toInt() for x in totals] - if not loc or not totals: - return error_dialog(self.view(), _('Invalid location'), - _('Cannot split on the body tag'), show=True) - self.split_requested.emit(loc, totals) + Bridge.__init__(self, parent) class WebPage(QWebEnginePage): @@ -315,10 +291,7 @@ class WebPage(QWebEnginePage): def __init__(self, parent): QWebEnginePage.__init__(self, create_profile(), parent) secure_webengine(self, for_viewer=True) - self.channel = c = QWebChannel(self) - self.bridge = Bridge(self) - self.setWebChannel(c, QWebEngineScript.ApplicationWorld) - c.registerObject('bridge', self.bridge) + self.bridge = PreviewBridge(self) def javaScriptConsoleMessage(self, level, msg, linenumber, source_id): prints('%s:%s: %s' % (source_id, linenumber, msg)) @@ -348,7 +321,8 @@ class WebPage(QWebEnginePage): self.bridge.go_to_sourceline_address.emit(lnum, tags) def split_mode(self, enabled): - self.bridge.set_split_mode.emit(1 if enabled else 0) + if self.bridge.ready: + self.bridge.set_split_mode.emit(1 if enabled else 0) class WebView(QWebEngineView): @@ -426,8 +400,8 @@ class Preview(QWidget): self.setLayout(l) l.setContentsMargins(0, 0, 0, 0) self.view = WebView(self) - self.view._page.bridge.sync_requested.connect(self.request_sync) - self.view._page.bridge.split_requested.connect(self.request_split) + self.view._page.bridge.request_sync.connect(self.request_sync) + self.view._page.bridge.request_split.connect(self.request_split) self.view._page.loadFinished.connect(self.load_finished) self.inspector = self.view.inspector l.addWidget(self.view) @@ -510,6 +484,10 @@ class Preview(QWidget): self.sync_requested.emit(self.current_name, lnum) def request_split(self, loc, totals): + actions['split-in-preview'].setChecked(False) + if not loc or not totals: + return error_dialog(self, _('Invalid location'), + _('Cannot split on the body tag'), show=True) if self.current_name: self.split_requested.emit(self.current_name, loc, totals) diff --git a/src/calibre/gui2/webengine.py b/src/calibre/gui2/webengine.py index 94dcb20158..a7edb4410f 100644 --- a/src/calibre/gui2/webengine.py +++ b/src/calibre/gui2/webengine.py @@ -4,7 +4,13 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from PyQt5.QtWebEngineWidgets import QWebEngineScript +import json + +from PyQt5.Qt import QObject, pyqtSignal +from PyQt5.QtWebEngineWidgets import QWebEngineScript, QWebEngineView + +from calibre import prints +from calibre.utils.rapydscript import special_title def secure_webengine(view_or_page_or_settings, for_viewer=False): @@ -40,3 +46,106 @@ def create_script(name, src, world=QWebEngineScript.ApplicationWorld, injection_ script.setInjectionPoint(injection_point) script.setRunsOnSubFrames(on_subframes) return script + + +from_js = pyqtSignal + + +class to_js(type('')): + + def __call__(self, *a): + prints('WARNING: Calling {}() before the javascript bridge is ready'.format(self.name)) + emit = __call__ + + +class to_js_bound(QObject): + + def __init__(self, bridge, name): + QObject.__init__(self, bridge) + self.name = name + + def __call__(self, *args): + self.parent().page.runJavaScript('if (window.python_comm) python_comm._from_python({}, {})'.format( + json.dumps(self.name), json.dumps(args)), QWebEngineScript.ApplicationWorld) + emit = __call__ + + +class Bridge(QObject): + + def __init__(self, page): + QObject.__init__(self, page) + self._signals = json.dumps(tuple({k for k, v in self.__class__.__dict__.iteritems() if isinstance(v, pyqtSignal)})) + self._signals_registered = False + page.titleChanged.connect(self._title_changed) + for k, v in self.__class__.__dict__.iteritems(): + if isinstance(v, to_js): + v.name = k + + @property + def page(self): + return self.parent() + + @property + def ready(self): + return self._signals_registered + + def _title_changed(self, title): + if title.startswith(special_title): + self._poll_for_messages() + + def _register_signals(self): + self._signals_registered = True + for k, v in self.__class__.__dict__.iteritems(): + if isinstance(v, to_js): + setattr(self, k, to_js_bound(self, k)) + self.page.runJavaScript('python_comm._register_signals(' + self._signals + ')', QWebEngineScript.ApplicationWorld) + + def _poll_for_messages(self): + self.page.runJavaScript('python_comm._poll()', QWebEngineScript.ApplicationWorld, self._dispatch_messages) + + def _dispatch_messages(self, messages): + try: + for msg in messages: + if isinstance(msg, dict): + mt = msg.get('type') + if mt == 'signal': + signal = getattr(self, msg['name'], None) + if signal is None: + prints('WARNING: No js-to-python signal named: ' + msg['name']) + else: + args = msg['args'] + if args: + signal.emit(*args) + else: + signal.emit() + elif mt == 'qt-ready': + self._register_signals() + except Exception: + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + from calibre.gui2 import Application + from calibre.gui2.tweak_book.preview import WebPage + from PyQt5.Qt import QMainWindow + app = Application([]) + view = QWebEngineView() + page = WebPage(view) + view.setPage(page) + w = QMainWindow() + w.setCentralWidget(view) + + class Test(Bridge): + s1 = from_js(object) + j1 = to_js() + t = Test(view.page()) + t.s1.connect(print) + w.show() + view.setHtml(''' +
hello
+ ''') + app.exec_() + del t + del page + del app diff --git a/src/calibre/utils/rapydscript.py b/src/calibre/utils/rapydscript.py index 82121cba96..f3c6ca0a53 100644 --- a/src/calibre/utils/rapydscript.py +++ b/src/calibre/utils/rapydscript.py @@ -26,6 +26,7 @@ from polyglot.builtins import itervalues, range, exec_path, raw_input, error_mes from polyglot.queue import Empty, Queue COMPILER_PATH = 'rapydscript/compiler.js.xz' +special_title = '__webengine_messages_pending__' def abspath(x): @@ -215,7 +216,7 @@ def compile_editor(): rapydscript_dir = os.path.join(base, 'src', 'pyj') fname = os.path.join(rapydscript_dir, 'editor.pyj') with lopen(fname, 'rb') as f: - js = compile_fast(f.read(), fname, js_version=6) + js = compile_fast(f.read(), fname, js_version=6).replace('__SPECIAL_TITLE__', special_title, 1) base = os.path.join(base, 'resources') atomic_write(base, 'editor.js', js) diff --git a/src/pyj/editor.pyj b/src/pyj/editor.pyj index 6b7fd4538c..adbee3c331 100644 --- a/src/pyj/editor.pyj +++ b/src/pyj/editor.pyj @@ -5,6 +5,9 @@ from __python__ import bound_methods, hash_literals from elementmaker import E +from qt import to_python, from_python + + def is_hidden(elem): while elem: if (elem.style and (elem.style.visibility is 'hidden' or elem.style.display is 'none')): @@ -243,142 +246,126 @@ def scroll_to_node(node): node.scrollIntoView() -class PreviewIntegration: +state = {'blocks_found': False, 'in_split_mode': False} - ### - # Namespace to expose all the functions used for integration with the Tweak - # Book Preview Panel. - ### - def __init__(self): - self.blocks_found = False - self.in_split_mode = False - if window is window.top: - setTimeout(self.connect_channel, 10) - document.body.addEventListener('click', self.onclick, True) - document.documentElement.appendChild(E.style( - type='text/css', - '[data-in-split-mode="1"] [data-is-block="1"]:hover { cursor: pointer !important; border-top: solid 5px green !important }' - )) - def go_to_line(self, lnum): - for node in document.querySelectorAll(f'[data-lnum="{lnum}"]'): - if is_hidden(node): - continue - scroll_to_node(node) +def go_to_line(lnum): + for node in document.querySelectorAll(f'[data-lnum="{lnum}"]'): + if is_hidden(node): + continue + scroll_to_node(node) + break + +@from_python +def go_to_sourceline_address(sourceline, tags): + nodes = document.querySelectorAll(f'[data-lnum="{sourceline}"]') + for index in range(nodes.length): + node = nodes[index] + if index >= tags.length or node.tagName.toLowerCase() is not tags[index]: break + if index == tags.length - 1 and not is_hidden(node): + return scroll_to_node(node) + go_to_line(sourceline) - def go_to_sourceline_address(self, sourceline, tags): - nodes = document.querySelectorAll(f'[data-lnum="{sourceline}"]') - for index in range(nodes.length): - node = nodes[index] - if index >= tags.length or node.tagName.toLowerCase() is not tags[index]: - break - if index == tags.length - 1 and not is_hidden(node): - return scroll_to_node(node) - self.go_to_line(sourceline) +def line_numbers(): + found_body = False + ans = v'[]' + for node in document.getElementsByTagName('*'): + if not found_body and node.tagName.toLowerCase() is "body": + found_body = True + if found_body: + ans.push(node.dataset.lnum) + return ans - def line_numbers(self): - found_body = False - ans = v'[]' - for node in document.getElementsByTagName('*'): - if not found_body and node.tagName.toLowerCase() is "body": - found_body = True - if found_body: - ans.push(node.dataset.lnum) - return ans +def find_blocks(): + if state.blocks_found: + return + for elem in document.body.getElementsByTagName('*'): + if is_block(elem) and not in_table(elem): + elem.setAttribute('data-is-block', '1') + state.blocks_found = True - def find_blocks(self): - if self.blocks_found: - return - for elem in document.body.getElementsByTagName('*'): - if is_block(elem) and not in_table(elem): - elem.setAttribute('data-is-block', '1') - self.blocks_found = True +@from_python +def set_split_mode(enabled): + state.in_split_mode = enabled + document.body.dataset.inSplitMode = '1' if enabled else '0' + if enabled: + find_blocks() - def set_split_mode(self, enabled): - self.in_split_mode = enabled - document.body.dataset.inSplitMode = '1' if enabled else '0' - if enabled: - self.find_blocks() +def report_split(node): + loc = v'[]' + totals = v'[]' + parent = find_containing_block(node) + while parent and parent.tagName.toLowerCase() is not 'body': + totals.push(parent.parentNode.children.length) + num = 0 + sibling = parent.previousElementSibling + while sibling: + num += 1 + sibling = sibling.previousElementSibling + loc.push(num) + parent = parent.parentNode + loc.reverse() + totals.reverse() + to_python.request_split(loc, totals) - def report_split(self, node): - loc = v'[]' - totals = v'[]' - parent = find_containing_block(node) - while parent and parent.tagName.toLowerCase() is not 'body': - totals.push(parent.parentNode.children.length) - num = 0 - sibling = parent.previousElementSibling - while sibling: - num += 1 - sibling = sibling.previousElementSibling - loc.push(num) - parent = parent.parentNode - loc.reverse() - totals.reverse() - self.bridge.request_split(loc, totals) +def onclick(event): + event.preventDefault() + if state.in_split_mode: + report_split(event.target) + else: + e = event.target + address = get_sourceline_address(e) + # Find the closest containing link, if any + href = tn = '' + while e and e is not document.body and e is not document and e is not document.documentElement and (tn is not 'a' or not href): + tn = e.tagName.toLowerCase() if e.tagName else '' + href = e.getAttribute('href') + e = e.parentNode + to_python.request_sync(tn, href, address) + return False - def connect_channel(self): - self.qwebchannel = new window.QWebChannel(window.qt.webChannelTransport, def(channel): - self.bridge = channel.objects.bridge - self.bridge.go_to_sourceline_address.connect(self.go_to_sourceline_address) - self.bridge.go_to_anchor.connect(self.go_to_anchor) - self.bridge.set_split_mode.connect(self.set_split_mode) - ) +@from_python +def go_to_anchor(anchor): + elem = document.getElementById(anchor) + if not elem: + elem = document.querySelector(f'[name="{anchor}"]') + if elem: + elem.scrollIntoView() + address = get_sourceline_address(elem) + to_python.request_sync('', '', address) - def onclick(self, event): - event.preventDefault() - if self.in_split_mode: - self.report_split(event.target) - else: - e = event.target - address = get_sourceline_address(e) - # Find the closest containing link, if any - href = tn = '' - while e and e is not document.body and e is not document and e is not document.documentElement and (tn is not 'a' or not href): - tn = e.tagName.toLowerCase() if e.tagName else '' - href = e.getAttribute('href') - e = e.parentNode - self.bridge.request_sync(tn, href, address) - return False +def live_css(sourceline, tags): + target = None + i = 0 + for node in document.querySelectorAll(f'[data-lnum="{sourceline}"]'): + tn = node.tagName.toLowerCase() if node.tagName else '' + if tn is not tags[i]: + return JSON.stringify(None) + i += 1 + target = node + if i >= tags.length: + break + all_properties = {} + ans = {'nodes':v'[]', 'computed_css':all_properties} + is_ancestor = False + while target and target.ownerDocument: + css = get_matched_css(target, is_ancestor, all_properties) + # We want to show the Matched CSS rules header even if no rules matched + if css.length > 0 or not is_ancestor: + tn = target.tagName.toLowerCase() if target.tagName else '' + ans.nodes.push({ + 'name': tn, + 'css': css, 'is_ancestor': is_ancestor, + 'sourceline': target.getAttribute('data-lnum') + }) + target = target.parentNode + is_ancestor = True + return JSON.stringify(ans) - def go_to_anchor(self, anchor): - elem = document.getElementById(anchor) - if not elem: - elem = document.querySelector(f'[name="{anchor}"]') - if elem: - elem.scrollIntoView() - address = get_sourceline_address(elem) - self.bridge.request_sync('', '', address) - - def live_css(self, sourceline, tags): - target = None - i = 0 - for node in document.querySelectorAll(f'[data-lnum="{sourceline}"]'): - tn = node.tagName.toLowerCase() if node.tagName else '' - if tn is not tags[i]: - return JSON.stringify(None) - i += 1 - target = node - if i >= tags.length: - break - all_properties = {} - ans = {'nodes':v'[]', 'computed_css':all_properties} - is_ancestor = False - while target and target.ownerDocument: - css = get_matched_css(target, is_ancestor, all_properties) - # We want to show the Matched CSS rules header even if no rules matched - if css.length > 0 or not is_ancestor: - tn = target.tagName.toLowerCase() if target.tagName else '' - ans.nodes.push({ - 'name': tn, - 'css': css, 'is_ancestor': is_ancestor, - 'sourceline': target.getAttribute('data-lnum') - }) - target = target.parentNode - is_ancestor = True - return JSON.stringify(ans) - - -calibre_preview_integration = PreviewIntegration() +document.body.addEventListener('click', onclick, True) +document.documentElement.appendChild(E.style( + type='text/css', + '[data-in-split-mode="1"] [data-is-block="1"]:hover { cursor: pointer !important; border-top: solid 5px green !important }' +)) diff --git a/src/pyj/qt.pyj b/src/pyj/qt.pyj new file mode 100644 index 0000000000..be3a0191e1 --- /dev/null +++ b/src/pyj/qt.pyj @@ -0,0 +1,60 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2018, Kovid Goyal