add a webview_did_receive_js_message hook

This commit is contained in:
Damien Elmes 2020-01-22 10:46:35 +10:00
parent a5db36e208
commit d54f719558
11 changed files with 131 additions and 38 deletions

View file

@ -2133,7 +2133,7 @@ update cards set usn=?, mod=?, did=? where id in """
frm.fields.addItems(fields) frm.fields.addItems(fields)
self._dupesButton = None self._dupesButton = None
# links # links
frm.webView.onBridgeCmd = self.dupeLinkClicked frm.webView.set_bridge_command(self.dupeLinkClicked, "find_dupes")
def onFin(code): def onFin(code):
saveGeom(d, "findDupes") saveGeom(d, "findDupes")

View file

@ -216,6 +216,9 @@ class CardLayout(QDialog):
pform.backWeb.stdHtml( pform.backWeb.stdHtml(
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
) )
# specify a context for add-ons
pform.frontWeb.set_bridge_command(lambda msg: None, "card_layout")
pform.backWeb.set_bridge_command(lambda msg: None, "card_layout")
def updateMainArea(self): def updateMainArea(self):
if self._isCloze(): if self._isCloze():

View file

@ -8,25 +8,25 @@ import aqt
from anki.errors import DeckRenameError from anki.errors import DeckRenameError
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.utils import fmtTimeSpan, ids2str from anki.utils import fmtTimeSpan, ids2str
from aqt import gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar
from aqt.utils import askUser, getOnlyText, openHelp, openLink, shortcut, showWarning from aqt.utils import askUser, getOnlyText, openHelp, openLink, shortcut, showWarning
class DeckBrowser: class DeckBrowser:
_dueTree: Any _dueTree: Any
def __init__(self, mw): def __init__(self, mw: AnkiQt) -> None:
self.mw = mw self.mw = mw
self.web = mw.web self.web = mw.web
self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0) self.scrollPos = QPoint(0, 0)
def show(self): def show(self):
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
self.web.resetHandlers() self.web.set_bridge_command(self._linkHandler, "deck_browser")
self.web.onBridgeCmd = self._linkHandler
self._renderPage() self._renderPage()
def refresh(self): def refresh(self):
@ -330,7 +330,7 @@ where id > ?""",
b b
) )
self.bottom.draw(buf) self.bottom.draw(buf)
self.bottom.web.onBridgeCmd = self._linkHandler self.bottom.web.set_bridge_command(self._linkHandler, "deck_browser_bottom_bar")
def _onShared(self): def _onShared(self):
openLink(aqt.appShared + "decks/") openLink(aqt.appShared + "decks/")

View file

@ -96,7 +96,7 @@ class Editor:
self.web = EditorWebView(self.widget, self) self.web = EditorWebView(self.widget, self)
self.web.title = "editor" self.web.title = "editor"
self.web.allowDrops = True self.web.allowDrops = True
self.web.onBridgeCmd = self.onBridgeCmd self.web.set_bridge_command(self.onBridgeCmd, "editor")
self.outerLayout.addWidget(self.web, 1) self.outerLayout.addWidget(self.web, 1)
righttopbtns: List[str] = [ righttopbtns: List[str] = [

View file

@ -913,6 +913,50 @@ class _UndoStateDidChangeHook:
undo_state_did_change = _UndoStateDidChangeHook() undo_state_did_change = _UndoStateDidChangeHook()
class _WebviewDidReceiveJsMessageFilter:
"""Used to handle pycmd() messages sent from Javascript.
Message is the string passed to pycmd(). Context is what was
passed to set_bridge_command(), such as 'editor' or 'reviewer'.
For messages you don't want to handle, return handled unchanged.
If you handle a message and don't want it passed to the original
bridge command handler, return (True, None).
If you want to pass a value to pycmd's result callback, you can
return it with (True, some_value)."""
_hooks: List[Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]]] = []
def append(
self, cb: Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]]
) -> None:
"""(handled: Tuple[bool, Any], message: str, context: str)"""
self._hooks.append(cb)
def remove(
self, cb: Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]]
) -> None:
if cb in self._hooks:
self._hooks.remove(cb)
def __call__(
self, handled: Tuple[bool, Any], message: str, context: str
) -> Tuple[bool, Any]:
for filter in self._hooks:
try:
handled = filter(handled, message, context)
except:
# if the hook fails, remove it
self._hooks.remove(filter)
raise
return handled
webview_did_receive_js_message = _WebviewDidReceiveJsMessageFilter()
class _WebviewWillShowContextMenuHook: class _WebviewWillShowContextMenuHook:
_hooks: List[Callable[["aqt.webview.AnkiWebView", QMenu], None]] = [] _hooks: List[Callable[["aqt.webview.AnkiWebView", QMenu], None]] = []

View file

@ -643,8 +643,9 @@ from the profile screen."
if self.resetModal: if self.resetModal:
# we don't have to change the webview, as we have a covering window # we don't have to change the webview, as we have a covering window
return return
self.web.resetHandlers() self.web.set_bridge_command(
self.web.onBridgeCmd = lambda url: self.delayedMaybeReset() lambda url: self.delayedMaybeReset(), "reset_required"
)
i = _("Waiting for editing to finish.") i = _("Waiting for editing to finish.")
b = self.button("refresh", _("Resume Now"), id="resume") b = self.button("refresh", _("Resume Now"), id="resume")
self.web.stdHtml( self.web.stdHtml(

View file

@ -2,24 +2,26 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import aqt import aqt
from anki.lang import _ from anki.lang import _
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar
from aqt.utils import askUserDialog, openLink, shortcut, tooltip from aqt.utils import askUserDialog, openLink, shortcut, tooltip
class Overview: class Overview:
"Deck overview." "Deck overview."
def __init__(self, mw): def __init__(self, mw: aqt.AnkiQt) -> None:
self.mw = mw self.mw = mw
self.web = mw.web self.web = mw.web
self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
def show(self): def show(self):
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
self.web.resetHandlers() self.web.set_bridge_command(self._linkHandler, "overview")
self.web.onBridgeCmd = self._linkHandler
self.mw.setStateShortcuts(self._shortcutKeys()) self.mw.setStateShortcuts(self._shortcutKeys())
self.refresh() self.refresh()
@ -235,7 +237,7 @@ to their original deck."""
b b
) )
self.bottom.draw(buf) self.bottom.draw(buf)
self.bottom.web.onBridgeCmd = self._linkHandler self.bottom.web.set_bridge_command(self._linkHandler, "overview_bottom")
# Studying more # Studying more
###################################################################### ######################################################################

View file

@ -10,7 +10,6 @@ import re
import unicodedata as ucd import unicodedata as ucd
from typing import List, Optional from typing import List, Optional
import aqt
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.lang import _, ngettext from anki.lang import _, ngettext
@ -19,6 +18,7 @@ from anki.utils import bodyClass, stripHTML
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player, getAudio from aqt.sound import av_player, getAudio
from aqt.toolbar import BottomBar
from aqt.utils import ( from aqt.utils import (
askUserDialog, askUserDialog,
downArrow, downArrow,
@ -42,15 +42,14 @@ class Reviewer:
self._current_side_audio: Optional[List[AVTag]] = None self._current_side_audio: Optional[List[AVTag]] = None
self.typeCorrect = None # web init happens before this is set self.typeCorrect = None # web init happens before this is set
self.state: Optional[str] = None self.state: Optional[str] = None
self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech) hooks.card_did_leech.append(self.onLeech)
def show(self): def show(self):
self.mw.col.reset() self.mw.col.reset()
self.web.resetHandlers()
self.mw.setStateShortcuts(self._shortcutKeys()) self.mw.setStateShortcuts(self._shortcutKeys())
self.web.onBridgeCmd = self._linkHandler self.web.set_bridge_command(self._linkHandler, "reviewer")
self.bottom.web.onBridgeCmd = self._linkHandler self.bottom.web.set_bridge_command(self._linkHandler, "reviewer_bottom")
self._reps = None self._reps = None
self.nextCard() self.nextCard()

View file

@ -2,12 +2,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import aqt
from anki.lang import _ from anki.lang import _
from aqt.qt import * from aqt.qt import *
from aqt.webview import AnkiWebView
class Toolbar: class Toolbar:
def __init__(self, mw, web): def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:
self.mw = mw self.mw = mw
self.web = web self.web = web
self.link_handlers = { self.link_handlers = {
@ -22,7 +26,7 @@ class Toolbar:
self.web.requiresCol = False self.web.requiresCol = False
def draw(self): def draw(self):
self.web.onBridgeCmd = self._linkHandler self.web.set_bridge_command(self._linkHandler, "top_toolbar")
self.web.stdHtml(self._body % self._centerLinks(), css=["toolbar.css"]) self.web.stdHtml(self._body % self._centerLinks(), css=["toolbar.css"])
self.web.adjustHeightToFit() self.web.adjustHeightToFit()
@ -107,7 +111,8 @@ class BottomBar(Toolbar):
""" """
def draw(self, buf): def draw(self, buf):
self.web.onBridgeCmd = self._linkHandler # note: some screens may override this
self.web.set_bridge_command(self._linkHandler, "bottom_toolbar")
self.web.stdHtml( self.web.stdHtml(
self._centerBody % buf, css=["toolbar.css", "toolbar-bottom.css"] self._centerBody % buf, css=["toolbar.css", "toolbar-bottom.css"]
) )

View file

@ -4,6 +4,7 @@
import json import json
import math import math
import sys import sys
from typing import Any, List, Optional, Tuple
from anki.lang import _ from anki.lang import _
from anki.utils import isLin, isMac, isWin from anki.utils import isLin, isMac, isWin
@ -99,18 +100,21 @@ class AnkiWebPage(QWebEnginePage): # type: ignore
class AnkiWebView(QWebEngineView): # type: ignore class AnkiWebView(QWebEngineView): # type: ignore
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None) -> None:
QWebEngineView.__init__(self, parent=parent) QWebEngineView.__init__(self, parent=parent) # type: ignore
self.title = "default" self.title = "default"
self._page = AnkiWebPage(self._onBridgeCmd) self._page = AnkiWebPage(self._onBridgeCmd)
self._page.setBackgroundColor(self._getWindowColor()) # reduce flicker self._page.setBackgroundColor(self._getWindowColor()) # reduce flicker
# in new code, use .set_bridge_command() instead of setting this directly
self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd
self._domDone = True self._domDone = True
self._pendingActions = [] self._pendingActions: List[Tuple[str, List[Any]]] = []
self.requiresCol = True self.requiresCol = True
self.setPage(self._page) self.setPage(self._page)
self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache) self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache) # type: ignore
self.resetHandlers() self.resetHandlers()
self.allowDrops = False self.allowDrops = False
self._filterSet = False self._filterSet = False
@ -119,7 +123,7 @@ class AnkiWebView(QWebEngineView): # type: ignore
self, self,
context=Qt.WidgetWithChildrenShortcut, context=Qt.WidgetWithChildrenShortcut,
activated=self.onEsc, activated=self.onEsc,
) ) # type: ignore
if isMac: if isMac:
for key, fn in [ for key, fn in [
(QKeySequence.Copy, self.onCopy), (QKeySequence.Copy, self.onCopy),
@ -129,13 +133,13 @@ class AnkiWebView(QWebEngineView): # type: ignore
]: ]:
QShortcut( QShortcut(
key, self, context=Qt.WidgetWithChildrenShortcut, activated=fn key, self, context=Qt.WidgetWithChildrenShortcut, activated=fn
) ) # type: ignore
QShortcut( QShortcut(
QKeySequence("ctrl+shift+v"), QKeySequence("ctrl+shift+v"),
self, self,
context=Qt.WidgetWithChildrenShortcut, context=Qt.WidgetWithChildrenShortcut,
activated=self.onPaste, activated=self.onPaste,
) ) # type: ignore
def eventFilter(self, obj, evt): def eventFilter(self, obj, evt):
# disable pinch to zoom gesture # disable pinch to zoom gesture
@ -391,7 +395,7 @@ body {{ zoom: {}; background: {}; {} }}
return True return True
return False return False
def _onBridgeCmd(self, cmd): def _onBridgeCmd(self, cmd: str) -> Any:
if self._shouldIgnoreWebEvent(): if self._shouldIgnoreWebEvent():
print("ignored late bridge cmd", cmd) print("ignored late bridge cmd", cmd)
return return
@ -404,13 +408,21 @@ body {{ zoom: {}; background: {}; {} }}
self._domDone = True self._domDone = True
self._maybeRunActions() self._maybeRunActions()
else: else:
return self.onBridgeCmd(cmd) handled, result = gui_hooks.webview_did_receive_js_message(
(False, None), cmd, self._bridge_command_name
)
if handled:
return result
else:
return self.onBridgeCmd(cmd)
def defaultOnBridgeCmd(self, cmd): def defaultOnBridgeCmd(self, cmd: str) -> Any:
print("unhandled bridge cmd:", cmd) print("unhandled bridge cmd:", cmd)
# legacy
def resetHandlers(self): def resetHandlers(self):
self.onBridgeCmd = self.defaultOnBridgeCmd self.onBridgeCmd = self.defaultOnBridgeCmd
self._bridge_command_name = "unknown"
def adjustHeightToFit(self): def adjustHeightToFit(self):
self.evalWithCallback("$(document.body).height()", self._onHeight) self.evalWithCallback("$(document.body).height()", self._onHeight)
@ -429,3 +441,11 @@ body {{ zoom: {}; background: {}; {} }}
height = math.ceil(qvar * scaleFactor) height = math.ceil(qvar * scaleFactor)
self.setFixedHeight(height) self.setFixedHeight(height)
def set_bridge_command(self, func: Callable[[str], Any], context: str) -> None:
"""Set a handler for pycmd() messages received from Javascript.
Context is a human readable name that is provided to the
webview_did_receive_js_message hook."""
self.onBridgeCmd = func
self._bridge_command_name = context

View file

@ -65,11 +65,6 @@ hooks = [
args=["browser: aqt.browser.Browser"], args=["browser: aqt.browser.Browser"],
legacy_hook="browser.rowChanged", legacy_hook="browser.rowChanged",
), ),
Hook(
name="webview_will_show_context_menu",
args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"],
legacy_hook="AnkiWebView.contextMenuEvent",
),
# States # States
################### ###################
Hook( Hook(
@ -98,6 +93,30 @@ hooks = [
legacy_hook="reset", legacy_hook="reset",
doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.",
), ),
# Webview
###################
Hook(
name="webview_did_receive_js_message",
args=["handled: Tuple[bool, Any]", "message: str", "context: str"],
return_type="Tuple[bool, Any]",
doc="""Used to handle pycmd() messages sent from Javascript.
Message is the string passed to pycmd(). Context is what was
passed to set_bridge_command(), such as 'editor' or 'reviewer'.
For messages you don't want to handle, return handled unchanged.
If you handle a message and don't want it passed to the original
bridge command handler, return (True, None).
If you want to pass a value to pycmd's result callback, you can
return it with (True, some_value).""",
),
Hook(
name="webview_will_show_context_menu",
args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"],
legacy_hook="AnkiWebView.contextMenuEvent",
),
# Main # Main
################### ###################
Hook(name="profile_did_open", legacy_hook="profileLoaded"), Hook(name="profile_did_open", legacy_hook="profileLoaded"),