# -*- coding: utf-8 -*- # Copyright: Damien Elmes # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from PyQt4.QtGui import * from PyQt4.QtCore import * from PyQt4.QtWebKit import QWebView import re, os, sys, urllib2, ctypes, simplejson, traceback from anki.utils import stripHTML, parseTags, isWin, namedtmp from anki.sound import play from anki.hooks import runHook from aqt.sound import getAudio from aqt.webview import AnkiWebView from aqt.utils import shortcut, showInfo, showWarning, getBase, getFile import aqt import anki.js # fixme: when tab order returns to the webview, the previously focused field # is focused, which is not good when the user is tabbing through the dialog # fixme: set rtl in div css # fixme: commit from tag/group area causes error pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif") audio = ("wav", "mp3", "ogg", "flac") _html = """ %s
%s
""" # caller is responsible for resetting fact on reset class Editor(object): def __init__(self, mw, widget, addMode=False): self.widget = widget self.mw = mw self.fact = None self.stealFocus = True self.addMode = addMode self._loaded = False self._keepButtons = False # current card, for card layout self.card = None self.setupOuter() self.setupButtons() self.setupWeb() self.setupTagsAndGroup() self.setupKeyboard() # Initial setup ############################################################ def setupOuter(self): l = QVBoxLayout()#self.widget) l.setMargin(0) l.setSpacing(3) self.widget.setLayout(l) self.outerLayout = l def setupWeb(self): self.web = EditorWebView(self.widget, self) self.web.allowDrops = True self.web.setBridge(self.bridge) self.outerLayout.addWidget(self.web) # pick up the window colour p = self.web.palette() p.setBrush(QPalette.Base, Qt.transparent) self.web.page().setPalette(p) self.web.setAttribute(Qt.WA_OpaquePaintEvent, False) # Top buttons ###################################################################### def _addButton(self, name, func, key=None, tip=None, size=True, text="", check=False): b = QPushButton(text) if check: b.connect(b, SIGNAL("clicked(bool)"), func) else: b.connect(b, SIGNAL("clicked()"), func) if size: b.setFixedHeight(20) b.setFixedWidth(20) b.setStyle(self.plastiqueStyle) b.setFocusPolicy(Qt.NoFocus) if not text: b.setIcon(QIcon(":/icons/%s.png" % name)) if key: b.setShortcut(QKeySequence(key)) if tip: b.setToolTip(shortcut(tip)) if check: b.setCheckable(True) self.iconsBox.addWidget(b) self._buttons[name] = b return b def setupButtons(self): self._buttons = {} # button styles for mac self.plastiqueStyle = QStyleFactory.create("plastique") self.widget.setStyle(self.plastiqueStyle) # icons self.iconsBox = QHBoxLayout() self.iconsBox.setMargin(0) self.iconsBox.setSpacing(0) self.outerLayout.addLayout(self.iconsBox) # align to right self.iconsBox.addItem(QSpacerItem(20,1, QSizePolicy.Expanding)) b = self._addButton b("layout", self.onCardLayout, "Ctrl+l", shortcut(_("Layout (Ctrl+l)")), size=False, text=_("Layout...")) b("text_bold", self.toggleBold, "Ctrl+b", _("Bold text (Ctrl+b)"), check=True) b("text_italic", self.toggleItalic, "Ctrl+i", _("Italic text (Ctrl+i)"), check=True) b("text_under", self.toggleUnderline, "Ctrl+u", _("Underline text (Ctrl+u)"), check=True) b("text_super", self.toggleSuper, "Ctrl+=", _("Superscript (Ctrl+=)"), check=True) b("text_sub", self.toggleSub, "Ctrl+Shift+=", _("Subscript (Ctrl+Shift+=)"), check=True) b("text_remove", self.removeFormat, "Ctrl+r", _("Subscript (Ctrl+r)")) but = b("foreground", self.onForeground, "F7", text=" ") self.setupForegroundButton(but) but = b("cloze", self.onCloze, "Ctrl+Shift+c", _("Cloze (Ctrl+Shift+c)"), text="[...]") but.setFixedWidth(24) # fixme: better image names b("text-speak", self.onAddMedia, "F3", _("Add pictures/audio/video (F3)")) b("media-record", self.onRecSound, "F5", _("Record audio (F5)")) b("tex", self.insertLatex, "Ctrl+t, t", _("LaTeX (Ctrl+t then t)")) b("math_sqrt", self.insertLatexEqn, "Ctrl+t, e", _("LaTeX equation (Ctrl+t then e)")) b("math_matrix", self.insertLatexMathEnv, "Ctrl+t, m", _("LaTeX math environment (Ctrl+t then m)")) but = b("text-xml", self.onHtmlEdit, "Ctrl+Shift+x", _("Source (Ctrl+Shift+x)")) def enableButtons(self, val=True): for b in self._buttons.values(): b.setEnabled(val) def disableButtons(self): self.enableButtons(False) def onCardLayout(self): from aqt.clayout import CardLayout self.saveNow() if self.card: type = 1; ord = self.card.ord else: type = 0; ord = 0 CardLayout(self.mw, self.fact, type=type, ord=ord, parent=self.widget) self.loadFact() # JS->Python bridge ###################################################################### def bridge(self, str): print "bridge", str if not self.fact or not runHook: # shutdown return # focus lost or key/button pressed? if str.startswith("blur") or str.startswith("key"): (type, txt) = str.split(":", 1) self.fact.fields[self.currentField] = self.mungeHTML(txt) self.mw.requireReset() self.fact.flush() if type == "blur": if not self._keepButtons: self.disableButtons() runHook("editFocusLost", self.fact) else: runHook("editTimer", self.fact) self.checkValid() # focused into field? elif str.startswith("focus"): (type, num) = str.split(":", 1) self.enableButtons() self.currentField = int(num) # state buttons changed? elif str.startswith("state"): (cmd, txt) = str.split(":", 1) r = simplejson.loads(txt) self._buttons['text_bold'].setChecked(r['bold']) self._buttons['text_italic'].setChecked(r['italic']) self._buttons['text_under'].setChecked(r['under']) self._buttons['text_super'].setChecked(r['super']) self._buttons['text_sub'].setChecked(r['sub']) elif str.startswith("dupes"): self.showDupes() else: print str def mungeHTML(self, txt): if txt == "
": txt = "" return txt # Setting/unsetting the current fact ###################################################################### def _loadFinished(self, w): self._loaded = True if self.fact: self.loadFact() def setFact(self, fact, hide=True): "Make FACT the current fact." self.fact = fact # change timer if self.fact: self.web.setHtml(_html % (getBase(self.mw.deck), anki.js.all, _("Show Duplicates")), loadCB=self._loadFinished) self.updateTagsAndGroup() self.updateKeyboard() elif hide: self.widget.hide() def loadFact(self, field=0): if not self._loaded: # will be loaded when page is ready return self.web.eval("setFields(%s, %d);" % ( simplejson.dumps(self.fact.items()), field)) self.web.eval("setFonts(%s);" % ( simplejson.dumps(self.fonts()))) self.checkValid() self.widget.show() if self.stealFocus: self.web.setFocus() def focus(self): self.web.setFocus() def fonts(self): return [(f['font'], f['esize']) for f in self.fact.model().fields] def saveNow(self): "Must call this before adding cards, closing dialog, etc." if not self.fact: return self._keepButtons = True self.web.eval("saveField('blur');") self._keepButtons = False self.saveTagsAndGroup() def checkValid(self): cols = [] self.dupe = None for c, p in enumerate(self.fact.problems()): if not p: cols.append("#fff") elif p == "unique": cols.append("#fcc") self.dupe = c else: cols.append("#ffc") self.web.eval("setBackgrounds(%s);" % simplejson.dumps(cols)) if self.dupe is not None: self.web.eval("showDupes();") else: self.web.eval("hideDupes();") def showDupes(self): contents = self.fact.fields[self.dupe] browser = aqt.dialogs.open("Browser", self.mw) browser.form.searchEdit.setText( "'model:%s' '%s'" % (self.fact.model().name, contents)) browser.onSearch() def fieldsAreBlank(self): if not self.fact: return True for f in self.fact.fields: if f: return False return True # HTML editing ###################################################################### def onHtmlEdit(self): self.saveNow() d = QDialog(self.widget) form = aqt.forms.edithtml.Ui_Dialog() form.setupUi(d) d.connect(form.buttonBox, SIGNAL("helpRequested()"), lambda: aqt.openHelp("HtmlEditor")) form.textEdit.setPlainText(self.fact.fields[self.currentField]) form.textEdit.moveCursor(QTextCursor.End) d.exec_() self.fact.fields[self.currentField] = unicode( form.textEdit.toPlainText()) self.loadFact(self.currentField) # Tag and group handling ###################################################################### def setupTagsAndGroup(self): import aqt.tagedit g = QGroupBox(self.widget) g.setFlat(True) tb = QGridLayout() tb.setSpacing(12) tb.setMargin(6) # group l = QLabel(_("Group")) tb.addWidget(l, 0, 0) if not self.addMode: self.group = QPushButton() self.group.connect(self.group, SIGNAL("clicked()"), self.changeGroup) else: self.group = aqt.tagedit.TagEdit(self.widget, type=1) self.group.connect(self.group, SIGNAL("lostFocus"), self.saveTagsAndGroup) tb.addWidget(self.group, 0, 1) # tags l = QLabel(_("Tags")) tb.addWidget(l, 0, 2) self.tags = aqt.tagedit.TagEdit(self.widget) self.tags.connect(self.tags, SIGNAL("lostFocus"), self.saveTagsAndGroup) tb.addWidget(self.tags, 0, 3) g.setLayout(tb) self.outerLayout.addWidget(g) def updateTagsAndGroup(self): if self.tags.deck != self.mw.deck: self.tags.setDeck(self.mw.deck) if self.addMode: self.group.setDeck(self.mw.deck) self.tags.setText(self.fact.stringTags().strip()) if getattr(self.fact, 'gid', None): gid = self.fact.gid else: gid = self.fact.model().conf['gid'] self.group.setText(self.mw.deck.groupName(gid)) def saveTagsAndGroup(self): if not self.fact: return self.fact.tags = parseTags(unicode(self.tags.text())) if self.addMode: # save group and tags to model self.fact.gid = self.mw.deck.groupId(unicode(self.group.text())) m = self.fact.model() m.conf['gid'] = self.fact.gid m.conf['tags'] = self.fact.tags m.flush() self.fact.flush() runHook("tagsAndGroupUpdated", self.fact) def changeGroup(self): id = self.fact.id runHook("closeEditCurrent") browser = aqt.dialogs.open("Browser", self.mw) browser.form.searchEdit.setText("fid:%d" % id) browser.onSearch() browser.setGroup() # Format buttons ###################################################################### def toggleBold(self, bool): self.web.eval("setFormat('bold');") def toggleItalic(self, bool): self.web.eval("setFormat('italic');") def toggleUnderline(self, bool): self.web.eval("setFormat('underline');") def toggleSuper(self, bool): self.web.eval("setFormat('superscript');") def toggleSub(self, bool): 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 ok = False for t in self.fact.model().templates: if "cloze" in t['qfmt'] or "cloze" in t['afmt']: ok = True break if not ok: showInfo(_("Please use a cloze deletion model."), help="ClozeDeletion") return f = self.fact.fields[self.currentField] # find the highest existing cloze m = re.findall("\{\{c(\d+)::", f) if m: next = sorted([int(x) for x in m])[-1] + 1 else: next = 1 self.web.eval("wrap('{{c%d::', '}}');" % next) # Foreground colour ###################################################################### def setupForegroundButton(self, but): self.foregroundFrame = QFrame() self.foregroundFrame.setAutoFillBackground(True) self.colourChanged() hbox = QHBoxLayout() hbox.addWidget(self.foregroundFrame) hbox.setMargin(5) but.setLayout(hbox) def _updateForegroundButton(self, txtcol): self.foregroundFrame.setPalette(QPalette(QColor(txtcol))) self.foregroundFrame.setStyleSheet("* {background-color: %s}" % txtcol) def colourChanged(self): recent = self.mw.config['recentColours'] self._updateForegroundButton(recent[-1]) def onForeground(self): class ColourPopup(QDialog): def __init__(self, parent): QDialog.__init__(self, parent, Qt.FramelessWindowHint) def event(self, evt): if evt.type() == QEvent.WindowDeactivate: self.close() return QDialog.event(self, evt) p = ColourPopup(self.widget) p.move(self.foregroundFrame.mapToGlobal(QPoint(0,0))) g = QGridLayout(p) g.setMargin(4) g.setSpacing(0) p.setLayout(g) lastWidget = None self.colourNext = QShortcut(QKeySequence("F7"), p) p.connect(self.colourNext, SIGNAL("activated()"), self.onNextColour) self.colourChoose = QShortcut(QKeySequence("F6"), p) p.connect(self.colourChoose, SIGNAL("activated()"), self.onChooseColourKey) for n, c in enumerate(reversed(self.mw.config['recentColours'])): col = QToolButton() col.setAutoRaise(True) col.setFixedWidth(64) col.setFixedHeight(16) col.setAutoFillBackground(True) col.setPalette(QPalette(QColor(c))) col.setStyleSheet("* {background-color: %s}" % c) col.connect(col, SIGNAL("clicked()"), lambda c=c: self.onChooseColour(c)) g.addWidget(col, n, 0) if lastWidget: p.setTabOrder(lastWidget, col) lastWidget = col but = QPushButton("X") but.setFixedWidth(16) but.setFixedHeight(16) but.setAutoDefault(False) but.connect(but, SIGNAL("clicked()"), lambda c=c: self.onRemoveColour(c)) g.addWidget(but, n, 1) spc = QSpacerItem(5,10, QSizePolicy.Fixed) g.addItem(spc, n+1, 0) cb = QPushButton(_("+")) cb.connect(cb, SIGNAL("clicked()"), self.onNewColour) cb.setFixedWidth(80) cb.setFixedHeight(16) cb.setAutoDefault(False) g.addWidget(cb, n+2, 0, 1, 2) self.colourDiag = p p.show() def onRemoveColour(self, colour): recent = self.mw.config['recentColours'] recent.remove(colour) if not recent: recent.append("#000000") self.colourDiag.close() self.onForeground() self.colourChanged() def onNextColour(self): self.colourDiag.focusWidget().nextInFocusChain().setFocus() def onChooseColourKey(self): try: self.colourDiag.focusWidget().click() except: # dialog focused pass def onChooseColour(self, colour): recent = self.mw.config['recentColours'] recent.remove(colour) recent.append(colour) self.web.eval("setFormat('forecolor', '%s')" % colour) self.colourDiag.close() self.colourChanged() def onNewColour(self): new = QColorDialog.getColor(Qt.white, self.widget) self.widget.raise_() recent = self.mw.config['recentColours'] if new.isValid(): txtcol = unicode(new.name()) if txtcol not in recent: recent.append(txtcol) self.colourChanged() self.onChooseColour(txtcol) # 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") def addMedia(self, path, canDelete=False): html = self._addMedia(path, canDelete) self.web.eval("setFormat('inserthtml', %s);" % simplejson.dumps(html)) def _addMedia(self, path, canDelete=False): "Add to media folder and return basename." # copy to media folder name = self.mw.deck.media.addFile(path) # remove original? if canDelete and self.mw.config['deleteMedia']: if os.path.abspath(name) != os.path.abspath(path): try: os.unlink(old) except: pass # return a local html link ext = name.split(".")[-1].lower() if ext in pics: return '' % name else: anki.sound.play(name) return '[sound:%s]' % name def onRecSound(self): try: file = getAudio(self.widget) except Exception, e: showWarning(_( "Couldn't record audio. Have you installed lame and sox?") + "\n\n" + unicode(e)) return self.addMedia(file) # LaTeX ###################################################################### def insertLatex(self): self.mw.deck.media.dir(create=True) self.web.eval("wrap('[latex]', '[/latex]');") def insertLatexEqn(self): self.mw.deck.media.dir(create=True) self.web.eval("wrap('[$]', '[/$]');") def insertLatexMathEnv(self): self.mw.deck.media.dir(create=True) self.web.eval("wrap('[$$]', '[/$$]');") # Keyboard layout ###################################################################### def setupKeyboard(self): if isWin and self.mw.config['preserveKeyboard']: a = ctypes.windll.user32.ActivateKeyboardLayout a.restype = ctypes.c_void_p a.argtypes = [ctypes.c_void_p, ctypes.c_uint] g = ctypes.windll.user32.GetKeyboardLayout g.restype = ctypes.c_void_p g.argtypes = [ctypes.c_uint] else: a = g = None self.activateKeyboard = a self.getKeyboard = g def updateKeyboard(self): self.keyboardLayouts = {} def saveKeyboard(self): if not self.getKeyboard: return self.keyboardLayouts[self.currentField] = self.getKeyboard(0) def restoreKeyboard(self): if not self.getKeyboard: return if self.currentField in self.keyboardLayouts: self.activateKeyboard(self.keyboardLayouts[self.currentField], 0) # Pasting, drag & drop, and keyboard layouts ###################################################################### class EditorWebView(AnkiWebView): def __init__(self, parent, editor): AnkiWebView.__init__(self, parent) self.editor = editor self.errtxt = _("An error occured while opening %s") self.strip = self.editor.mw.config['stripHTML'] def keyPressEvent(self, evt): self._curKey = True if evt.matches(QKeySequence.Paste): self.onPaste() return QWebView.keyPressEvent(self, evt) def contextMenuEvent(self, evt): # adjust in case the user is going to paste self.onPaste() QWebView.contextMenuEvent(self, evt) def dropEvent(self, evt): oldmime = evt.mimeData() # coming from us? if evt.source() == self: # if they're copying just an image, we need to turn it into html # again txt = "" mime = QMimeData() if not oldmime.hasHtml() and oldmime.hasUrls(): # qt gives it to us twice txt += '' % os.path.basename( unicode(oldmime.urls()[0].toString())) mime.setHtml(txt) else: mime.setText(oldmime.text()) else: mime = self._processMime(oldmime) # create a new event with the new mime data new = QDropEvent(evt.pos(), evt.possibleActions(), mime, evt.mouseButtons(), evt.keyboardModifiers()) evt.accept() QWebView.dropEvent(self, new) def onPaste(self): clip = self.editor.mw.app.clipboard() mime = clip.mimeData() mime = self._processMime(mime) clip.setMimeData(mime) def _processMime(self, mime): 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() if mime.hasUrls(): return self._processUrls(mime) elif mime.hasImage(): return self._processImage(mime) elif mime.hasText() and (self.strip or not mime.hasHtml()): return self._processText(mime) elif mime.hasHtml(): return self._processHtml(mime) else: # nothing return QMimeData() def _processUrls(self, mime): links = [] for url in mime.urls(): url = unicode(url.toString()) link = self._retrieveURL(url) if link: links.append(link) mime = QMimeData() mime.setHtml("".join(links)) return mime def _processText(self, mime): txt = unicode(mime.text()) l = txt.lower() html = None # firefox on linux just gives us a url for an image if "\n" in l and (l.startswith("http://") or l.startswith("file://")): txt = txt.split("\r\n")[0] html = self._retrieveURL(txt) new = QMimeData() if html: new.setHtml(html) else: new.setText(mime.text()) return new def _processHtml(self, mime): html = mime.html() if self.strip: html = stripHTML(html) mime = QMimeData() mime.setHtml(html) return mime def _processImage(self, mime): im = QImage(mime.imageData()) name = namedtmp("paste-%d.png" % im.cacheKey()) uname = unicode(name, sys.getfilesystemencoding()) if im.hasAlphaChannel(): im.save(uname) else: im.save(uname, None, 95) mime = QMimeData() mime.setHtml(self.editor._addMedia(uname)) return mime def _retrieveURL(self, url): # is it media? ext = name.split(".")[-1].lower() if ext not in pics and ext not in audio: return # fetch it into a temporary folder try: req = urllib2.Request(url, None, { 'User-Agent': 'Mozilla/5.0 (compatible; Anki)'}) filecontents = urllib2.urlopen(req).read() except urllib2.URLError, e: showWarning(self.errtxt % e) return path = namedtmp(os.path.basename(url)) file = open(path, "wb") file.write(filecontents) file.close() return self.editor._addMedia(path)