Merge pull request #445 from glutanimate/new-html-view-hooks-2

Allow add-on authors to easily inject their own content into Anki's web views – take 3
This commit is contained in:
Damien Elmes 2020-02-16 08:32:34 +10:00 committed by GitHub
commit 990a6c394b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 288 additions and 77 deletions

View File

@ -219,5 +219,5 @@ suggestions, bug reports and donations."
abt.label.setMinimumWidth(800)
abt.label.setMinimumHeight(600)
dialog.show()
abt.label.stdHtml(abouttext, js=" ")
abt.label.stdHtml(abouttext, js=[])
return dialog

View File

@ -1384,27 +1384,20 @@ by clicking on one on the left."""
info, cs = self._cardInfoData()
reps = self._revlogData(cs)
class CardInfoDialog(QDialog):
silentlyClose = True
def reject(self):
saveGeom(self, "revlog")
return QDialog.reject(self)
d = CardInfoDialog(self)
card_info_dialog = CardInfoDialog(self)
l = QVBoxLayout()
l.setContentsMargins(0, 0, 0, 0)
w = AnkiWebView()
w = AnkiWebView(title="browser card info")
l.addWidget(w)
w.stdHtml(info + "<p>" + reps)
w.stdHtml(info + "<p>" + reps, context=card_info_dialog)
bb = QDialogButtonBox(QDialogButtonBox.Close)
l.addWidget(bb)
bb.rejected.connect(d.reject)
d.setLayout(l)
d.setWindowModality(Qt.WindowModal)
d.resize(500, 400)
restoreGeom(d, "revlog")
d.show()
bb.rejected.connect(card_info_dialog.reject)
card_info_dialog.setLayout(l)
card_info_dialog.setWindowModality(Qt.WindowModal)
card_info_dialog.resize(500, 400)
restoreGeom(card_info_dialog, "revlog", CardInfoDialog)
card_info_dialog.show()
def _cardInfoData(self):
from anki.stats import CardStats
@ -1563,7 +1556,7 @@ where id in %s"""
self._previewWindow.silentlyClose = True
vbox = QVBoxLayout()
vbox.setContentsMargins(0, 0, 0, 0)
self._previewWeb = AnkiWebView()
self._previewWeb = AnkiWebView(title="previewer")
vbox.addWidget(self._previewWeb)
bbox = QDialogButtonBox()
@ -1658,12 +1651,15 @@ where id in %s"""
"mathjax/MathJax.js",
"reviewer.js",
]
web_context = PreviewDialog(dialog=self._previewWindow, browser=self)
self._previewWeb.stdHtml(
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
self.mw.reviewer.revHtml(),
css=["reviewer.css"],
js=jsinc,
context=web_context,
)
self._previewWeb.set_bridge_command(
self._on_preview_bridge_cmd,
PreviewDialog(dialog=self._previewWindow, browser=self),
self._on_preview_bridge_cmd, web_context,
)
def _on_preview_bridge_cmd(self, cmd: str) -> Any:
@ -2161,10 +2157,10 @@ update cards set usn=?, mod=?, did=? where id in """
frm.fields.addItems(fields)
self._dupesButton = None
# links
frm.webView.set_bridge_command(
self.dupeLinkClicked, FindDupesDialog(dialog=d, browser=self)
)
frm.webView.stdHtml("")
frm.webView.title = "find duplicates"
web_context = FindDupesDialog(dialog=d, browser=self)
frm.webView.set_bridge_command(self.dupeLinkClicked, web_context)
frm.webView.stdHtml("", context=web_context)
def onFin(code):
saveGeom(d, "findDupes")
@ -2173,13 +2169,15 @@ update cards set usn=?, mod=?, did=? where id in """
def onClick():
field = fields[frm.fields.currentIndex()]
self.duplicatesReport(frm.webView, field, frm.search.text(), frm)
self.duplicatesReport(
frm.webView, field, frm.search.text(), frm, web_context
)
search = frm.buttonBox.addButton(_("Search"), QDialogButtonBox.ActionRole)
search.clicked.connect(onClick)
d.show()
def duplicatesReport(self, web, fname, search, frm):
def duplicatesReport(self, web, fname, search, frm, web_context):
self.mw.progress.start()
res = self.mw.col.findDupes(fname, search)
if not self._dupesButton:
@ -2204,7 +2202,7 @@ update cards set usn=?, mod=?, did=? where id in """
)
)
t += "</ol>"
web.stdHtml(t)
web.stdHtml(t, context=web_context)
self.mw.progress.finish()
def _onTagDupes(self, res):
@ -2478,3 +2476,19 @@ Are you sure you want to continue?"""
def onHelp(self):
openHelp("browsermisc")
# Card Info Dialog
######################################################################
class CardInfoDialog(QDialog):
silentlyClose = True
def __init__(self, browser: Browser, *args, **kwargs):
super().__init__(browser, *args, **kwargs)
self.browser = browser
def reject(self):
saveGeom(self, "revlog")
return QDialog.reject(self)

View File

@ -203,9 +203,9 @@ class CardLayout(QDialog):
def setupWebviews(self):
pform = self.pform
pform.frontWeb = AnkiWebView()
pform.frontWeb = AnkiWebView(title="card layout front")
pform.frontPrevBox.addWidget(pform.frontWeb)
pform.backWeb = AnkiWebView()
pform.backWeb = AnkiWebView(title="card layout back")
pform.backPrevBox.addWidget(pform.backWeb)
jsinc = [
"jquery.js",
@ -215,10 +215,10 @@ class CardLayout(QDialog):
"reviewer.js",
]
pform.frontWeb.stdHtml(
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc, context=self,
)
pform.backWeb.stdHtml(
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc, context=self,
)
pform.frontWeb.set_bridge_command(self._on_bridge_cmd, self)
pform.backWeb.set_bridge_command(self._on_bridge_cmd, self)

View File

@ -109,6 +109,7 @@ class DeckBrowser:
self._body % dict(tree=tree, stats=stats, countwarn=self._countWarn()),
css=["deckbrowser.css"],
js=["jquery.js", "jquery-ui.js", "deckbrowser.js"],
context=self,
)
self.web.key = "deckBrowser"
self._drawButtons()
@ -340,9 +341,10 @@ where id > ?""",
<button title='%s' onclick='pycmd(\"%s\");'>%s</button>""" % tuple(
b
)
self.bottom.draw(buf)
self.bottom.web.set_bridge_command(
self._linkHandler, DeckBrowserBottomBar(self)
self.bottom.draw(
buf=buf,
link_handler=self._linkHandler,
web_context=DeckBrowserBottomBar(self),
)
def _onShared(self):

View File

@ -95,7 +95,6 @@ class Editor:
def setupWeb(self) -> None:
self.web = EditorWebView(self.widget, self)
self.web.title = "editor"
self.web.allowDrops = True
self.web.set_bridge_command(self.onBridgeCmd, self)
self.outerLayout.addWidget(self.web, 1)
@ -167,6 +166,7 @@ class Editor:
_html % (bgcol, bgcol, topbuts, _("Show Duplicates")),
css=["editor.css"],
js=["jquery.js", "editor.js"],
context=self,
)
# Top buttons
@ -937,7 +937,7 @@ to a cloze type first, via Edit>Change Note Type."""
class EditorWebView(AnkiWebView):
def __init__(self, parent, editor):
AnkiWebView.__init__(self)
AnkiWebView.__init__(self, title="editor")
self.editor = editor
self.strip = self.editor.mw.pm.profile["stripHTML"]
self.setAcceptDrops(True)

View File

@ -7,7 +7,7 @@ See pylib/anki/hooks.py
from __future__ import annotations
from typing import Any, Callable, Dict, List, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
import anki
import aqt
@ -1129,6 +1129,67 @@ class _WebviewDidReceiveJsMessageFilter:
webview_did_receive_js_message = _WebviewDidReceiveJsMessageFilter()
class _WebviewWillSetContentHook:
"""Used to modify web content before it is rendered.
Web_content contains the HTML, JS, and CSS the web view will be
populated with.
Context is the instance that was passed to stdHtml().
It can be inspected to check which screen this hook is firing
in, and to get a reference to the screen. For example, if your
code wishes to function only in the review screen, you could do:
def on_webview_will_set_content(web_content: WebContent, context):
if not isinstance(context, aqt.reviewer.Reviewer):
# not reviewer, do not modify content
return
# reviewer, perform changes to content
context: aqt.reviewer.Reviewer
addon_package = mw.addonManager.addonFromModule(__name__)
web_content.css.append(
f"/_addons/{addon_package}/web/my-addon.css")
web_content.js.append(
f"/_addons/{addon_package}/web/my-addon.js")
web_content.head += "<script>console.log('my-addon')</script>"
web_content.body += "<div id='my-addon'></div>"
"""
_hooks: List[Callable[["aqt.webview.WebContent", Optional[Any]], None]] = []
def append(
self, cb: Callable[["aqt.webview.WebContent", Optional[Any]], None]
) -> None:
"""(web_content: aqt.webview.WebContent, context: Optional[Any])"""
self._hooks.append(cb)
def remove(
self, cb: Callable[["aqt.webview.WebContent", Optional[Any]], None]
) -> None:
if cb in self._hooks:
self._hooks.remove(cb)
def __call__(
self, web_content: aqt.webview.WebContent, context: Optional[Any]
) -> None:
for hook in self._hooks:
try:
hook(web_content, context)
except:
# if the hook fails, remove it
self._hooks.remove(hook)
raise
webview_will_set_content = _WebviewWillSetContentHook()
class _WebviewWillShowContextMenuHook:
_hooks: List[Callable[["aqt.webview.AnkiWebView", QMenu], None]] = []

View File

@ -663,9 +663,8 @@ from the profile screen."
if self.resetModal:
# we don't have to change the webview, as we have a covering window
return
self.web.set_bridge_command(
lambda url: self.delayedMaybeReset(), ResetRequired(self)
)
web_context = ResetRequired(self)
self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context)
i = _("Waiting for editing to finish.")
b = self.button("refresh", _("Resume Now"), id="resume")
self.web.stdHtml(
@ -676,7 +675,8 @@ from the profile screen."
%s</div></div></center>
<script>$('#resume').focus()</script>
"""
% (i, b)
% (i, b),
context=web_context,
)
self.bottomWeb.hide()
self.web.setFocus()
@ -717,19 +717,16 @@ title="%s" %s>%s</button>""" % (
self.form = aqt.forms.main.Ui_MainWindow()
self.form.setupUi(self)
# toolbar
tweb = self.toolbarWeb = aqt.webview.AnkiWebView()
tweb.title = "top toolbar"
tweb = self.toolbarWeb = aqt.webview.AnkiWebView(title="top toolbar")
tweb.setFocusPolicy(Qt.WheelFocus)
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
self.toolbar.draw()
# main area
self.web = aqt.webview.AnkiWebView()
self.web.title = "main webview"
self.web = aqt.webview.AnkiWebView(title="main webview")
self.web.setFocusPolicy(Qt.WheelFocus)
self.web.setMinimumWidth(400)
# bottom area
sweb = self.bottomWeb = aqt.webview.AnkiWebView()
sweb.title = "bottom toolbar"
sweb = self.bottomWeb = aqt.webview.AnkiWebView(title="bottom toolbar")
sweb.setFocusPolicy(Qt.WheelFocus)
# add in a layout
self.mainLayout = QVBoxLayout()

View File

@ -151,6 +151,7 @@ class Overview:
),
css=["overview.css"],
js=["jquery.js", "overview.js"],
context=self,
)
def _desc(self, deck):
@ -243,8 +244,9 @@ to their original deck."""
<button title="%s" onclick='pycmd("%s")'>%s</button>""" % tuple(
b
)
self.bottom.draw(buf)
self.bottom.web.set_bridge_command(self._linkHandler, OverviewBottomBar(self))
self.bottom.draw(
buf=buf, link_handler=self._linkHandler, web_context=OverviewBottomBar(self)
)
# Studying more
######################################################################

View File

@ -153,6 +153,7 @@ class Reviewer:
"mathjax/MathJax.js",
"reviewer.js",
],
context=self,
)
# show answer / ease buttons
self.bottom.web.show()
@ -160,6 +161,7 @@ class Reviewer:
self._bottomHTML(),
css=["toolbar-bottom.css", "reviewer-bottom.css"],
js=["jquery.js", "reviewer-bottom.js"],
context=ReviewerBottomBar(self),
)
# Showing the question

View File

@ -95,7 +95,10 @@ class DeckStats(QDialog):
stats = self.mw.col.stats()
stats.wholeCollection = self.wholeCollection
self.report = stats.report(type=self.period)
self.form.web.title = "deck stats"
self.form.web.stdHtml(
"<html><body>" + self.report + "</body></html>", js=["jquery.js", "plot.js"]
"<html><body>" + self.report + "</body></html>",
js=["jquery.js", "plot.js"],
context=self,
)
self.mw.progress.finish()

View File

@ -4,6 +4,8 @@
from __future__ import annotations
from typing import Any, Optional
import aqt
from anki.lang import _
from aqt.qt import *
@ -37,9 +39,18 @@ class Toolbar:
self.web.setFixedHeight(30)
self.web.requiresCol = False
def draw(self):
self.web.set_bridge_command(self._linkHandler, TopToolbar(self))
self.web.stdHtml(self._body % self._centerLinks(), css=["toolbar.css"])
def draw(
self,
buf: str = "",
web_context: Optional[Any] = None,
link_handler: Optional[Callable[[str], Any]] = None,
):
web_context = web_context or TopToolbar(self)
link_handler = link_handler or self._linkHandler
self.web.set_bridge_command(link_handler, web_context)
self.web.stdHtml(
self._body % self._centerLinks(), css=["toolbar.css"], context=web_context,
)
self.web.adjustHeightToFit()
# Available links
@ -122,10 +133,19 @@ class BottomBar(Toolbar):
%s</td></tr></table></center>
"""
def draw(self, buf):
def draw(
self,
buf: str = "",
web_context: Optional[Any] = None,
link_handler: Optional[Callable[[str], Any]] = None,
):
# note: some screens may override this
self.web.set_bridge_command(self._linkHandler, BottomToolbar(self))
web_context = web_context or BottomToolbar(self)
link_handler = link_handler or self._linkHandler
self.web.set_bridge_command(link_handler, web_context)
self.web.stdHtml(
self._centerBody % buf, css=["toolbar.css", "toolbar-bottom.css"]
self._centerBody % buf,
css=["toolbar.css", "toolbar-bottom.css"],
context=web_context,
)
self.web.adjustHeightToFit()

View File

@ -1,6 +1,7 @@
# Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import dataclasses
import json
import math
import sys
@ -96,14 +97,76 @@ class AnkiWebPage(QWebEnginePage): # type: ignore
return self._onBridgeCmd(str)
# Add-ons
##########################################################################
@dataclasses.dataclass
class WebContent:
"""Stores all dynamically modified content that a particular web view
will be populated with.
Attributes:
body {str} -- HTML body
head {str} -- HTML head
css {List[str]} -- List of media server subpaths,
each pointing to a CSS file
js {List[str]} -- List of media server subpaths,
each pointing to a JS file
Important Notes:
- When modifying the attributes specified above, please make sure your
changes only perform the minimum requried edits to make your add-on work.
You should avoid overwriting or interfering with existing data as much
as possible, instead opting to append your own changes, e.g.:
def on_webview_will_set_content(web_content: WebContent, context):
web_content.body += "<my_html>"
web_content.head += "<my_head>"
- The paths specified in `css` and `js` need to be accessible by Anki's
media server. All list members without a specified subpath are assumed
to be located under `/_anki`, which is the media server subpath used
for all web assets shipped with Anki.
Add-ons may expose their own web assets by utilizing
aqt.addons.AddonManager.setWebExports(). Web exports registered
in this manner may then be accessed under the `/_addons` subpath.
E.g., to allow access to a `my-addon.js` and `my-addon.css` residing
in a "web" subfolder in your add-on package, first register the
corresponding web export:
> from aqt import mw
> mw.addonManager.setWebExports(__name__, r"web/.*(css|js)")
Then append the subpaths to the corresponding web_content fields
within a function subscribing to gui_hooks.webview_will_set_content:
def on_webview_will_set_content(web_content: WebContent, context):
addon_package = mw.addonManager.addonFromModule(__name__)
web_content.css.append(
f"/_addons/{addon_package}/web/my-addon.css")
web_content.js.append(
f"/_addons/{addon_package}/web/my-addon.js")
"""
body: str = ""
head: str = ""
css: List[str] = dataclasses.field(default_factory=lambda: [])
js: List[str] = dataclasses.field(default_factory=lambda: [])
# Main web view
##########################################################################
class AnkiWebView(QWebEngineView): # type: ignore
def __init__(self, parent: Optional[QWidget] = None) -> None:
def __init__(
self, parent: Optional[QWidget] = None, title: str = "default"
) -> None:
QWebEngineView.__init__(self, parent=parent) # type: ignore
self.title = "default"
self.title = title
self._page = AnkiWebPage(self._onBridgeCmd)
self._page.setBackgroundColor(self._getWindowColor()) # reduce flicker
@ -254,11 +317,23 @@ class AnkiWebView(QWebEngineView): # type: ignore
return QColor("#ececec")
return self.style().standardPalette().color(QPalette.Window)
def stdHtml(self, body, css=None, js=None, head=""):
if css is None:
css = []
if js is None:
js = ["jquery.js"]
def stdHtml(
self,
body: str,
css: Optional[List[str]] = None,
js: Optional[List[str]] = None,
head: str = "",
context: Optional[Any] = None,
):
web_content = WebContent(
body=body,
head=head,
js=["webview.js"] + (["jquery.js"] if js is None else js),
css=["webview.css"] + ([] if css is None else css),
)
gui_hooks.webview_will_set_content(web_content, context)
palette = self.style().standardPalette()
color_hl = palette.color(QPalette.Highlight).name()
@ -299,16 +374,12 @@ div[contenteditable="true"]:focus {
"color_hl_txt": color_hl_txt,
}
csstxt = "\n".join(
[self.bundledCSS("webview.css")] + [self.bundledCSS(fname) for fname in css]
)
jstxt = "\n".join(
[self.bundledScript("webview.js")]
+ [self.bundledScript(fname) for fname in js]
)
csstxt = "\n".join(self.bundledCSS(fname) for fname in web_content.css)
jstxt = "\n".join(self.bundledScript(fname) for fname in web_content.js)
from aqt import mw
head = mw.baseHTML() + head + csstxt + jstxt
head = mw.baseHTML() + web_content.head + csstxt + jstxt
body_class = theme_manager.body_class()
@ -334,20 +405,25 @@ body {{ zoom: {}; background: {}; {} }}
widgetspec,
head,
body_class,
body,
web_content.body,
)
# print(html)
self.setHtml(html)
def webBundlePath(self, path):
def webBundlePath(self, path: str) -> str:
from aqt import mw
return "http://127.0.0.1:%d/_anki/%s" % (mw.mediaServer.getPort(), path)
if path.startswith("/"):
subpath = ""
else:
subpath = "/_anki/"
def bundledScript(self, fname):
return f"http://127.0.0.1:{mw.mediaServer.getPort()}{subpath}{path}"
def bundledScript(self, fname: str) -> str:
return '<script src="%s"></script>' % self.webBundlePath(fname)
def bundledCSS(self, fname):
def bundledCSS(self, fname: str) -> str:
return '<link rel="stylesheet" type="text/css" href="%s">' % self.webBundlePath(
fname
)

View File

@ -163,6 +163,40 @@ hooks = [
return handled
""",
),
Hook(
name="webview_will_set_content",
args=["web_content: aqt.webview.WebContent", "context: Optional[Any]",],
doc="""Used to modify web content before it is rendered.
Web_content contains the HTML, JS, and CSS the web view will be
populated with.
Context is the instance that was passed to stdHtml().
It can be inspected to check which screen this hook is firing
in, and to get a reference to the screen. For example, if your
code wishes to function only in the review screen, you could do:
def on_webview_will_set_content(web_content: WebContent, context):
if not isinstance(context, aqt.reviewer.Reviewer):
# not reviewer, do not modify content
return
# reviewer, perform changes to content
context: aqt.reviewer.Reviewer
addon_package = mw.addonManager.addonFromModule(__name__)
web_content.css.append(
f"/_addons/{addon_package}/web/my-addon.css")
web_content.js.append(
f"/_addons/{addon_package}/web/my-addon.js")
web_content.head += "<script>console.log('my-addon')</script>"
web_content.body += "<div id='my-addon'></div>"
""",
),
Hook(
name="webview_will_show_context_menu",
args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"],