2012-12-21 08:51:59 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
2013-11-13 06:56:37 +01:00
|
|
|
import re
|
|
|
|
import os
|
2016-05-12 06:45:35 +02:00
|
|
|
import urllib.request, urllib.error, urllib.parse
|
2013-11-13 06:56:37 +01:00
|
|
|
import ctypes
|
2016-05-12 06:45:35 +02:00
|
|
|
import urllib.request, urllib.parse, urllib.error
|
2016-10-20 00:40:30 +02:00
|
|
|
import warnings
|
2016-12-15 09:14:47 +01:00
|
|
|
import html
|
2017-01-08 13:52:33 +01:00
|
|
|
import mimetypes
|
|
|
|
import base64
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2013-11-13 06:56:37 +01:00
|
|
|
from anki.lang import _
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.qt import *
|
2016-12-15 09:14:47 +01:00
|
|
|
from anki.utils import stripHTML, isWin, isMac, namedtmp, json, stripHTMLMedia, \
|
|
|
|
checksum
|
2013-07-11 10:21:16 +02:00
|
|
|
import anki.sound
|
2012-12-21 08:51:59 +01:00
|
|
|
from anki.hooks import runHook, runFilter
|
|
|
|
from aqt.sound import getAudio
|
|
|
|
from aqt.webview import AnkiWebView
|
2016-07-07 15:39:48 +02:00
|
|
|
from aqt.utils import shortcut, showInfo, showWarning, getFile, \
|
2015-09-28 15:09:30 +02:00
|
|
|
openHelp, tooltip, downArrow
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt
|
|
|
|
import anki.js
|
2016-05-12 06:45:35 +02:00
|
|
|
from bs4 import BeautifulSoup
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2015-03-14 19:00:00 +01:00
|
|
|
pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp")
|
|
|
|
audio = ("wav", "mp3", "ogg", "flac", "mp4", "swf", "mov", "mpeg", "mkv", "m4a", "3gp", "spx", "oga")
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
_html = """
|
|
|
|
<html><head>%s<style>
|
|
|
|
.field {
|
|
|
|
border: 1px solid #aaa; background:#fff; color:#000; padding: 5px;
|
|
|
|
}
|
|
|
|
/* prevent floated images from being displayed outside field */
|
|
|
|
.field:after {
|
2013-03-03 01:07:35 +01:00
|
|
|
content: "";
|
2012-12-21 08:51:59 +01:00
|
|
|
display: block;
|
|
|
|
height: 0;
|
|
|
|
clear: both;
|
|
|
|
visibility: hidden;
|
|
|
|
}
|
|
|
|
.fname { vertical-align: middle; padding: 0; }
|
|
|
|
img { max-width: 90%%; }
|
|
|
|
body { margin: 5px; }
|
2017-01-10 10:02:29 +01:00
|
|
|
#topbuts { position: fixed; height: 24px; top: 0; padding: 2px; left:0;right:0}
|
2016-06-22 06:52:17 +02:00
|
|
|
.topbut { width: 16px; height: 16px; }
|
|
|
|
.rainbow {
|
|
|
|
background-image: -webkit-gradient(linear, left top, left bottom,
|
|
|
|
color-stop(0.00, #f77),
|
|
|
|
color-stop(50%%, #7f7),
|
|
|
|
color-stop(100%%, #77f));
|
|
|
|
}
|
2017-01-06 16:39:20 +01:00
|
|
|
.linkb { -webkit-appearance: none; border: 0; padding: 0px 2px; background: transparent; }
|
2016-06-22 06:52:17 +02:00
|
|
|
.linkb:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
|
|
|
|
|
|
.highlighted {
|
|
|
|
border-bottom: 3px solid #000;
|
|
|
|
}
|
|
|
|
|
|
|
|
#fields { margin-top: 35px; }
|
|
|
|
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
</style><script>
|
|
|
|
%s
|
|
|
|
|
|
|
|
var currentField = null;
|
|
|
|
var changeTimer = null;
|
|
|
|
var dropTarget = null;
|
|
|
|
|
|
|
|
String.prototype.format = function() {
|
|
|
|
var args = arguments;
|
|
|
|
return this.replace(/\{\d+\}/g, function(m){
|
|
|
|
return args[m.match(/\d+/)]; });
|
|
|
|
};
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
function setBG(col) {
|
|
|
|
document.body.style.backgroundColor = col;
|
|
|
|
$("#topbuts")[0].style.backgroundColor = col;
|
|
|
|
};
|
|
|
|
|
|
|
|
function setFGButton(col) {
|
|
|
|
$("#forecolor")[0].style.backgroundColor = col;
|
|
|
|
};
|
|
|
|
|
2016-07-12 05:30:10 +02:00
|
|
|
function saveNow() {
|
2016-07-14 12:23:44 +02:00
|
|
|
clearChangeTimer();
|
2016-07-12 05:30:10 +02:00
|
|
|
if (currentField) {
|
|
|
|
currentField.blur();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
function onKey() {
|
|
|
|
// esc clears focus, allowing dialog to close
|
|
|
|
if (window.event.which == 27) {
|
|
|
|
currentField.blur();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
clearChangeTimer();
|
2016-07-07 09:41:47 +02:00
|
|
|
changeTimer = setTimeout(function () {
|
2016-06-22 06:52:17 +02:00
|
|
|
updateButtonState();
|
2016-07-07 09:41:47 +02:00
|
|
|
saveField("key");
|
|
|
|
}, 600);
|
|
|
|
};
|
|
|
|
|
|
|
|
function checkForEmptyField() {
|
|
|
|
if (currentField.innerHTML == "") {
|
|
|
|
currentField.innerHTML = "<br>";
|
2012-12-21 08:51:59 +01:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
function updateButtonState() {
|
|
|
|
var buts = ["bold", "italic", "underline", "superscript", "subscript"];
|
|
|
|
for (var i=0; i<buts.length; i++) {
|
|
|
|
var name = buts[i];
|
|
|
|
if (document.queryCommandState(name)) {
|
|
|
|
$("#"+name).addClass("highlighted");
|
|
|
|
} else {
|
|
|
|
$("#"+name).removeClass("highlighted");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// fixme: forecolor
|
|
|
|
// 'col': document.queryCommandValue("forecolor")
|
2012-12-21 08:51:59 +01:00
|
|
|
};
|
|
|
|
|
2017-01-08 14:33:45 +01:00
|
|
|
function toggleEditorButton(buttonid) {
|
|
|
|
if ($(buttonid).hasClass("highlighted")) {
|
|
|
|
$(buttonid).removeClass("highlighted");
|
|
|
|
} else {
|
|
|
|
$(buttonid).addClass("highlighted");
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
function setFormat(cmd, arg, nosave) {
|
|
|
|
document.execCommand(cmd, false, arg);
|
|
|
|
if (!nosave) {
|
|
|
|
saveField('key');
|
2016-06-22 06:52:17 +02:00
|
|
|
updateButtonState();
|
2012-12-21 08:51:59 +01:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
function clearChangeTimer() {
|
|
|
|
if (changeTimer) {
|
|
|
|
clearTimeout(changeTimer);
|
|
|
|
changeTimer = null;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
function onFocus(elem) {
|
|
|
|
currentField = elem;
|
2016-06-06 09:54:39 +02:00
|
|
|
pycmd("focus:" + currentField.id.substring(1));
|
2016-06-22 06:52:17 +02:00
|
|
|
enableButtons();
|
2012-12-21 08:51:59 +01:00
|
|
|
// don't adjust cursor on mouse clicks
|
|
|
|
if (mouseDown) { return; }
|
|
|
|
// do this twice so that there's no flicker on newer versions
|
|
|
|
caretToEnd();
|
|
|
|
// need to do this in a timeout for older qt versions
|
|
|
|
setTimeout(function () { caretToEnd() }, 1);
|
|
|
|
// scroll if bottom of element off the screen
|
|
|
|
function pos(obj) {
|
|
|
|
var cur = 0;
|
|
|
|
do {
|
|
|
|
cur += obj.offsetTop;
|
|
|
|
} while (obj = obj.offsetParent);
|
|
|
|
return cur;
|
|
|
|
}
|
|
|
|
var y = pos(elem);
|
|
|
|
if ((window.pageYOffset+window.innerHeight) < (y+elem.offsetHeight) ||
|
|
|
|
window.pageYOffset > y) {
|
|
|
|
window.scroll(0,y+elem.offsetHeight-window.innerHeight);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function focusField(n) {
|
|
|
|
$("#f"+n).focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
function onDragOver(elem) {
|
|
|
|
// if we focus the target element immediately, the drag&drop turns into a
|
|
|
|
// copy, so note it down for later instead
|
|
|
|
dropTarget = elem;
|
|
|
|
}
|
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
function onPaste(elem) {
|
|
|
|
pycmd("paste");
|
|
|
|
window.event.preventDefault();
|
|
|
|
}
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
function caretToEnd() {
|
|
|
|
var r = document.createRange()
|
|
|
|
r.selectNodeContents(currentField);
|
|
|
|
r.collapse(false);
|
|
|
|
var s = document.getSelection();
|
|
|
|
s.removeAllRanges();
|
|
|
|
s.addRange(r);
|
|
|
|
};
|
|
|
|
|
|
|
|
function onBlur() {
|
|
|
|
if (currentField) {
|
|
|
|
saveField("blur");
|
|
|
|
}
|
|
|
|
clearChangeTimer();
|
2016-06-22 06:52:17 +02:00
|
|
|
disableButtons();
|
2012-12-21 08:51:59 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
function saveField(type) {
|
|
|
|
if (!currentField) {
|
|
|
|
// no field has been focused yet
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// type is either 'blur' or 'key'
|
2016-06-06 09:54:39 +02:00
|
|
|
pycmd(type + ":" + currentField.innerHTML);
|
2012-12-21 08:51:59 +01:00
|
|
|
clearChangeTimer();
|
|
|
|
};
|
|
|
|
|
|
|
|
function wrappedExceptForWhitespace(text, front, back) {
|
|
|
|
var match = text.match(/^(\s*)([^]*?)(\s*)$/);
|
|
|
|
return match[1] + front + match[2] + back + match[3];
|
|
|
|
};
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
function disableButtons() {
|
|
|
|
$("button.linkb").prop("disabled", true);
|
|
|
|
};
|
|
|
|
|
|
|
|
function enableButtons() {
|
|
|
|
$("button.linkb").prop("disabled", false);
|
|
|
|
};
|
|
|
|
|
|
|
|
// disable the buttons if a field is not currently focused
|
|
|
|
function maybeDisableButtons() {
|
|
|
|
if (!document.activeElement || document.activeElement.className != "field") {
|
|
|
|
disableButtons();
|
|
|
|
} else {
|
|
|
|
enableButtons();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
function wrap(front, back) {
|
|
|
|
var s = window.getSelection();
|
|
|
|
var r = s.getRangeAt(0);
|
|
|
|
var content = r.cloneContents();
|
|
|
|
var span = document.createElement("span")
|
|
|
|
span.appendChild(content);
|
|
|
|
var new_ = wrappedExceptForWhitespace(span.innerHTML, front, back);
|
|
|
|
setFormat("inserthtml", new_);
|
|
|
|
if (!span.innerHTML) {
|
|
|
|
// run with an empty selection; move cursor back past postfix
|
|
|
|
r = s.getRangeAt(0);
|
|
|
|
r.setStart(r.startContainer, r.startOffset - back.length);
|
|
|
|
r.collapse(true);
|
|
|
|
s.removeAllRanges();
|
|
|
|
s.addRange(r);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
function setFields(fields, focusTo) {
|
|
|
|
var txt = "";
|
|
|
|
for (var i=0; i<fields.length; i++) {
|
|
|
|
var n = fields[i][0];
|
|
|
|
var f = fields[i][1];
|
|
|
|
if (!f) {
|
|
|
|
f = "<br>";
|
|
|
|
}
|
|
|
|
txt += "<tr><td class=fname>{0}</td></tr><tr><td width=100%%>".format(n);
|
2016-07-07 09:41:47 +02:00
|
|
|
txt += "<div id=f{0} onkeydown='onKey();' oninput='checkForEmptyField()' onmouseup='onKey();'".format(i);
|
2012-12-21 08:51:59 +01:00
|
|
|
txt += " onfocus='onFocus(this);' onblur='onBlur();' class=field ";
|
2016-12-15 09:14:47 +01:00
|
|
|
txt += "ondragover='onDragOver(this);' onpaste='onPaste(this);' ";
|
2012-12-21 08:51:59 +01:00
|
|
|
txt += "contentEditable=true class=field>{0}</div>".format(f);
|
|
|
|
txt += "</td></tr>";
|
|
|
|
}
|
|
|
|
$("#fields").html("<table cellpadding=0 width=100%%>"+txt+"</table>");
|
|
|
|
if (!focusTo) {
|
|
|
|
focusTo = 0;
|
|
|
|
}
|
|
|
|
if (focusTo >= 0) {
|
|
|
|
$("#f"+focusTo).focus();
|
|
|
|
}
|
2016-06-22 06:52:17 +02:00
|
|
|
maybeDisableButtons();
|
2012-12-21 08:51:59 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
function setBackgrounds(cols) {
|
|
|
|
for (var i=0; i<cols.length; i++) {
|
|
|
|
$("#f"+i).css("background", cols[i]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function setFonts(fonts) {
|
|
|
|
for (var i=0; i<fonts.length; i++) {
|
|
|
|
$("#f"+i).css("font-family", fonts[i][0]);
|
|
|
|
$("#f"+i).css("font-size", fonts[i][1]);
|
|
|
|
$("#f"+i)[0].dir = fonts[i][2] ? "rtl" : "ltr";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function showDupes() {
|
|
|
|
$("#dupes").show();
|
|
|
|
}
|
|
|
|
|
|
|
|
function hideDupes() {
|
|
|
|
$("#dupes").hide();
|
|
|
|
}
|
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
var pasteHTML = function(html, internal) {
|
|
|
|
if (!internal) {
|
|
|
|
html = filterHTML(html);
|
|
|
|
}
|
|
|
|
setFormat("inserthtml", html);
|
|
|
|
};
|
|
|
|
|
|
|
|
var filterHTML = function(html) {
|
|
|
|
// wrap it in <top> as we aren't allowed to change top level elements
|
|
|
|
var top = $.parseHTML("<ankitop>" + html + "</ankitop>")[0];
|
|
|
|
filterNode(top);
|
|
|
|
var outHtml = top.innerHTML;
|
|
|
|
// get rid of nbsp
|
|
|
|
outHtml = outHtml.replace(/ /ig, " ");
|
|
|
|
//console.log(`input html: ${html}`);
|
|
|
|
//console.log(`outpt html: ${outHtml}`);
|
|
|
|
return outHtml;
|
|
|
|
};
|
|
|
|
|
|
|
|
var allowedTags = {};
|
|
|
|
|
2016-12-19 15:58:59 +01:00
|
|
|
var TAGS_WITHOUT_ATTRS = ["H1", "H2", "H3", "P", "DIV", "BR", "LI", "UL",
|
|
|
|
"OL", "B", "I", "U", "BLOCKQUOTE", "CODE", "EM",
|
|
|
|
"STRONG", "PRE", "SUB", "SUP", "TABLE"];
|
|
|
|
for (var i = 0; i < TAGS_WITHOUT_ATTRS.length; i++) {
|
|
|
|
allowedTags[TAGS_WITHOUT_ATTRS[i]] = {"attrs": []};
|
2016-12-15 09:14:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
allowedTags["A"] = {"attrs": ["HREF"]};
|
|
|
|
allowedTags["TR"] = {"attrs": ["ROWSPAN"]};
|
|
|
|
allowedTags["TD"] = {"attrs": ["COLSPAN", "ROWSPAN"]};
|
|
|
|
allowedTags["TH"] = {"attrs": ["COLSPAN", "ROWSPAN"]};
|
|
|
|
allowedTags["IMG"] = {"attrs": ["SRC"]};
|
|
|
|
|
|
|
|
var filterNode = function(node) {
|
|
|
|
// if it's a text node, nothing to do
|
|
|
|
if (node.nodeType == 3) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// descend first, and take a copy of the child nodes as the loop will skip
|
|
|
|
// elements due to node modifications otherwise
|
|
|
|
|
|
|
|
var nodes = [];
|
2016-12-19 15:58:59 +01:00
|
|
|
for (var i = 0; i < node.childNodes.length; i++) {
|
2016-12-15 09:14:47 +01:00
|
|
|
nodes.push(node.childNodes[i]);
|
|
|
|
}
|
2016-12-19 15:58:59 +01:00
|
|
|
for (var i = 0; i < nodes.length; i++) {
|
2016-12-15 09:14:47 +01:00
|
|
|
filterNode(nodes[i]);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (node.tagName == "ANKITOP") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2016-12-19 15:58:59 +01:00
|
|
|
var tag = allowedTags[node.tagName];
|
2016-12-15 09:14:47 +01:00
|
|
|
if (!tag) {
|
|
|
|
node.outerHTML = node.innerHTML;
|
|
|
|
} else {
|
|
|
|
// allowed, filter out attributes
|
|
|
|
var toRemove = [];
|
2016-12-19 15:58:59 +01:00
|
|
|
for (var i = 0; i < node.attributes.length; i++) {
|
|
|
|
var attr = node.attributes[i];
|
|
|
|
var attrName = attr.name.toUpperCase();
|
2016-12-15 09:14:47 +01:00
|
|
|
if (tag.attrs.indexOf(attrName) == -1) {
|
2016-12-19 15:58:59 +01:00
|
|
|
toRemove.push(attr);
|
2016-12-15 09:14:47 +01:00
|
|
|
}
|
|
|
|
}
|
2016-12-19 15:58:59 +01:00
|
|
|
for (var i = 0; i < toRemove.length; i++) {
|
2016-12-15 09:14:47 +01:00
|
|
|
node.removeAttributeNode(toRemove[i]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
var mouseDown = 0;
|
|
|
|
|
|
|
|
$(function () {
|
2016-06-22 06:52:17 +02:00
|
|
|
document.body.onmousedown = function () {
|
|
|
|
mouseDown++;
|
|
|
|
}
|
2013-06-14 06:11:48 +02:00
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
document.body.onmouseup = function () {
|
|
|
|
mouseDown--;
|
|
|
|
}
|
2013-06-14 06:11:48 +02:00
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
document.onclick = function (evt) {
|
|
|
|
var src = window.event.srcElement;
|
|
|
|
if (src.tagName == "IMG") {
|
|
|
|
// image clicked; find contenteditable parent
|
|
|
|
var p = src;
|
|
|
|
while (p = p.parentNode) {
|
|
|
|
if (p.className == "field") {
|
|
|
|
$("#"+p.id).focus();
|
|
|
|
break;
|
|
|
|
}
|
2012-12-21 08:51:59 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2013-06-14 06:11:48 +02:00
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
// prevent editor buttons from taking focus
|
|
|
|
$("button.linkb").on("mousedown", function(e) { e.preventDefault(); });
|
2012-12-21 08:51:59 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
</script></head><body>
|
2016-06-22 06:52:17 +02:00
|
|
|
<div id="topbuts">%s</div>
|
2012-12-21 08:51:59 +01:00
|
|
|
<div id="fields"></div>
|
2016-06-06 09:54:39 +02:00
|
|
|
<div id="dupes" style="display:none;"><a href="#" onclick="pycmd('dupes');return false;">%s</a></div>
|
2012-12-21 08:51:59 +01:00
|
|
|
</body></html>
|
|
|
|
"""
|
|
|
|
|
|
|
|
# caller is responsible for resetting note on reset
|
|
|
|
class Editor(object):
|
|
|
|
def __init__(self, mw, widget, parentWindow, addMode=False):
|
|
|
|
self.mw = mw
|
|
|
|
self.widget = widget
|
|
|
|
self.parentWindow = parentWindow
|
|
|
|
self.note = None
|
|
|
|
self.stealFocus = True
|
|
|
|
self.addMode = addMode
|
|
|
|
self._loaded = False
|
|
|
|
self.currentField = 0
|
|
|
|
# current card, for card layout
|
|
|
|
self.card = None
|
|
|
|
self.setupOuter()
|
2016-06-22 06:52:17 +02:00
|
|
|
self.setupShortcuts()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.setupWeb()
|
|
|
|
self.setupTags()
|
|
|
|
|
|
|
|
# Initial setup
|
|
|
|
############################################################
|
|
|
|
|
|
|
|
def setupOuter(self):
|
|
|
|
l = QVBoxLayout()
|
2016-06-06 09:54:39 +02:00
|
|
|
l.setContentsMargins(0,0,0,0)
|
2012-12-21 08:51:59 +01:00
|
|
|
l.setSpacing(0)
|
|
|
|
self.widget.setLayout(l)
|
|
|
|
self.outerLayout = l
|
|
|
|
|
|
|
|
def setupWeb(self):
|
|
|
|
self.web = EditorWebView(self.widget, self)
|
2016-07-07 09:23:13 +02:00
|
|
|
self.web.title = "editor"
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.allowDrops = True
|
2016-06-06 09:54:39 +02:00
|
|
|
self.web.onBridgeCmd = self.onBridgeCmd
|
2012-12-21 08:51:59 +01:00
|
|
|
self.outerLayout.addWidget(self.web, 1)
|
2016-06-06 09:54:39 +02:00
|
|
|
self.web.onLoadFinished = self._loadFinished
|
2016-06-22 06:52:17 +02:00
|
|
|
|
2017-01-06 15:54:55 +01:00
|
|
|
righttopbtns = list()
|
2017-01-06 16:40:10 +01:00
|
|
|
righttopbtns.append(self._addButton('text_bold', 'bold', "Bold text (Ctrl+B)", id='bold'))
|
|
|
|
righttopbtns.append(self._addButton('text_italic', 'italic', "Italic text (Ctrl+I)", id='italic'))
|
2017-01-07 14:21:55 +01:00
|
|
|
righttopbtns.append(self._addButton('text_under', 'underline', "Underline text (Ctrl+U)", id='underline'))
|
|
|
|
righttopbtns.append(self._addButton('text_super', 'super', "Superscript (Ctrl+Shift+=)", id='superscript'))
|
2017-01-06 16:40:10 +01:00
|
|
|
righttopbtns.append(self._addButton('text_sub', 'sub', "Subscript (Ctrl+=)", id='subscript'))
|
2017-01-06 15:54:55 +01:00
|
|
|
righttopbtns.append(self._addButton('text_clear', 'clear', "Remove formatting (Ctrl+R)"))
|
|
|
|
# The color selection buttons do not use an icon so the HTML must be specified manually
|
|
|
|
righttopbtns.append('''<button tabindex=-1 class=linkb title="Set foreground colour (F7)"
|
|
|
|
type="button" onclick="pycmd('colour');return false;">
|
|
|
|
<div id=forecolor style="display:inline-block; background: #000;border-radius: 5px;"
|
|
|
|
class=topbut></div></button>''')
|
|
|
|
righttopbtns.append('''<button tabindex=-1 class=linkb title="Change colour (F8)"
|
|
|
|
type="button" onclick="pycmd('changeCol');return false;">
|
|
|
|
<div style="display:inline-block; border-radius: 5px;"
|
|
|
|
class="topbut rainbow"></div></button>''')
|
|
|
|
righttopbtns.append(self._addButton('text_cloze', 'cloze', "Cloze deletion (Ctrl+Shift+C)"))
|
|
|
|
righttopbtns.append(self._addButton('paperclip', 'attach', "Attach pictures/audio/video (F3)"))
|
|
|
|
righttopbtns.append(self._addButton('media-record', 'record', "Record audio (F5)"))
|
|
|
|
righttopbtns.append(self._addButton('more', 'more'))
|
2017-01-06 16:37:57 +01:00
|
|
|
righttopbtns = runFilter("setupEditorButtons", righttopbtns, self)
|
2016-06-22 06:52:17 +02:00
|
|
|
topbuts = """
|
2017-01-06 15:54:55 +01:00
|
|
|
<div id="topbutsleft" style="float:left;">
|
|
|
|
<button onclick="pycmd('fields')">%(flds)s...</button>
|
|
|
|
<button onclick="pycmd('cards')">%(cards)s...</button>
|
|
|
|
</div>
|
|
|
|
<div id="topbutsright" style="float:right;">
|
|
|
|
%(rightbts)s
|
|
|
|
</div>
|
|
|
|
""" % dict(flds=_("Fields"), cards=_("Cards"), rightbts="".join(righttopbtns))
|
2016-06-06 09:54:39 +02:00
|
|
|
self.web.stdHtml(_html % (
|
2016-07-07 15:39:48 +02:00
|
|
|
self.mw.baseHTML(), anki.js.jquery,
|
2016-06-22 06:52:17 +02:00
|
|
|
topbuts,
|
2016-06-06 09:54:39 +02:00
|
|
|
_("Show Duplicates")))
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Top buttons
|
|
|
|
######################################################################
|
|
|
|
|
2017-01-08 13:52:33 +01:00
|
|
|
def resourceToData(self, path):
|
|
|
|
"""Convert a file (specified by a path) into a data URI."""
|
2017-01-14 21:16:50 +01:00
|
|
|
if not os.path.exists(path):
|
|
|
|
raise FileNotFoundError
|
2017-01-08 13:52:33 +01:00
|
|
|
mime, _ = mimetypes.guess_type(path)
|
|
|
|
with open(path, 'rb') as fp:
|
|
|
|
data = fp.read()
|
|
|
|
data64 = b''.join(base64.encodestring(data).splitlines())
|
|
|
|
return 'data:%s;base64,%s' % (mime, data64.decode('ascii'))
|
|
|
|
|
2017-01-08 14:33:45 +01:00
|
|
|
def _addButton(self, icon, cmd, tip="", id=None, toggleable=False):
|
2017-01-06 15:56:35 +01:00
|
|
|
if os.path.isabs(icon):
|
2017-01-08 13:52:33 +01:00
|
|
|
iconstr = self.resourceToData(icon)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2017-01-06 15:56:35 +01:00
|
|
|
iconstr = "qrc:/icons/{}.png".format(icon)
|
2017-01-06 16:40:10 +01:00
|
|
|
if id:
|
|
|
|
idstr = 'id={}'.format(id)
|
|
|
|
else:
|
|
|
|
idstr = ""
|
2017-01-08 14:33:45 +01:00
|
|
|
if toggleable:
|
|
|
|
toggleScript = 'toggleEditorButton(this);'
|
|
|
|
else:
|
|
|
|
toggleScript = ''
|
|
|
|
return '''<button tabindex=-1 {id} class=linkb type="button" title="{tip}" onclick="pycmd('{cmd}');{togglesc}return false;">
|
|
|
|
<img class=topbut src="{icon}"></button>'''.format(icon=iconstr, cmd=cmd, tip=_(tip), id=idstr, togglesc=toggleScript)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def setupShortcuts(self):
|
|
|
|
cuts = [
|
|
|
|
("Ctrl+L", self.onCardLayout),
|
|
|
|
("Ctrl+B", self.toggleBold),
|
|
|
|
("Ctrl+I", self.toggleItalic),
|
|
|
|
("Ctrl+U", self.toggleUnderline),
|
|
|
|
("Ctrl+Shift+=", self.toggleSuper),
|
|
|
|
("Ctrl+=", self.toggleSub),
|
|
|
|
("Ctrl+R", self.removeFormat),
|
|
|
|
("F7", self.onForeground),
|
|
|
|
("F8", self.onChangeCol),
|
|
|
|
("Ctrl+Shift+C", self.onCloze),
|
|
|
|
("Ctrl+Shift+Alt+C", self.onCloze),
|
|
|
|
("F3", self.onAddMedia),
|
|
|
|
("F5", self.onRecSound),
|
|
|
|
("Ctrl+T, T", self.insertLatex),
|
|
|
|
("Ctrl+T, E", self.insertLatexEqn),
|
|
|
|
("Ctrl+T, M", self.insertLatexMathEnv),
|
|
|
|
("Ctrl+Shift+X", self.onHtmlEdit),
|
|
|
|
("Ctrl+Shift+T", lambda: self.tags.setFocus),
|
|
|
|
]
|
|
|
|
runFilter("setupEditorShortcuts", cuts)
|
|
|
|
for keys, fn in cuts:
|
|
|
|
QShortcut(QKeySequence(keys), self.widget, activated=fn)
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
def onFields(self):
|
2016-07-14 12:23:44 +02:00
|
|
|
self.saveNow(self._onFields)
|
|
|
|
|
|
|
|
def _onFields(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.fields import FieldDialog
|
|
|
|
FieldDialog(self.mw, self.note, parent=self.parentWindow)
|
|
|
|
|
|
|
|
def onCardLayout(self):
|
2016-07-14 12:23:44 +02:00
|
|
|
self.saveNow(self._onCardLayout)
|
|
|
|
|
|
|
|
def _onCardLayout(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.clayout import CardLayout
|
|
|
|
if self.card:
|
|
|
|
ord = self.card.ord
|
|
|
|
else:
|
|
|
|
ord = 0
|
2016-07-07 04:32:27 +02:00
|
|
|
CardLayout(self.mw, self.note, ord=ord, parent=self.parentWindow,
|
2012-12-21 08:51:59 +01:00
|
|
|
addMode=self.addMode)
|
2013-07-23 15:35:00 +02:00
|
|
|
if isWin:
|
|
|
|
self.parentWindow.activateWindow()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# JS->Python bridge
|
|
|
|
######################################################################
|
|
|
|
|
2016-06-06 09:54:39 +02:00
|
|
|
def onBridgeCmd(self, cmd):
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.note or not runHook:
|
|
|
|
# shutdown
|
|
|
|
return
|
|
|
|
# focus lost or key/button pressed?
|
2016-06-06 09:54:39 +02:00
|
|
|
if cmd.startswith("blur") or cmd.startswith("key"):
|
|
|
|
(type, txt) = cmd.split(":", 1)
|
|
|
|
txt = urllib.parse.unquote(txt)
|
2012-12-21 08:51:59 +01:00
|
|
|
txt = self.mungeHTML(txt)
|
|
|
|
# misbehaving apps may include a null byte in the text
|
|
|
|
txt = txt.replace("\x00", "")
|
2014-08-01 02:37:23 +02:00
|
|
|
# reverse the url quoting we added to get images to display
|
2014-08-01 02:42:28 +02:00
|
|
|
txt = self.mw.col.media.escapeImages(txt, unescape=True)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.note.fields[self.currentField] = txt
|
|
|
|
if not self.addMode:
|
|
|
|
self.note.flush()
|
|
|
|
self.mw.requireReset()
|
|
|
|
if type == "blur":
|
|
|
|
# run any filters
|
|
|
|
if runFilter(
|
|
|
|
"editFocusLost", False, self.note, self.currentField):
|
|
|
|
# something updated the note; schedule reload
|
|
|
|
def onUpdate():
|
Avoid calling checkValid on an empty note
Fix error message when editFocusLost is called after editor destruction
https://anki.tenderapp.com/discussions/ankidesktop/11175-bug-in-browser-revealed-by-add-on?unresolve=true
To reproduce:
1. Install the attached add-on. (All it does is register a function on the editFocusLost hook, and set the flag to 1 to signify that a field has been updated).
2. Run Anki. Enter the Browse window. Press Enter to display your deck.
3. Select one card.
4. Click inside one of the fields for that card. Now, when you leave that field, the editFocusLost hook will be called.
5. Now, select 2 notes from the browser, with your mouse, without clicking anywhere else before.
You will observe this error message:
Traceback (most recent call last):
File "aqt/progress.pyc", line 69, in handler
File "aqt/editor.pyc", line 467, in onUpdate
File "aqt/editor.pyc", line 572, in checkValid AttributeError: 'NoneType' object has no attribute 'fields'
2014-12-31 15:36:12 +01:00
|
|
|
if not self.note:
|
|
|
|
return
|
2013-01-30 09:00:17 +01:00
|
|
|
self.stealFocus = True
|
2012-12-21 08:51:59 +01:00
|
|
|
self.loadNote()
|
|
|
|
self.checkValid()
|
|
|
|
self.mw.progress.timer(100, onUpdate, False)
|
|
|
|
else:
|
|
|
|
self.checkValid()
|
|
|
|
else:
|
|
|
|
runHook("editTimer", self.note)
|
|
|
|
self.checkValid()
|
|
|
|
# focused into field?
|
2016-06-06 09:54:39 +02:00
|
|
|
elif cmd.startswith("focus"):
|
|
|
|
(type, num) = cmd.split(":", 1)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.currentField = int(num)
|
2013-05-21 00:19:43 +02:00
|
|
|
runHook("editFocusGained", self.note, self.currentField)
|
2016-06-22 06:52:17 +02:00
|
|
|
elif cmd in self._links:
|
|
|
|
self._links[cmd](self)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2016-06-22 06:52:17 +02:00
|
|
|
print("uncaught cmd", cmd)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def mungeHTML(self, txt):
|
|
|
|
if txt == "<br>":
|
|
|
|
txt = ""
|
2016-12-15 09:14:47 +01:00
|
|
|
return txt
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Setting/unsetting the current note
|
|
|
|
######################################################################
|
|
|
|
|
2016-06-06 09:54:39 +02:00
|
|
|
def _loadFinished(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self._loaded = True
|
2016-06-22 06:52:17 +02:00
|
|
|
|
|
|
|
# match the background colour
|
|
|
|
bgcol = self.mw.app.palette().window().color().name()
|
|
|
|
self.web.eval("setBG('%s')" % bgcol)
|
|
|
|
# setup colour button
|
|
|
|
self.setupForegroundButton()
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
if self.note:
|
|
|
|
self.loadNote()
|
|
|
|
|
|
|
|
def setNote(self, note, hide=True, focus=False):
|
|
|
|
"Make NOTE the current note."
|
|
|
|
self.note = note
|
|
|
|
self.currentField = 0
|
2013-05-22 05:54:29 +02:00
|
|
|
if focus:
|
|
|
|
self.stealFocus = True
|
2012-12-21 08:51:59 +01:00
|
|
|
if self.note:
|
2016-06-06 09:54:39 +02:00
|
|
|
self.loadNote()
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
|
|
|
self.hideCompleters()
|
|
|
|
if hide:
|
|
|
|
self.widget.hide()
|
|
|
|
|
|
|
|
def loadNote(self):
|
|
|
|
if not self.note:
|
|
|
|
return
|
|
|
|
if self.stealFocus:
|
|
|
|
field = self.currentField
|
|
|
|
else:
|
|
|
|
field = -1
|
|
|
|
if not self._loaded:
|
|
|
|
# will be loaded when page is ready
|
|
|
|
return
|
|
|
|
data = []
|
2016-05-12 06:45:35 +02:00
|
|
|
for fld, val in list(self.note.items()):
|
2012-12-21 08:51:59 +01:00
|
|
|
data.append((fld, self.mw.col.media.escapeImages(val)))
|
|
|
|
self.web.eval("setFields(%s, %d);" % (
|
|
|
|
json.dumps(data), field))
|
|
|
|
self.web.eval("setFonts(%s);" % (
|
|
|
|
json.dumps(self.fonts())))
|
|
|
|
self.checkValid()
|
2016-06-06 09:54:39 +02:00
|
|
|
self.updateTags()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.widget.show()
|
|
|
|
if self.stealFocus:
|
|
|
|
self.web.setFocus()
|
2013-05-22 05:54:29 +02:00
|
|
|
self.stealFocus = False
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2016-06-06 09:54:39 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
def focus(self):
|
|
|
|
self.web.setFocus()
|
|
|
|
|
|
|
|
def fonts(self):
|
|
|
|
return [(f['font'], f['size'], f['rtl'])
|
|
|
|
for f in self.note.model()['flds']]
|
|
|
|
|
2016-07-14 12:23:44 +02:00
|
|
|
def saveNow(self, callback):
|
|
|
|
"Save unsaved edits then call callback()."
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.note:
|
2016-07-14 12:23:44 +02:00
|
|
|
callback()
|
2012-12-21 08:51:59 +01:00
|
|
|
return
|
|
|
|
self.saveTags()
|
2016-07-14 12:23:44 +02:00
|
|
|
self.web.evalWithCallback("saveNow()", lambda res: callback())
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def checkValid(self):
|
|
|
|
cols = []
|
|
|
|
err = None
|
|
|
|
for f in self.note.fields:
|
|
|
|
cols.append("#fff")
|
|
|
|
err = self.note.dupeOrEmpty()
|
|
|
|
if err == 2:
|
|
|
|
cols[0] = "#fcc"
|
|
|
|
self.web.eval("showDupes();")
|
|
|
|
else:
|
|
|
|
self.web.eval("hideDupes();")
|
|
|
|
self.web.eval("setBackgrounds(%s);" % json.dumps(cols))
|
|
|
|
|
|
|
|
def showDupes(self):
|
2013-01-14 22:25:21 +01:00
|
|
|
contents = stripHTMLMedia(self.note.fields[0])
|
2012-12-21 08:51:59 +01:00
|
|
|
browser = aqt.dialogs.open("Browser", self.mw)
|
|
|
|
browser.form.searchEdit.lineEdit().setText(
|
2013-05-16 07:17:07 +02:00
|
|
|
'"dupe:%s,%s"' % (self.note.model()['id'],
|
|
|
|
contents))
|
2016-07-14 12:23:44 +02:00
|
|
|
browser.onSearchActivated()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def fieldsAreBlank(self):
|
|
|
|
if not self.note:
|
|
|
|
return True
|
2013-05-24 05:04:28 +02:00
|
|
|
m = self.note.model()
|
|
|
|
for c, f in enumerate(self.note.fields):
|
|
|
|
if f and not m['flds'][c]['sticky']:
|
2012-12-21 08:51:59 +01:00
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
# HTML editing
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def onHtmlEdit(self):
|
2016-07-14 12:23:44 +02:00
|
|
|
self.saveNow(self._onHtmlEdit)
|
|
|
|
|
|
|
|
def _onHtmlEdit(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
d = QDialog(self.widget)
|
|
|
|
form = aqt.forms.edithtml.Ui_Dialog()
|
|
|
|
form.setupUi(d)
|
2016-06-06 09:54:39 +02:00
|
|
|
form.buttonBox.helpRequested.connect(lambda: openHelp("editor"))
|
2012-12-21 08:51:59 +01:00
|
|
|
form.textEdit.setPlainText(self.note.fields[self.currentField])
|
|
|
|
form.textEdit.moveCursor(QTextCursor.End)
|
|
|
|
d.exec_()
|
|
|
|
html = form.textEdit.toPlainText()
|
|
|
|
# filter html through beautifulsoup so we can strip out things like a
|
|
|
|
# leading </div>
|
2016-10-20 00:40:30 +02:00
|
|
|
with warnings.catch_warnings() as w:
|
|
|
|
warnings.simplefilter('ignore', UserWarning)
|
|
|
|
html = str(BeautifulSoup(html, "html.parser"))
|
2012-12-21 08:51:59 +01:00
|
|
|
self.note.fields[self.currentField] = html
|
|
|
|
self.loadNote()
|
|
|
|
# focus field so it's saved
|
|
|
|
self.web.setFocus()
|
|
|
|
self.web.eval("focusField(%d);" % self.currentField)
|
|
|
|
|
|
|
|
# Tag handling
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def setupTags(self):
|
|
|
|
import aqt.tagedit
|
|
|
|
g = QGroupBox(self.widget)
|
|
|
|
g.setFlat(True)
|
|
|
|
tb = QGridLayout()
|
|
|
|
tb.setSpacing(12)
|
2016-06-06 09:54:39 +02:00
|
|
|
tb.setContentsMargins(6,6,6,6)
|
2012-12-21 08:51:59 +01:00
|
|
|
# tags
|
|
|
|
l = QLabel(_("Tags"))
|
|
|
|
tb.addWidget(l, 1, 0)
|
|
|
|
self.tags = aqt.tagedit.TagEdit(self.widget)
|
2016-06-06 09:54:39 +02:00
|
|
|
self.tags.lostFocus.connect(self.saveTags)
|
2013-05-17 07:32:50 +02:00
|
|
|
self.tags.setToolTip(shortcut(_("Jump to tags with Ctrl+Shift+T")))
|
2012-12-21 08:51:59 +01:00
|
|
|
tb.addWidget(self.tags, 1, 1)
|
|
|
|
g.setLayout(tb)
|
|
|
|
self.outerLayout.addWidget(g)
|
|
|
|
|
|
|
|
def updateTags(self):
|
|
|
|
if self.tags.col != self.mw.col:
|
|
|
|
self.tags.setCol(self.mw.col)
|
|
|
|
if not self.tags.text() or not self.addMode:
|
|
|
|
self.tags.setText(self.note.stringTags().strip())
|
|
|
|
|
|
|
|
def saveTags(self):
|
|
|
|
if not self.note:
|
|
|
|
return
|
2013-05-17 06:51:49 +02:00
|
|
|
self.note.tags = self.mw.col.tags.canonify(
|
|
|
|
self.mw.col.tags.split(self.tags.text()))
|
|
|
|
self.tags.setText(self.mw.col.tags.join(self.note.tags).strip())
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.addMode:
|
|
|
|
self.note.flush()
|
|
|
|
runHook("tagsUpdated", self.note)
|
|
|
|
|
|
|
|
def saveAddModeVars(self):
|
|
|
|
if self.addMode:
|
|
|
|
# save tags to model
|
|
|
|
m = self.note.model()
|
|
|
|
m['tags'] = self.note.tags
|
|
|
|
self.mw.col.models.save(m)
|
|
|
|
|
|
|
|
def hideCompleters(self):
|
|
|
|
self.tags.hideCompleter()
|
|
|
|
|
|
|
|
# Format buttons
|
|
|
|
######################################################################
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def toggleBold(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.eval("setFormat('bold');")
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def toggleItalic(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.eval("setFormat('italic');")
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def toggleUnderline(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.eval("setFormat('underline');")
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def toggleSuper(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.eval("setFormat('superscript');")
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def toggleSub(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.eval("setFormat('subscript');")
|
|
|
|
|
|
|
|
def removeFormat(self):
|
|
|
|
self.web.eval("setFormat('removeFormat');")
|
|
|
|
|
|
|
|
def onCloze(self):
|
|
|
|
# check that the model is set up for cloze deletion
|
2014-03-09 19:12:39 +01:00
|
|
|
if not re.search('{{(.*:)*cloze:',self.note.model()['tmpls'][0]['qfmt']):
|
2012-12-21 08:51:59 +01:00
|
|
|
if self.addMode:
|
2013-05-17 08:57:22 +02:00
|
|
|
tooltip(_("Warning, cloze deletions will not work until "
|
|
|
|
"you switch the type at the top to Cloze."))
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
|
|
|
showInfo(_("""\
|
|
|
|
To make a cloze deletion on an existing note, you need to change it \
|
|
|
|
to a cloze type first, via Edit>Change Note Type."""))
|
2013-05-17 08:57:22 +02:00
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
# find the highest existing cloze
|
|
|
|
highest = 0
|
2016-05-12 06:45:35 +02:00
|
|
|
for name, val in list(self.note.items()):
|
2012-12-21 08:51:59 +01:00
|
|
|
m = re.findall("\{\{c(\d+)::", val)
|
|
|
|
if m:
|
|
|
|
highest = max(highest, sorted([int(x) for x in m])[-1])
|
|
|
|
# reuse last?
|
|
|
|
if not self.mw.app.keyboardModifiers() & Qt.AltModifier:
|
|
|
|
highest += 1
|
|
|
|
# must start at 1
|
|
|
|
highest = max(1, highest)
|
|
|
|
self.web.eval("wrap('{{c%d::', '}}');" % highest)
|
|
|
|
|
|
|
|
# Foreground colour
|
|
|
|
######################################################################
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
def setupForegroundButton(self):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.fcolour = self.mw.pm.profile.get("lastColour", "#00f")
|
|
|
|
self.onColourChanged()
|
|
|
|
|
|
|
|
# use last colour
|
|
|
|
def onForeground(self):
|
|
|
|
self._wrapWithColour(self.fcolour)
|
|
|
|
|
|
|
|
# choose new colour
|
|
|
|
def onChangeCol(self):
|
|
|
|
new = QColorDialog.getColor(QColor(self.fcolour), None)
|
|
|
|
# native dialog doesn't refocus us for some reason
|
|
|
|
self.parentWindow.activateWindow()
|
|
|
|
if new.isValid():
|
|
|
|
self.fcolour = new.name()
|
|
|
|
self.onColourChanged()
|
|
|
|
self._wrapWithColour(self.fcolour)
|
|
|
|
|
|
|
|
def _updateForegroundButton(self):
|
2016-06-22 06:52:17 +02:00
|
|
|
self.web.eval("setFGButton('%s')" % self.fcolour)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def onColourChanged(self):
|
|
|
|
self._updateForegroundButton()
|
|
|
|
self.mw.pm.profile['lastColour'] = self.fcolour
|
|
|
|
|
|
|
|
def _wrapWithColour(self, colour):
|
|
|
|
self.web.eval("setFormat('forecolor', '%s')" % colour)
|
|
|
|
|
|
|
|
# Audio/video/images
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def onAddMedia(self):
|
|
|
|
key = (_("Media") +
|
|
|
|
" (*.jpg *.png *.gif *.tiff *.svg *.tif *.jpeg "+
|
|
|
|
"*.mp3 *.ogg *.wav *.avi *.ogv *.mpg *.mpeg *.mov *.mp4 " +
|
|
|
|
"*.mkv *.ogx *.ogv *.oga *.flv *.swf *.flac)")
|
|
|
|
def accept(file):
|
|
|
|
self.addMedia(file, canDelete=True)
|
|
|
|
file = getFile(self.widget, _("Add Media"), accept, key, key="media")
|
|
|
|
self.parentWindow.activateWindow()
|
|
|
|
|
|
|
|
def addMedia(self, path, canDelete=False):
|
|
|
|
html = self._addMedia(path, canDelete)
|
|
|
|
self.web.eval("setFormat('inserthtml', %s);" % json.dumps(html))
|
|
|
|
|
|
|
|
def _addMedia(self, path, canDelete=False):
|
2013-07-11 10:21:16 +02:00
|
|
|
"Add to media folder and return local img or sound tag."
|
2012-12-21 08:51:59 +01:00
|
|
|
# copy to media folder
|
2013-07-11 10:21:16 +02:00
|
|
|
fname = self.mw.col.media.addFile(path)
|
2012-12-21 08:51:59 +01:00
|
|
|
# remove original?
|
|
|
|
if canDelete and self.mw.pm.profile['deleteMedia']:
|
2013-07-11 10:21:16 +02:00
|
|
|
if os.path.abspath(fname) != os.path.abspath(path):
|
2012-12-21 08:51:59 +01:00
|
|
|
try:
|
|
|
|
os.unlink(path)
|
|
|
|
except:
|
|
|
|
pass
|
|
|
|
# return a local html link
|
2013-07-11 10:21:16 +02:00
|
|
|
return self.fnameToLink(fname)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def onRecSound(self):
|
|
|
|
try:
|
|
|
|
file = getAudio(self.widget)
|
2016-05-12 06:45:35 +02:00
|
|
|
except Exception as e:
|
2012-12-21 08:51:59 +01:00
|
|
|
showWarning(_(
|
|
|
|
"Couldn't record audio. Have you installed lame and sox?") +
|
2013-10-20 03:57:42 +02:00
|
|
|
"\n\n" + repr(str(e)))
|
2012-12-21 08:51:59 +01:00
|
|
|
return
|
|
|
|
self.addMedia(file)
|
|
|
|
|
2013-07-11 10:21:16 +02:00
|
|
|
# Media downloads
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def urlToLink(self, url):
|
|
|
|
fname = self.urlToFile(url)
|
|
|
|
if not fname:
|
|
|
|
return ""
|
|
|
|
return self.fnameToLink(fname)
|
|
|
|
|
|
|
|
def fnameToLink(self, fname):
|
|
|
|
ext = fname.split(".")[-1].lower()
|
|
|
|
if ext in pics:
|
2016-05-12 06:45:35 +02:00
|
|
|
name = urllib.parse.quote(fname.encode("utf8"))
|
2013-07-11 10:21:16 +02:00
|
|
|
return '<img src="%s">' % name
|
|
|
|
else:
|
|
|
|
anki.sound.play(fname)
|
|
|
|
return '[sound:%s]' % fname
|
|
|
|
|
|
|
|
def urlToFile(self, url):
|
|
|
|
l = url.lower()
|
|
|
|
for suffix in pics+audio:
|
|
|
|
if l.endswith(suffix):
|
|
|
|
return self._retrieveURL(url)
|
2013-11-26 09:57:02 +01:00
|
|
|
# not a supported type
|
2013-07-11 10:21:16 +02:00
|
|
|
return
|
|
|
|
|
2013-07-18 13:32:41 +02:00
|
|
|
def isURL(self, s):
|
|
|
|
s = s.lower()
|
|
|
|
return (s.startswith("http://")
|
|
|
|
or s.startswith("https://")
|
2013-09-20 07:41:56 +02:00
|
|
|
or s.startswith("ftp://")
|
|
|
|
or s.startswith("file://"))
|
2013-07-18 13:32:41 +02:00
|
|
|
|
2013-07-11 10:21:16 +02:00
|
|
|
def _retrieveURL(self, url):
|
|
|
|
"Download file into media folder and return local filename or None."
|
2013-09-20 07:41:56 +02:00
|
|
|
# urllib doesn't understand percent-escaped utf8, but requires things like
|
|
|
|
# '#' to be escaped. we don't try to unquote the incoming URL, because
|
|
|
|
# we should only be receiving file:// urls from url mime, which is unquoted
|
2013-07-11 10:21:16 +02:00
|
|
|
if url.lower().startswith("file://"):
|
|
|
|
url = url.replace("%", "%25")
|
|
|
|
url = url.replace("#", "%23")
|
2013-09-20 07:41:56 +02:00
|
|
|
# fetch it into a temporary folder
|
2013-07-11 10:21:16 +02:00
|
|
|
self.mw.progress.start(
|
|
|
|
immediate=True, parent=self.parentWindow)
|
|
|
|
try:
|
2016-05-12 06:45:35 +02:00
|
|
|
req = urllib.request.Request(url, None, {
|
2013-07-11 10:21:16 +02:00
|
|
|
'User-Agent': 'Mozilla/5.0 (compatible; Anki)'})
|
2016-05-12 06:45:35 +02:00
|
|
|
filecontents = urllib.request.urlopen(req).read()
|
|
|
|
except urllib.error.URLError as e:
|
2013-07-11 10:21:16 +02:00
|
|
|
showWarning(_("An error occurred while opening %s") % e)
|
|
|
|
return
|
|
|
|
finally:
|
|
|
|
self.mw.progress.finish()
|
2016-05-12 06:45:35 +02:00
|
|
|
path = urllib.parse.unquote(url)
|
2013-07-11 10:21:16 +02:00
|
|
|
return self.mw.col.media.writeData(path, filecontents)
|
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
# Paste/drag&drop
|
2013-07-11 10:21:16 +02:00
|
|
|
######################################################################
|
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
removeTags = ["script", "iframe", "object", "style"]
|
|
|
|
|
|
|
|
def _pastePreFilter(self, html):
|
2016-10-20 00:40:30 +02:00
|
|
|
with warnings.catch_warnings() as w:
|
|
|
|
warnings.simplefilter('ignore', UserWarning)
|
|
|
|
doc = BeautifulSoup(html, "html.parser")
|
2016-12-15 09:14:47 +01:00
|
|
|
|
|
|
|
for tag in self.removeTags:
|
|
|
|
for node in doc(tag):
|
|
|
|
node.decompose()
|
|
|
|
|
|
|
|
# convert p tags to divs
|
|
|
|
for node in doc("p"):
|
|
|
|
node.name = "div"
|
|
|
|
|
2013-07-11 10:21:16 +02:00
|
|
|
for tag in doc("img"):
|
|
|
|
try:
|
2016-12-15 09:14:47 +01:00
|
|
|
if self.isURL(tag['src']):
|
2013-07-11 10:21:16 +02:00
|
|
|
# convert remote image links to local ones
|
|
|
|
fname = self.urlToFile(tag['src'])
|
|
|
|
if fname:
|
|
|
|
tag['src'] = fname
|
|
|
|
except KeyError:
|
|
|
|
# for some bizarre reason, mnemosyne removes src elements
|
|
|
|
# from missing media
|
|
|
|
pass
|
2016-12-15 09:14:47 +01:00
|
|
|
|
2016-05-12 06:45:35 +02:00
|
|
|
html = str(doc)
|
2013-07-11 10:21:16 +02:00
|
|
|
return html
|
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
def doPaste(self, html, internal):
|
|
|
|
if not internal:
|
|
|
|
html = self._pastePreFilter(html)
|
|
|
|
self.web.eval("pasteHTML(%s);" % json.dumps(html))
|
|
|
|
|
|
|
|
def doDrop(self, html, internal):
|
|
|
|
self.web.evalWithCallback("dropTarget.focus();",
|
|
|
|
lambda _: self.doPaste(html, internal))
|
|
|
|
self.web.setFocus()
|
|
|
|
|
|
|
|
def onPaste(self):
|
|
|
|
self.web.onPaste()
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Advanced menu
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def onAdvanced(self):
|
|
|
|
m = QMenu(self.mw)
|
|
|
|
a = m.addAction(_("LaTeX"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.insertLatex)
|
2012-12-21 08:51:59 +01:00
|
|
|
a = m.addAction(_("LaTeX equation"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.insertLatexEqn)
|
2012-12-21 08:51:59 +01:00
|
|
|
a = m.addAction(_("LaTeX math env."))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.insertLatexMathEnv)
|
2012-12-21 08:51:59 +01:00
|
|
|
a = m.addAction(_("Edit HTML"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.onHtmlEdit)
|
2012-12-21 08:51:59 +01:00
|
|
|
m.exec_(QCursor.pos())
|
|
|
|
|
|
|
|
# LaTeX
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
def insertLatex(self):
|
|
|
|
self.web.eval("wrap('[latex]', '[/latex]');")
|
|
|
|
|
|
|
|
def insertLatexEqn(self):
|
|
|
|
self.web.eval("wrap('[$]', '[/$]');")
|
|
|
|
|
|
|
|
def insertLatexMathEnv(self):
|
|
|
|
self.web.eval("wrap('[$$]', '[/$$]');")
|
|
|
|
|
2016-06-22 06:52:17 +02:00
|
|
|
# Links from HTML
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
_links = dict(
|
|
|
|
fields=onFields,
|
|
|
|
cards=onCardLayout,
|
|
|
|
bold=toggleBold,
|
|
|
|
italic=toggleItalic,
|
|
|
|
underline=toggleUnderline,
|
|
|
|
super=toggleSuper,
|
|
|
|
sub=toggleSub,
|
|
|
|
clear=removeFormat,
|
|
|
|
colour=onForeground,
|
|
|
|
changeCol=onChangeCol,
|
|
|
|
cloze=onCloze,
|
|
|
|
attach=onAddMedia,
|
|
|
|
record=onRecSound,
|
|
|
|
more=onAdvanced,
|
|
|
|
dupes=showDupes,
|
2016-12-15 09:14:47 +01:00
|
|
|
paste=onPaste,
|
2016-06-22 06:52:17 +02:00
|
|
|
)
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Pasting, drag & drop, and keyboard layouts
|
|
|
|
######################################################################
|
|
|
|
|
|
|
|
class EditorWebView(AnkiWebView):
|
|
|
|
|
|
|
|
def __init__(self, parent, editor):
|
2014-09-15 08:04:14 +02:00
|
|
|
AnkiWebView.__init__(self)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.editor = editor
|
|
|
|
self.strip = self.editor.mw.pm.profile['stripHTML']
|
2016-06-06 09:54:39 +02:00
|
|
|
self.setAcceptDrops(True)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def onCut(self):
|
2016-06-06 09:54:39 +02:00
|
|
|
self.triggerPageAction(QWebEnginePage.Cut)
|
2012-12-21 08:51:59 +01:00
|
|
|
self._flagAnkiText()
|
|
|
|
|
|
|
|
def onCopy(self):
|
2016-06-06 09:54:39 +02:00
|
|
|
self.triggerPageAction(QWebEnginePage.Copy)
|
2012-12-21 08:51:59 +01:00
|
|
|
self._flagAnkiText()
|
|
|
|
|
|
|
|
def onPaste(self):
|
2016-12-15 09:14:47 +01:00
|
|
|
mime = self.editor.mw.app.clipboard().mimeData(mode=QClipboard.Clipboard)
|
|
|
|
html, internal = self._processMime(mime)
|
|
|
|
if not html:
|
|
|
|
return
|
|
|
|
self.editor.doPaste(html, internal)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
def dropEvent(self, evt):
|
|
|
|
mime = evt.mimeData()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2016-12-15 09:14:47 +01:00
|
|
|
if evt.source() and mime.hasHtml():
|
|
|
|
# don't filter html from other fields
|
|
|
|
html, internal = mime.html(), True
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2016-12-15 09:14:47 +01:00
|
|
|
html, internal = self._processMime(mime)
|
|
|
|
|
|
|
|
if not html:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.editor.doDrop(html, internal)
|
|
|
|
|
|
|
|
# returns (html, isInternal)
|
|
|
|
def _processMime(self, mime):
|
2017-01-25 06:12:48 +01:00
|
|
|
# print("html=%s image=%s urls=%s txt=%s" % (
|
|
|
|
# mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()))
|
|
|
|
# print("html", mime.html())
|
|
|
|
# print("urls", mime.urls())
|
|
|
|
# print("text", mime.text())
|
2016-12-15 09:14:47 +01:00
|
|
|
|
|
|
|
# try various content types in turn
|
|
|
|
html, internal = self._processHtml(mime)
|
|
|
|
if html:
|
|
|
|
return html, internal
|
|
|
|
for fn in (self._processUrls, self._processImage, self._processText):
|
|
|
|
html = fn(mime)
|
|
|
|
if html:
|
|
|
|
return html, False
|
|
|
|
return "", False
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def _processUrls(self, mime):
|
2016-12-15 09:14:47 +01:00
|
|
|
if not mime.hasUrls():
|
|
|
|
return
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
url = mime.urls()[0].toString()
|
2013-05-09 08:34:16 +02:00
|
|
|
# chrome likes to give us the URL twice with a \n
|
|
|
|
url = url.splitlines()[0]
|
2016-12-15 09:14:47 +01:00
|
|
|
return self.editor.urlToLink(url)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def _processText(self, mime):
|
2016-12-15 09:14:47 +01:00
|
|
|
if not mime.hasText():
|
|
|
|
return
|
|
|
|
|
|
|
|
txt = mime.text()
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# if the user is pasting an image or sound link, convert it to local
|
2013-07-18 13:32:41 +02:00
|
|
|
if self.editor.isURL(txt):
|
2012-12-21 08:51:59 +01:00
|
|
|
txt = txt.split("\r\n")[0]
|
2016-12-15 09:14:47 +01:00
|
|
|
return self.editor.urlToLink(txt)
|
|
|
|
|
|
|
|
# normal text; convert it to HTML
|
|
|
|
return html.escape(txt)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def _processHtml(self, mime):
|
2016-12-15 09:14:47 +01:00
|
|
|
if not mime.hasHtml():
|
|
|
|
return None, False
|
2012-12-21 08:51:59 +01:00
|
|
|
html = mime.html()
|
2016-12-15 09:14:47 +01:00
|
|
|
|
|
|
|
# no filtering required for internal pastes
|
|
|
|
if html.startswith("<!--anki-->"):
|
|
|
|
return html[11:], True
|
|
|
|
|
|
|
|
return html, False
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def _processImage(self, mime):
|
|
|
|
im = QImage(mime.imageData())
|
2016-12-15 09:14:47 +01:00
|
|
|
uname = namedtmp("paste")
|
2012-12-21 08:51:59 +01:00
|
|
|
if self.editor.mw.pm.profile.get("pastePNG", False):
|
|
|
|
ext = ".png"
|
|
|
|
im.save(uname+ext, None, 50)
|
|
|
|
else:
|
|
|
|
ext = ".jpg"
|
|
|
|
im.save(uname+ext, None, 80)
|
2016-12-15 09:14:47 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# invalid image?
|
2016-12-15 09:14:47 +01:00
|
|
|
path = uname+ext
|
|
|
|
if not os.path.exists(path):
|
|
|
|
return
|
|
|
|
|
|
|
|
# hash and rename
|
|
|
|
csum = checksum(open(path, "rb").read())
|
|
|
|
newpath = "{}-{}{}".format(uname, csum, ext)
|
|
|
|
os.rename(path, newpath)
|
|
|
|
|
|
|
|
# add to media and return resulting html link
|
|
|
|
return self.editor._addMedia(newpath)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
def _flagAnkiText(self):
|
|
|
|
# add a comment in the clipboard html so we can tell text is copied
|
|
|
|
# from us and doesn't need to be stripped
|
|
|
|
clip = self.editor.mw.app.clipboard()
|
|
|
|
mime = clip.mimeData()
|
|
|
|
if not mime.hasHtml():
|
|
|
|
return
|
|
|
|
html = mime.html()
|
|
|
|
mime.setHtml("<!--anki-->" + mime.html())
|
|
|
|
|
|
|
|
def contextMenuEvent(self, evt):
|
|
|
|
m = QMenu(self)
|
|
|
|
a = m.addAction(_("Cut"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.onCut)
|
2012-12-21 08:51:59 +01:00
|
|
|
a = m.addAction(_("Copy"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.onCopy)
|
2012-12-21 08:51:59 +01:00
|
|
|
a = m.addAction(_("Paste"))
|
2016-06-06 09:54:39 +02:00
|
|
|
a.triggered.connect(self.onPaste)
|
2013-07-16 09:42:50 +02:00
|
|
|
runHook("EditorWebView.contextMenuEvent", self, m)
|
2012-12-21 08:51:59 +01:00
|
|
|
m.popup(QCursor.pos())
|