Work on replacing webkit in the comments editor

This commit is contained in:
Kovid Goyal 2019-06-27 16:41:03 +05:30
parent 371e5f9813
commit 25eb65e156
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C

View File

@ -1,151 +1,103 @@
#!/usr/bin/env python2 #!/usr/bin/env python2
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
# License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net>
from __future__ import absolute_import, division, print_function, unicode_literals from __future__ import absolute_import, division, print_function, unicode_literals
__license__ = 'GPL v3' import json
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' import os
__docformat__ = 'restructuredtext en' import re
import weakref
import re, os, json, weakref
from lxml import html
from PyQt5.Qt import (QApplication, QFontInfo, QSize, QWidget, QPlainTextEdit,
QToolBar, QVBoxLayout, QAction, QIcon, Qt, QTabWidget, QUrl, QFormLayout,
QSyntaxHighlighter, QColor, QColorDialog, QMenu, QDialog, QLabel,
QHBoxLayout, QKeySequence, QLineEdit, QDialogButtonBox, QPushButton,
pyqtSignal, QCheckBox)
from PyQt5.QtWebKitWidgets import QWebView, QWebPage
try:
from PyQt5 import sip
except ImportError:
import sip
from calibre.ebooks.chardet import xml_to_unicode
from calibre import xml_replace_entities, prepare_string_for_xml
from calibre.gui2 import open_url, error_dialog, choose_files, gprefs, NO_URL_FORMATTING, secure_web_page
from calibre.gui2.widgets import LineEditECM
from html5_parser import parse from html5_parser import parse
from lxml import html
from PyQt5.Qt import (
QAction, QApplication, QByteArray, QCheckBox, QColor, QColorDialog, QDialog,
QDialogButtonBox, QFontInfo, QFormLayout, QHBoxLayout, QIcon, QKeySequence,
QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSize, QSyntaxHighlighter,
Qt, QTabWidget, QTextEdit, QToolBar, QUrl, QVBoxLayout, QWidget, pyqtSignal,
pyqtSlot
)
from calibre import prepare_string_for_xml, xml_replace_entities
from calibre.ebooks.chardet import xml_to_unicode
from calibre.gui2 import NO_URL_FORMATTING, choose_files, error_dialog, gprefs
from calibre.gui2.widgets import LineEditECM
from calibre.utils.config import tweaks from calibre.utils.config import tweaks
from calibre.utils.imghdr import what from calibre.utils.imghdr import what
from polyglot.builtins import unicode_type from polyglot.builtins import unicode_type
class PageAction(QAction): # {{{ class EditorWidget(QTextEdit, LineEditECM): # {{{
def __init__(self, wac, icon, text, checkable, view):
QAction.__init__(self, QIcon(I(icon+'.png')), text, view)
self._page_action = getattr(QWebPage, wac)
self.setCheckable(checkable)
self.triggered.connect(self.trigger_page_action)
view.selectionChanged.connect(self.update_state,
type=Qt.QueuedConnection)
self.page_action.changed.connect(self.update_state,
type=Qt.QueuedConnection)
self.update_state()
@property
def page_action(self):
return self.parent().pageAction(self._page_action)
def trigger_page_action(self, *args):
self.page_action.trigger()
def update_state(self, *args):
if sip.isdeleted(self) or sip.isdeleted(self.page_action):
return
if self.isCheckable():
self.setChecked(self.page_action.isChecked())
self.setEnabled(self.page_action.isEnabled())
# }}}
class BlockStyleAction(QAction): # {{{
def __init__(self, text, name, view):
QAction.__init__(self, text, view)
self._name = name
self.triggered.connect(self.apply_style)
def apply_style(self, *args):
self.parent().exec_command('formatBlock', self._name)
# }}}
class EditorWidget(QWebView, LineEditECM): # {{{
data_changed = pyqtSignal() data_changed = pyqtSignal()
@property
def readonly(self):
return self.isReadOnly()
@readonly.setter
def readonly(self, val):
self.setReadOnly(bool(val))
def __init__(self, parent=None): def __init__(self, parent=None):
QWebView.__init__(self, parent) QTextEdit.__init__(self, parent)
self.setTabChangesFocus(True)
font = self.font()
f = QFontInfo(font)
delta = tweaks['change_book_details_font_size_by'] + 1
if delta:
font.setPixelSize(f.pixelSize() + delta)
self.setFont(font)
self.base_url = None self.base_url = None
self._parent = weakref.ref(parent) self._parent = weakref.ref(parent)
self.readonly = False
self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL) self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL)
extra_shortcuts = { extra_shortcuts = {
'ToggleBold': 'Bold', 'bold': 'Bold',
'ToggleItalic': 'Italic', 'italic': 'Italic',
'ToggleUnderline': 'Underline', 'underline': 'Underline',
} }
for wac, name, icon, text, checkable in [ for rec in (
('ToggleBold', 'bold', 'format-text-bold', _('Bold'), True), ('bold', 'format-text-bold', _('Bold'), True),
('ToggleItalic', 'italic', 'format-text-italic', _('Italic'), ('italic', 'format-text-italic', _('Italic'), True),
True), ('underline', 'format-text-underline', _('Underline'), True),
('ToggleUnderline', 'underline', 'format-text-underline', ('strikethrough', 'format-text-strikethrough', _('Strikethrough'), True),
_('Underline'), True), ('superscript', 'format-text-superscript', _('Superscript'), True),
('ToggleStrikethrough', 'strikethrough', 'format-text-strikethrough', ('subscript', 'format-text-subscript', _('Subscript'), True),
_('Strikethrough'), True), ('ordered_list', 'format-list-ordered', _('Ordered list'), True),
('ToggleSuperscript', 'superscript', 'format-text-superscript', ('unordered_list', 'format-list-unordered', _('Unordered list'), True),
_('Superscript'), True),
('ToggleSubscript', 'subscript', 'format-text-subscript',
_('Subscript'), True),
('InsertOrderedList', 'ordered_list', 'format-list-ordered',
_('Ordered list'), True),
('InsertUnorderedList', 'unordered_list', 'format-list-unordered',
_('Unordered list'), True),
('AlignLeft', 'align_left', 'format-justify-left', ('align_left', 'format-justify-left', _('Align left'), ),
_('Align left'), False), ('align_center', 'format-justify-center', _('Align center'), ),
('AlignCenter', 'align_center', 'format-justify-center', ('align_right', 'format-justify-right', _('Align right'), ),
_('Align center'), False), ('align_justified', 'format-justify-fill', _('Align justified'), ),
('AlignRight', 'align_right', 'format-justify-right', ('undo', 'edit-undo', _('Undo'), ),
_('Align right'), False), ('redo', 'edit-redo', _('Redo'), ),
('AlignJustified', 'align_justified', 'format-justify-fill', ('remove_format', 'edit-clear', _('Remove formatting'), ),
_('Align justified'), False), ('copy', 'edit-copy', _('Copy'), ),
('Undo', 'undo', 'edit-undo', _('Undo'), False), ('paste', 'edit-paste', _('Paste'), ),
('Redo', 'redo', 'edit-redo', _('Redo'), False), ('cut', 'edit-cut', _('Cut'), ),
('RemoveFormat', 'remove_format', 'edit-clear', _('Remove formatting'), False), ('indent', 'format-indent-more', _('Increase indentation'), ),
('Copy', 'copy', 'edit-copy', _('Copy'), False), ('outdent', 'format-indent-less', _('Decrease indentation'), ),
('Paste', 'paste', 'edit-paste', _('Paste'), False), ('select_all', 'edit-select-all', _('Select all'), ),
('Cut', 'cut', 'edit-cut', _('Cut'), False),
('Indent', 'indent', 'format-indent-more', ('color', 'format-text-color', _('Foreground color')),
_('Increase indentation'), False), ('background', 'format-fill-color', _('Background color')),
('Outdent', 'outdent', 'format-indent-less', ('insert_link', 'insert-link', _('Insert link or image'),),
_('Decrease indentation'), False), ('insert_hr', 'format-text-hr.png', _('Insert separator'),),
('SelectAll', 'select_all', 'edit-select-all', ('clear', 'trash.png', _('Clear')),
_('Select all'), False), ):
]: name, icon, text = rec[:3]
ac = PageAction(wac, icon, text, checkable, self) checkable = len(rec) == 4
ac = QAction(QIcon(I(icon + '.png')), text, self)
if checkable:
ac.setCheckable(checkable)
setattr(self, 'action_'+name, ac) setattr(self, 'action_'+name, ac)
ss = extra_shortcuts.get(wac, None) ss = extra_shortcuts.get(name)
if ss: if ss is not None:
ac.setShortcut(QKeySequence(getattr(QKeySequence, ss))) ac.setShortcut(QKeySequence(getattr(QKeySequence, ss)))
if wac == 'RemoveFormat': ac.triggered.connect(getattr(self, 'do_' + name))
ac.triggered.connect(self.remove_format_cleanup,
type=Qt.QueuedConnection)
self.action_color = QAction(QIcon(I('format-text-color.png')), _('Foreground color'),
self)
self.action_color.triggered.connect(self.foreground_color)
self.action_background = QAction(QIcon(I('format-fill-color.png')),
_('Background color'), self)
self.action_background.triggered.connect(self.background_color)
self.action_block_style = QAction(QIcon(I('format-text-heading.png')), self.action_block_style = QAction(QIcon(I('format-text-heading.png')),
_('Style text block'), self) _('Style text block'), self)
@ -153,81 +105,124 @@ class EditorWidget(QWebView, LineEditECM): # {{{
_('Style the selected text block')) _('Style the selected text block'))
self.block_style_menu = QMenu(self) self.block_style_menu = QMenu(self)
self.action_block_style.setMenu(self.block_style_menu) self.action_block_style.setMenu(self.block_style_menu)
self.block_style_actions = [] h = _('Heading {0}')
for text, name in [ for text, name in (
(_('Normal'), 'p'), (_('Normal'), 'p'),
(_('Heading') +' 1', 'h1'), (h.format(1), 'h1'),
(_('Heading') +' 2', 'h2'), (h.format(2), 'h2'),
(_('Heading') +' 3', 'h3'), (h.format(3), 'h3'),
(_('Heading') +' 4', 'h4'), (h.format(4), 'h4'),
(_('Heading') +' 5', 'h5'), (h.format(5), 'h5'),
(_('Heading') +' 6', 'h6'), (h.format(6), 'h6'),
(_('Pre-formatted'), 'pre'), (_('Pre-formatted'), 'pre'),
(_('Blockquote'), 'blockquote'), (_('Blockquote'), 'blockquote'),
(_('Address'), 'address'), (_('Address'), 'address'),
]: ):
ac = BlockStyleAction(text, name, self) ac = QAction(text, self)
self.block_style_menu.addAction(ac) self.block_style_menu.addAction(ac)
self.block_style_actions.append(ac) connect_lambda(ac.triggered, self, lambda self: self.do_format_block(name))
self.action_insert_link = QAction(QIcon(I('insert-link.png')),
_('Insert link or image'), self)
self.action_insert_hr = QAction(QIcon(I('format-text-hr.png')),
_('Insert separator'), self)
self.action_insert_link.triggered.connect(self.insert_link)
self.action_insert_hr.triggered.connect(self.insert_hr)
self.pageAction(QWebPage.ToggleBold).changed.connect(self.update_link_action)
self.action_insert_link.setEnabled(False)
self.action_insert_hr.setEnabled(False)
self.action_clear = QAction(QIcon(I('trash.png')), _('Clear'), self)
self.action_clear.triggered.connect(self.clear_text)
self.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
self.page().linkClicked.connect(self.link_clicked)
secure_web_page(self.page().settings())
self.setHtml('') self.setHtml('')
self.set_readonly(False) self.textChanged.connect(self.data_changed)
self.page().contentsChanged.connect(self.data_changed)
def update_link_action(self):
wac = self.pageAction(QWebPage.ToggleBold).isEnabled()
self.action_insert_link.setEnabled(wac)
self.action_insert_hr.setEnabled(wac)
def set_readonly(self, what): def set_readonly(self, what):
self.readonly = what self.readonly = what
self.page().setContentEditable(not self.readonly)
def clear_text(self, *args): def do_clear(self, *args):
raise NotImplementedError('TODO')
us = self.page().undoStack() us = self.page().undoStack()
us.beginMacro('clear all text') us.beginMacro('clear all text')
self.action_select_all.trigger() self.action_select_all.trigger()
self.action_remove_format.trigger() self.action_remove_format.trigger()
self.exec_command('delete') self.exec_command('delete')
us.endMacro() us.endMacro()
self.set_font_style()
self.setFocus(Qt.OtherFocusReason) self.setFocus(Qt.OtherFocusReason)
clear_text = do_clear
def link_clicked(self, url): def do_bold(self):
open_url(url) raise NotImplementedError('TODO')
def foreground_color(self): def do_italic(self):
raise NotImplementedError('TODO')
def do_underline(self):
raise NotImplementedError('TODO')
def do_strikethrough(self):
raise NotImplementedError('TODO')
def do_superscript(self):
raise NotImplementedError('TODO')
def do_subscript(self):
raise NotImplementedError('TODO')
def do_ordered_list(self):
raise NotImplementedError('TODO')
def do_unordered_list(self):
raise NotImplementedError('TODO')
def do_align_left(self):
raise NotImplementedError('TODO')
def do_align_center(self):
raise NotImplementedError('TODO')
def do_align_right(self):
raise NotImplementedError('TODO')
def do_align_justified(self):
raise NotImplementedError('TODO')
def do_undo(self):
self.undo()
def do_redo(self):
self.redo()
def do_remove_format(self):
raise NotImplementedError('TODO')
def do_copy(self):
self.copy()
def do_paste(self):
self.paste()
def do_cut(self):
self.cut()
def do_indent(self):
raise NotImplementedError('TODO')
def do_outdent(self):
raise NotImplementedError('TODO')
def do_select_all(self):
raise NotImplementedError('TODO')
def do_format_block(self, name):
raise NotImplementedError('TODO')
def do_color(self):
col = QColorDialog.getColor(Qt.black, self, col = QColorDialog.getColor(Qt.black, self,
_('Choose foreground color'), QColorDialog.ShowAlphaChannel) _('Choose foreground color'), QColorDialog.ShowAlphaChannel)
if col.isValid(): if col.isValid():
raise NotImplementedError('TODO')
self.exec_command('foreColor', unicode_type(col.name())) self.exec_command('foreColor', unicode_type(col.name()))
def background_color(self): def do_background(self):
col = QColorDialog.getColor(Qt.white, self, col = QColorDialog.getColor(Qt.white, self,
_('Choose background color'), QColorDialog.ShowAlphaChannel) _('Choose background color'), QColorDialog.ShowAlphaChannel)
if col.isValid(): if col.isValid():
raise NotImplementedError('TODO')
self.exec_command('hiliteColor', unicode_type(col.name())) self.exec_command('hiliteColor', unicode_type(col.name()))
def insert_hr(self, *args): def do_insert_hr(self, *args):
self.exec_command('insertHTML', '<hr>') raise NotImplementedError('TODO')
def insert_link(self, *args): def do_insert_link(self, *args):
link, name, is_image = self.ask_link() link, name, is_image = self.ask_link()
if not link: if not link:
return return
@ -235,6 +230,7 @@ class EditorWidget(QWebView, LineEditECM): # {{{
if url.isValid(): if url.isValid():
url = unicode_type(url.toString(NO_URL_FORMATTING)) url = unicode_type(url.toString(NO_URL_FORMATTING))
self.setFocus(Qt.OtherFocusReason) self.setFocus(Qt.OtherFocusReason)
raise NotImplementedError('TODO')
if is_image: if is_image:
self.exec_command('insertHTML', self.exec_command('insertHTML',
'<img src="%s" alt="%s"></img>'%(prepare_string_for_xml(url, True), '<img src="%s" alt="%s"></img>'%(prepare_string_for_xml(url, True),
@ -325,18 +321,6 @@ class EditorWidget(QWebView, LineEditECM): # {{{
def sizeHint(self): def sizeHint(self):
return QSize(150, 150) return QSize(150, 150)
def exec_command(self, cmd, arg=None):
frame = self.page().mainFrame()
if arg is not None:
js = 'document.execCommand("%s", false, %s);' % (cmd,
json.dumps(unicode_type(arg)))
else:
js = 'document.execCommand("%s", false, null);' % cmd
frame.evaluateJavaScript(js)
def remove_format_cleanup(self):
self.html = self.html
@property @property
def html(self): def html(self):
ans = '' ans = ''
@ -371,7 +355,7 @@ class EditorWidget(QWebView, LineEditECM): # {{{
if not ans.startswith('<'): if not ans.startswith('<'):
ans = '<p>%s</p>'%ans ans = '<p>%s</p>'%ans
ans = xml_replace_entities(ans) ans = xml_replace_entities(ans)
except: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -379,15 +363,25 @@ class EditorWidget(QWebView, LineEditECM): # {{{
@html.setter @html.setter
def html(self, val): def html(self, val):
if self.base_url is None: self.setHtml(val)
self.setHtml(val)
else:
self.setHtml(val, self.base_url)
self.set_font_style()
def set_base_url(self, qurl): def set_base_url(self, qurl):
self.base_url = qurl self.base_url = qurl
self.setHtml('', self.base_url)
@pyqtSlot(int, 'QUrl', result='QVariant')
def loadResource(self, rtype, qurl):
if self.base_url:
if qurl.isRelative():
qurl = self.base_url.resolved(qurl)
if qurl.isLocalFile():
path = qurl.toLocalFile()
try:
with lopen(path, 'rb') as f:
data = f.read()
except EnvironmentError:
pass
else:
return QByteArray(data)
def set_html(self, val, allow_undo=True): def set_html(self, val, allow_undo=True):
if not allow_undo or self.readonly: if not allow_undo or self.readonly:
@ -396,44 +390,22 @@ class EditorWidget(QWebView, LineEditECM): # {{{
mf = self.page().mainFrame() mf = self.page().mainFrame()
mf.evaluateJavaScript('document.execCommand("selectAll", false, null)') mf.evaluateJavaScript('document.execCommand("selectAll", false, null)')
mf.evaluateJavaScript('document.execCommand("insertHTML", false, %s)' % json.dumps(unicode_type(val))) mf.evaluateJavaScript('document.execCommand("insertHTML", false, %s)' % json.dumps(unicode_type(val)))
self.set_font_style()
def set_font_style(self):
fi = QFontInfo(QApplication.font(self))
f = fi.pixelSize() + 1 + int(tweaks['change_book_details_font_size_by'])
fam = unicode_type(fi.family()).strip().replace('"', '')
if not fam:
fam = 'sans-serif'
style = 'font-size: %fpx; font-family:"%s",sans-serif;' % (f, fam)
# toList() is needed because PyQt on Debian is old/broken
for body in self.page().mainFrame().documentElement().findAll('body').toList():
body.setAttribute('style', style)
self.page().setContentEditable(not self.readonly)
def event(self, ev):
if ev.type() in (ev.KeyPress, ev.KeyRelease, ev.ShortcutOverride) and hasattr(ev, 'key') and ev.key() in (
Qt.Key_Tab, Qt.Key_Escape, Qt.Key_Backtab):
if (ev.key() == Qt.Key_Tab and ev.modifiers() & Qt.ControlModifier and ev.type() == ev.KeyPress):
self.exec_command('insertHTML', '<span style="white-space:pre">\t</span>')
ev.accept()
return True
ev.ignore()
return False
return QWebView.event(self, ev)
def text(self): def text(self):
return self.page().selectedText() return self.textCursor().selectedText()
def setText(self, text): def setText(self, text):
self.exec_command('insertText', text) c = self.textCursor()
c.insertText(text)
self.setTextCursor(c)
def contextMenuEvent(self, ev): def contextMenuEvent(self, ev):
menu = self.page().createStandardContextMenu() menu = self.createStandardContextMenu()
paste = self.pageAction(QWebPage.Paste) # paste = self.pageAction(QWebPage.Paste)
for action in menu.actions(): for action in menu.actions():
if action == paste: pass
menu.insertAction(action, self.pageAction(QWebPage.PasteAndMatchStyle)) # if action == paste:
# menu.insertAction(action, self.pageAction(QWebPage.PasteAndMatchStyle))
st = self.text() st = self.text()
if st and st.strip(): if st and st.strip():
self.create_change_case_menu(menu) self.create_change_case_menu(menu)
@ -757,7 +729,7 @@ class Editor(QWidget): # {{{
# }}} # }}}
self.code_edit.textChanged.connect(self.code_dirtied) self.code_edit.textChanged.connect(self.code_dirtied)
self.editor.page().contentsChanged.connect(self.wyswyg_dirtied) self.editor.data_changed.connect(self.wyswyg_dirtied)
def set_minimum_height_for_editor(self, val): def set_minimum_height_for_editor(self, val):
self.editor.setMinimumHeight(val) self.editor.setMinimumHeight(val)