mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00

Because <base> is set to the media server URL, <a href='#' ...> causes a page transition from the current setHtml() page data. Previous Qt versions allowed us to just ignore the request, but now returning False in acceptNavigationRequest() causes the subsequent page navigation to be rejected as well, resulting in no visible change when clicking on a deck in the deck list. To deal with this, Anki will now warn when such navigation requests come in, as the anchors need to be updated to return false. pycmd() has been updated to return false to make returning in onclick easier. Also use QUrl.matches() instead of converting the potentially long URL to a string.
365 lines
12 KiB
Python
365 lines
12 KiB
Python
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
# -*- coding: utf-8 -*-
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
import json
|
|
import sys
|
|
import math
|
|
from anki.hooks import runHook
|
|
from aqt.qt import *
|
|
from aqt.utils import openLink, showWarning
|
|
from anki.utils import isMac, isWin, isLin, devMode
|
|
|
|
# Page for debug messages
|
|
##########################################################################
|
|
|
|
class AnkiWebPage(QWebEnginePage):
|
|
|
|
def __init__(self, onBridgeCmd):
|
|
QWebEnginePage.__init__(self)
|
|
self._onBridgeCmd = onBridgeCmd
|
|
self._setupBridge()
|
|
self.setBackgroundColor(Qt.transparent)
|
|
|
|
def _setupBridge(self):
|
|
class Bridge(QObject):
|
|
@pyqtSlot(str, result=str)
|
|
def cmd(self, str):
|
|
return json.dumps(self.onCmd(str))
|
|
|
|
self._bridge = Bridge()
|
|
self._bridge.onCmd = self._onCmd
|
|
|
|
self._channel = QWebChannel(self)
|
|
self._channel.registerObject("py", self._bridge)
|
|
self.setWebChannel(self._channel)
|
|
|
|
js = QFile(':/qtwebchannel/qwebchannel.js')
|
|
assert js.open(QIODevice.ReadOnly)
|
|
js = bytes(js.readAll()).decode('utf-8')
|
|
|
|
script = QWebEngineScript()
|
|
script.setSourceCode(js + '''
|
|
var pycmd;
|
|
new QWebChannel(qt.webChannelTransport, function(channel) {
|
|
pycmd = function (arg, cb) {
|
|
var resultCB = function (res) {
|
|
// pass result back to user-provided callback
|
|
if (cb) {
|
|
cb(JSON.parse(res));
|
|
}
|
|
}
|
|
|
|
channel.objects.py.cmd(arg, resultCB);
|
|
return false;
|
|
}
|
|
pycmd("domDone");
|
|
});
|
|
''')
|
|
script.setWorldId(QWebEngineScript.MainWorld)
|
|
script.setInjectionPoint(QWebEngineScript.DocumentReady)
|
|
script.setRunsOnSubFrames(False)
|
|
self.profile().scripts().insert(script)
|
|
|
|
def javaScriptConsoleMessage(self, lvl, msg, line, srcID):
|
|
# not translated because console usually not visible,
|
|
# and may only accept ascii text
|
|
sys.stdout.write("JS error on line %(a)d: %(b)s" %
|
|
dict(a=line, b=msg+"\n"))
|
|
|
|
def acceptNavigationRequest(self, url, navType, isMainFrame):
|
|
if not isMainFrame:
|
|
return True
|
|
# data: links generated by setHtml()
|
|
if url.scheme() == "data":
|
|
return True
|
|
# catch buggy <a href='#' onclick='func()'> links
|
|
from aqt import mw
|
|
if url.matches(QUrl(mw.serverURL()), QUrl.RemoveFragment):
|
|
sys.stderr.write("onclick handler needs to return false\n")
|
|
return False
|
|
# load all other links in browser
|
|
openLink(url)
|
|
return False
|
|
|
|
def _onCmd(self, str):
|
|
return self._onBridgeCmd(str)
|
|
|
|
# Main web view
|
|
##########################################################################
|
|
|
|
class AnkiWebView(QWebEngineView):
|
|
|
|
def __init__(self, parent=None):
|
|
QWebEngineView.__init__(self, parent=parent)
|
|
self.title = "default"
|
|
self._page = AnkiWebPage(self._onBridgeCmd)
|
|
|
|
self._domDone = True
|
|
self._pendingActions = []
|
|
self.requiresCol = True
|
|
self.setPage(self._page)
|
|
|
|
self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache)
|
|
self.resetHandlers()
|
|
self.allowDrops = False
|
|
self._filterSet = False
|
|
QShortcut(QKeySequence("Esc"), self,
|
|
context=Qt.WidgetWithChildrenShortcut, activated=self.onEsc)
|
|
if isMac:
|
|
for key, fn in [
|
|
(QKeySequence.Copy, self.onCopy),
|
|
(QKeySequence.Paste, self.onPaste),
|
|
(QKeySequence.Cut, self.onCut),
|
|
(QKeySequence.SelectAll, self.onSelectAll),
|
|
]:
|
|
QShortcut(key, self,
|
|
context=Qt.WidgetWithChildrenShortcut,
|
|
activated=fn)
|
|
QShortcut(QKeySequence("ctrl+shift+v"), self,
|
|
context=Qt.WidgetWithChildrenShortcut, activated=self.onPaste)
|
|
|
|
def eventFilter(self, obj, evt):
|
|
# disable pinch to zoom gesture
|
|
if isinstance(evt, QNativeGestureEvent):
|
|
return True
|
|
elif evt.type() == QEvent.MouseButtonRelease:
|
|
if evt.button() == Qt.MidButton and isLin:
|
|
self.onMiddleClickPaste()
|
|
return True
|
|
return False
|
|
return False
|
|
|
|
def onEsc(self):
|
|
w = self.parent()
|
|
while w:
|
|
if isinstance(w, QDialog) or isinstance(w, QMainWindow):
|
|
from aqt import mw
|
|
# esc in a child window closes the window
|
|
if w != mw:
|
|
w.close()
|
|
else:
|
|
# in the main window, removes focus from type in area
|
|
self.parent().setFocus()
|
|
break
|
|
w = w.parent()
|
|
|
|
def onCopy(self):
|
|
self.triggerPageAction(QWebEnginePage.Copy)
|
|
|
|
def onCut(self):
|
|
self.triggerPageAction(QWebEnginePage.Cut)
|
|
|
|
def onPaste(self):
|
|
self.triggerPageAction(QWebEnginePage.Paste)
|
|
|
|
def onMiddleClickPaste(self):
|
|
self.triggerPageAction(QWebEnginePage.Paste)
|
|
|
|
def onSelectAll(self):
|
|
self.triggerPageAction(QWebEnginePage.SelectAll)
|
|
|
|
def contextMenuEvent(self, evt):
|
|
m = QMenu(self)
|
|
a = m.addAction(_("Copy"))
|
|
a.triggered.connect(self.onCopy)
|
|
runHook("AnkiWebView.contextMenuEvent", self, m)
|
|
m.popup(QCursor.pos())
|
|
|
|
def dropEvent(self, evt):
|
|
pass
|
|
|
|
def setHtml(self, html):
|
|
self._queueAction("setHtml", html)
|
|
|
|
def _setHtml(self, html):
|
|
app = QApplication.instance()
|
|
oldFocus = app.focusWidget()
|
|
self._domDone = False
|
|
self._page.setHtml(html)
|
|
# work around webengine stealing focus on setHtml()
|
|
if oldFocus:
|
|
oldFocus.setFocus()
|
|
|
|
def zoomFactor(self):
|
|
# overridden scale factor?
|
|
webscale = os.environ.get("ANKI_WEBSCALE")
|
|
if webscale:
|
|
return float(webscale)
|
|
|
|
if isMac:
|
|
return 1
|
|
screen = QApplication.desktop().screen()
|
|
dpi = screen.logicalDpiX()
|
|
factor = dpi / 96.0
|
|
if isLin:
|
|
factor = max(1, factor)
|
|
return factor
|
|
# compensate for qt's integer scaling on windows
|
|
qtIntScale = self._getQtIntScale(screen)
|
|
desiredScale = factor * qtIntScale
|
|
newFactor = desiredScale / qtIntScale
|
|
return max(1, newFactor)
|
|
|
|
def _getQtIntScale(self, screen):
|
|
# try to detect if Qt has scaled the screen
|
|
# - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,
|
|
# and a dpi of 150% = 2x
|
|
# - a screen with a normal physical dpi of 72 will have a dpi of 32
|
|
# if the scale factor has been rounded to 2x
|
|
# - different screens have different physical DPIs (eg 72, 93, 102)
|
|
# - until a better solution presents itself, assume a physical DPI at
|
|
# or above 70 is unscaled
|
|
if screen.physicalDpiX() > 70:
|
|
return 1
|
|
elif screen.physicalDpiX() > 35:
|
|
return 2
|
|
else:
|
|
return 3
|
|
|
|
def stdHtml(self, body, css=[], js=["jquery.js"], head=""):
|
|
if isWin:
|
|
widgetspec = "button { font-size: 12px; font-family:'Segoe UI'; }"
|
|
fontspec = 'font-size:12px;font-family:"Segoe UI";'
|
|
elif isMac:
|
|
family="Helvetica"
|
|
fontspec = 'font-size:15px;font-family:"%s";'% \
|
|
family
|
|
widgetspec = """
|
|
button { font-size: 13px; -webkit-appearance: none; background: #fff; border: 1px solid #ccc;
|
|
border-radius:5px; font-family: Helvetica }"""
|
|
else:
|
|
palette = self.style().standardPalette()
|
|
family = self.font().family()
|
|
color_hl = palette.color(QPalette.Highlight).name()
|
|
color_hl_txt = palette.color(QPalette.HighlightedText).name()
|
|
color_btn = palette.color(QPalette.Button).name()
|
|
fontspec = 'font-size:14px;font-family:"%s";'% family
|
|
widgetspec = """
|
|
/* Buttons */
|
|
button{ font-size:14px; -webkit-appearance:none; outline:0;
|
|
background-color: %(color_btn)s; border:1px solid rgba(0,0,0,.2);
|
|
border-radius:2px; height:24px; font-family:"%(family)s"; }
|
|
button:focus{ border-color: %(color_hl)s }
|
|
button:hover{ background-color:#fff }
|
|
button:active, button:active:hover { background-color: %(color_hl)s; color: %(color_hl_txt)s;}
|
|
/* Input field focus outline */
|
|
textarea:focus, input:focus, input[type]:focus, .uneditable-input:focus,
|
|
div[contenteditable="true"]:focus {
|
|
outline: 0 none;
|
|
border-color: %(color_hl)s;
|
|
}""" % {"family": family, "color_btn": color_btn,
|
|
"color_hl": color_hl, "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])
|
|
from aqt import mw
|
|
head = mw.baseHTML() + head + csstxt + jstxt
|
|
|
|
html = """
|
|
<!doctype html>
|
|
<html><head>
|
|
<title>{}</title>
|
|
|
|
<style>
|
|
body {{ zoom: {}; {} }}
|
|
{}
|
|
</style>
|
|
|
|
{}
|
|
</head>
|
|
|
|
<body>{}</body>
|
|
</html>""".format(self.title, self.zoomFactor(), fontspec, widgetspec, head, body)
|
|
#print(html)
|
|
self.setHtml(html)
|
|
|
|
def webBundlePath(self, path):
|
|
from aqt import mw
|
|
return "http://127.0.0.1:%d/_anki/%s" % (mw.mediaServer.getPort(), path)
|
|
|
|
def bundledScript(self, fname):
|
|
return '<script src="%s"></script>' % self.webBundlePath(fname)
|
|
|
|
def bundledCSS(self, fname):
|
|
return '<link rel="stylesheet" type="text/css" href="%s">' % self.webBundlePath(fname)
|
|
|
|
def eval(self, js):
|
|
self.evalWithCallback(js, None)
|
|
|
|
def evalWithCallback(self, js, cb):
|
|
self._queueAction("eval", js, cb)
|
|
|
|
def _evalWithCallback(self, js, cb):
|
|
if cb:
|
|
def handler(val):
|
|
if self._shouldIgnoreWebEvent():
|
|
print("ignored late js callback", cb)
|
|
return
|
|
cb(val)
|
|
self.page().runJavaScript(js, handler)
|
|
else:
|
|
self.page().runJavaScript(js)
|
|
|
|
def _queueAction(self, name, *args):
|
|
self._pendingActions.append((name, args))
|
|
self._maybeRunActions()
|
|
|
|
def _maybeRunActions(self):
|
|
while self._pendingActions and self._domDone:
|
|
name, args = self._pendingActions.pop(0)
|
|
|
|
if name == "eval":
|
|
self._evalWithCallback(*args)
|
|
elif name == "setHtml":
|
|
self._setHtml(*args)
|
|
else:
|
|
raise Exception("unknown action: {}".format(name))
|
|
|
|
def _openLinksExternally(self, url):
|
|
openLink(url)
|
|
|
|
def _shouldIgnoreWebEvent(self):
|
|
# async web events may be received after the profile has been closed
|
|
# or the underlying webview has been deleted
|
|
from aqt import mw
|
|
if sip.isdeleted(self):
|
|
return True
|
|
if not mw.col and self.requiresCol:
|
|
return True
|
|
return False
|
|
|
|
def _onBridgeCmd(self, cmd):
|
|
if not self._filterSet:
|
|
self.focusProxy().installEventFilter(self)
|
|
self._filterSet = True
|
|
|
|
if self._shouldIgnoreWebEvent():
|
|
print("ignored late bridge cmd", cmd)
|
|
return
|
|
|
|
if cmd == "domDone":
|
|
self._domDone = True
|
|
self._maybeRunActions()
|
|
else:
|
|
return self.onBridgeCmd(cmd)
|
|
|
|
def defaultOnBridgeCmd(self, cmd):
|
|
print("unhandled bridge cmd:", cmd)
|
|
|
|
def resetHandlers(self):
|
|
self.onBridgeCmd = self.defaultOnBridgeCmd
|
|
|
|
def adjustHeightToFit(self):
|
|
self.evalWithCallback("$(document.body).height()", self._onHeight)
|
|
|
|
def _onHeight(self, qvar):
|
|
if qvar is None:
|
|
from aqt import mw
|
|
openLink("https://anki.tenderapp.com/kb/problems/anki-must-be-able-to-connect-to-a-local-port")
|
|
mw.app.closeAllWindows()
|
|
return
|
|
|
|
height = math.ceil(qvar*self.zoomFactor())
|
|
self.setFixedHeight(height)
|