mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-01-10 06:00:21 -05:00
504 lines
18 KiB
Plaintext
504 lines
18 KiB
Plaintext
# vim:fileencoding=utf-8
|
|
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
|
from __python__ import bound_methods, hash_literals
|
|
|
|
from elementmaker import E
|
|
from gettext import gettext as _
|
|
|
|
from book_list.theme import get_color
|
|
from dom import add_extra_css, build_rule, clear, ensure_id, svgicon
|
|
from iframe_comm import IframeClient, IframeWrapper
|
|
from modals import create_custom_dialog
|
|
from utils import html_escape
|
|
from widgets import create_button
|
|
|
|
CLASS_NAME = 'comments-editor'
|
|
TOOLBAR_CLASS = 'comments-editor-toolbar'
|
|
|
|
add_extra_css(def():
|
|
sel = '.' + TOOLBAR_CLASS + ' '
|
|
style = ''
|
|
style += build_rule(sel, display='flex', flex_wrap='wrap', padding_top='0.25ex', padding_bottom='0.25ex')
|
|
sel += ' > div'
|
|
style += build_rule(sel, padding='0.5ex', margin_right='0.5ex', cursor='pointer')
|
|
style += build_rule(sel + ':hover', color='red')
|
|
style += build_rule(sel + '.activated', color=get_color('window-background'), background=get_color('window-foreground'))
|
|
sel += '.sep'
|
|
style += build_rule(sel, border_left='solid 2px currentColor', cursor='auto')
|
|
style += build_rule(sel + ':hover', color='currentColor')
|
|
return style
|
|
)
|
|
|
|
|
|
def choose_block_style(proceed):
|
|
create_custom_dialog(_('Choose style'), def(parent, close_modal):
|
|
|
|
def action(which):
|
|
close_modal()
|
|
proceed(which)
|
|
|
|
def s(text, tag):
|
|
ans = create_button(text, action=action.bind(None, tag))
|
|
ans.style.marginBottom = '1ex'
|
|
return ans
|
|
|
|
spc = '\xa0'
|
|
|
|
parent.appendChild(E.div(
|
|
E.div(_('Choose the block style you would like to use')),
|
|
E.div(class_='button-box', style='flex-wrap: wrap',
|
|
s(_('Normal'), 'div'), spc, s('H1', 'h1'), spc, s('H2', 'h2'), spc, s('H3', 'h3'), spc, s('H4', 'h4'), spc, s('H5', 'h5'), spc, s('H6', 'h6'), spc, s(_('Quote'), 'blockquote')
|
|
)
|
|
))
|
|
)
|
|
|
|
|
|
def insert_heading(editor_id, cmd):
|
|
editor = registry[editor_id]
|
|
if cmd:
|
|
editor.exec_command('formatBlock', f'<{cmd}>')
|
|
editor.focus()
|
|
|
|
|
|
def insert_link(title, msg, proceed):
|
|
create_custom_dialog(title, def(parent, close_modal):
|
|
|
|
def action(ok, evt):
|
|
close_modal()
|
|
c = evt.currentTarget.closest('.il-modal-container')
|
|
url = c.querySelector('input[name="url"]').value
|
|
name = c.querySelector('input[name="name"]').value
|
|
proceed(ok, url, name)
|
|
|
|
parent.appendChild(E.div(class_='il-modal-container',
|
|
E.div(msg),
|
|
E.table(style='width: 95%; margin-top: 1ex',
|
|
E.tr(E.td('URL:'), E.td(style='padding-bottom: 1ex', E.input(type='url', name='url', style='width: 100%'))),
|
|
E.tr(E.td(_('Name (optional):')), E.td(E.input(type='text', name='name', style='width: 100%'))),
|
|
),
|
|
E.div(class_='button-box',
|
|
create_button(_('OK'), action=action.bind(None, True), highlight=True),
|
|
'\xa0',
|
|
create_button(_('Cancel'), action=action.bind(None, False), highlight=False),
|
|
),
|
|
))
|
|
)
|
|
|
|
|
|
def insert_link_or_image(editor_id, is_image, ok, url, title):
|
|
editor = registry[editor_id]
|
|
if ok:
|
|
if title:
|
|
markup = '<img src="{}" title="{}"></img>' if is_image else '<a href="{}">{}</a>'
|
|
editor.exec_command('insertHTML', markup.format(html_escape(url), html_escape(title)))
|
|
else:
|
|
cmd = 'insertImage' if is_image else 'createLink'
|
|
editor.exec_command(cmd, url)
|
|
editor.focus()
|
|
|
|
|
|
def set_color(title, msg, proceed):
|
|
create_custom_dialog(title, def(parent, close_modal):
|
|
def action(ok, evt):
|
|
close_modal()
|
|
c = evt.currentTarget.closest('.sc-modal-container')
|
|
color = c.querySelector('input[name="color"]').value
|
|
proceed(ok, color)
|
|
|
|
parent.appendChild(E.div(class_='sc-modal-container',
|
|
E.div(msg),
|
|
E.div(E.input(type='color', name='color')),
|
|
E.div(class_='button-box',
|
|
create_button(_('OK'), action=action.bind(None, True), highlight=True),
|
|
'\xa0',
|
|
create_button(_('Cancel'), action=action.bind(None, False), highlight=False),
|
|
),
|
|
))
|
|
)
|
|
|
|
|
|
def change_color(editor_id, which, ok, color):
|
|
editor = registry[editor_id]
|
|
if ok:
|
|
editor.exec_command(which, color)
|
|
editor.focus()
|
|
|
|
|
|
|
|
def all_editor_actions(): # {{{
|
|
if not all_editor_actions.ans:
|
|
all_editor_actions.ans = {
|
|
'select-all': {
|
|
'icon': 'select-all',
|
|
'title': _('Select all'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('selectAll')
|
|
},
|
|
'remove-format': {
|
|
'icon': 'eraser',
|
|
'title': _('Remove formatting'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('removeFormat')
|
|
},
|
|
'undo': {
|
|
'icon': 'undo',
|
|
'title': _('Undo'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('undo')
|
|
},
|
|
'redo': {
|
|
'icon': 'redo',
|
|
'title': _('Redo'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('redo')
|
|
},
|
|
'bold': {
|
|
'icon': 'bold',
|
|
'title': _('Bold'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('bold')
|
|
},
|
|
'italic': {
|
|
'icon': 'italic',
|
|
'title': _('Italic'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('italic')
|
|
},
|
|
'underline': {
|
|
'icon': 'underline',
|
|
'title': _('Underline'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('underline')
|
|
},
|
|
'strikethrough': {
|
|
'icon': 'strikethrough',
|
|
'title': _('Strikethrough'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('strikeThrough')
|
|
},
|
|
'superscript': {
|
|
'icon': 'superscript',
|
|
'title': _('Superscript'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('superscript')
|
|
},
|
|
'subscript': {
|
|
'icon': 'subscript',
|
|
'title': _('Subscript'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('subscript')
|
|
},
|
|
'hr': {
|
|
'icon': 'hr',
|
|
'title': _('Insert separator'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('insertHorizontalRule')
|
|
},
|
|
'format-block': {
|
|
'icon': 'heading',
|
|
'title': _('Style the selected text block'),
|
|
'execute': def (editor, activated):
|
|
choose_block_style(insert_heading.bind(None, editor.id))
|
|
},
|
|
'ul': {
|
|
'icon': 'ul',
|
|
'title': _('Unordered list'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('insertUnorderedList')
|
|
},
|
|
'ol': {
|
|
'icon': 'ol',
|
|
'title': _('Ordered list'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('insertOrderedList')
|
|
},
|
|
'indent': {
|
|
'icon': 'indent',
|
|
'title': _('Increase indentation'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('indent')
|
|
},
|
|
'outdent': {
|
|
'icon': 'outdent',
|
|
'title': _('Decrease indentation'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('outdent')
|
|
},
|
|
'justify-left': {
|
|
'icon': 'justify-left',
|
|
'title': _('Align left'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('justifyLeft')
|
|
},
|
|
'justify-full': {
|
|
'icon': 'justify-full',
|
|
'title': _('Align justified'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('justifyFull')
|
|
},
|
|
'justify-center': {
|
|
'icon': 'justify-center',
|
|
'title': _('Align center'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('justifyCenter')
|
|
},
|
|
'justify-right': {
|
|
'icon': 'justify-right',
|
|
'title': _('Align right'),
|
|
'execute': def (editor, activated):
|
|
editor.exec_command('justifyRight')
|
|
},
|
|
'insert-link': {
|
|
'icon': 'insert-link',
|
|
'title': _('Insert a link or linked image'),
|
|
'execute': def (editor, activated):
|
|
insert_link(_('Insert a link'), _('Enter the link URL and optionally the link name'), insert_link_or_image.bind(None, editor.id, False))
|
|
},
|
|
'insert-image': {
|
|
'icon': 'image',
|
|
'title': _('Insert an image'),
|
|
'execute': def (editor, activated):
|
|
insert_link(_('Insert an image'), _('Enter the image URL and optionally the image name'), insert_link_or_image.bind(None, editor.id, True))
|
|
},
|
|
'fg': {
|
|
'icon': 'fg',
|
|
'title': _('Change the text color'),
|
|
'execute': def (editor, activated):
|
|
set_color(_('Choose text color'), _('Choose the color below'), change_color.bind(None, editor.id, 'foreColor'))
|
|
},
|
|
'bg': {
|
|
'icon': 'bg',
|
|
'title': _('Change the highlight color'),
|
|
'execute': def (editor, activated):
|
|
set_color(_('Choose highlight color'), _('Choose the color below'), change_color.bind(None, editor.id, 'hiliteColor'))
|
|
},
|
|
}
|
|
return all_editor_actions.ans
|
|
# }}}
|
|
|
|
|
|
class CommentsEditorBoss:
|
|
|
|
def __init__(self):
|
|
handlers = {
|
|
'initialize': self.initialize,
|
|
'set_html': self.set_html,
|
|
'get_html': self.get_html,
|
|
'exec_command': self.exec_command,
|
|
'focus': self.focus,
|
|
}
|
|
self.comm = IframeClient(handlers)
|
|
|
|
def initialize(self, data):
|
|
window.onerror = self.onerror
|
|
clear(document.body)
|
|
document.execCommand("defaultParagraphSeparator", False, "div")
|
|
document.execCommand("styleWithCSS", False, False)
|
|
document.body.style.margin = '0'
|
|
document.body.style.padding = '0'
|
|
document.documentElement.style.height = document.body.style.height = '100%'
|
|
document.documentElement.style.overflow = document.body.style.overflow = 'hidden'
|
|
document.body.style.fontFamily = window.default_font_family
|
|
document.body.appendChild(E.div(style='width: 100%; height: 100%; padding: 0; margin: 0; border: solid 3px transparent; box-sizing: border-box'))
|
|
document.body.lastChild.contentEditable = True
|
|
document.body.lastChild.addEventListener('keyup', self.update_state)
|
|
document.body.lastChild.addEventListener('mouseup', self.update_state)
|
|
document.body.lastChild.focus()
|
|
self.update_state()
|
|
|
|
def focus(self):
|
|
document.body.lastChild.focus()
|
|
|
|
def onerror(self, msg, script_url, line_number, column_number, error_object):
|
|
if error_object is None:
|
|
# This happens for cross-domain errors (probably javascript injected
|
|
# into the browser via extensions/ userscripts and the like). It also
|
|
# happens all the time when using Chrome on Safari
|
|
console.log(f'Unhandled error from external javascript, ignoring: {msg} {script_url} {line_number}')
|
|
else:
|
|
console.log(error_object)
|
|
|
|
def set_html(self, data):
|
|
document.body.lastChild.innerHTML = data.html
|
|
|
|
def get_html(self, data):
|
|
self.comm.send_message('html', html=document.body.lastChild.innerHTML)
|
|
|
|
def exec_command(self, data):
|
|
document.execCommand(data.name, False, data.value)
|
|
self.update_state()
|
|
|
|
def update_state(self):
|
|
state = {name: document.queryCommandState(name) for name in 'bold italic underline superscript subscript'.split(' ')}
|
|
state.strikethrough = document.queryCommandState('strikeThrough')
|
|
self.comm.send_message('update_state', state=state)
|
|
|
|
|
|
|
|
registry = {}
|
|
|
|
def add_editor(editor):
|
|
for k in Object.keys(registry):
|
|
if not document.getElementById(k):
|
|
registry[k].destroy()
|
|
v'delete registry[k]'
|
|
registry[editor.id] = editor
|
|
|
|
|
|
class Editor:
|
|
|
|
def __init__(self, iframe):
|
|
handlers = {
|
|
'ready': self.on_iframe_ready,
|
|
'html': self.on_html_received,
|
|
'update_state': self.update_state,
|
|
}
|
|
self.iframe_wrapper = IframeWrapper(handlers, iframe, 'book_list.comments_editor', _('Loading comments editor...'))
|
|
self.id = ensure_id(iframe)
|
|
self.ready = False
|
|
self.pending_set_html = None
|
|
self.get_html_callbacks = v'[]'
|
|
|
|
def init(self):
|
|
self.iframe_wrapper.init()
|
|
|
|
def destroy(self):
|
|
self.iframe_wrapper.destroy()
|
|
self.get_html_callbacks = v'[]'
|
|
|
|
def focus(self):
|
|
self.iframe.contentWindow.focus()
|
|
if self.ready:
|
|
self.iframe_wrapper.send_message('focus')
|
|
|
|
@property
|
|
def iframe(self):
|
|
return self.iframe_wrapper.iframe
|
|
|
|
def on_iframe_ready(self, msg):
|
|
self.ready = True
|
|
return self.after_iframe_initialized
|
|
|
|
def after_iframe_initialized(self):
|
|
if self.pending_set_html is not None:
|
|
self.set_html(self.pending_set_html)
|
|
self.pending_set_html = None
|
|
if self.get_html_callbacks.length:
|
|
self.get_html(self.get_html_callback)
|
|
|
|
def set_html(self, html):
|
|
if not self.ready:
|
|
self.pending_set_html = html
|
|
return
|
|
self.iframe_wrapper.send_message('set_html', html=html)
|
|
|
|
def get_html(self, proceed):
|
|
self.get_html_callbacks.push(proceed)
|
|
if self.ready:
|
|
self.iframe_wrapper.send_message('get_html')
|
|
|
|
def on_html_received(self, data):
|
|
if self.get_html_callbacks.length:
|
|
for f in self.get_html_callbacks:
|
|
f(data.html)
|
|
self.get_html_callbacks = v'[]'
|
|
|
|
def exec_command(self, name, value=None):
|
|
if self.ready:
|
|
self.iframe_wrapper.send_message('exec_command', name=name, value=value)
|
|
|
|
def update_state(self, data):
|
|
c = self.iframe.closest('.' + CLASS_NAME)
|
|
for name in Object.keys(data.state):
|
|
div = c.querySelector(f'.{TOOLBAR_CLASS} > [name="{name}"]')
|
|
if div:
|
|
if data.state[name]:
|
|
div.classList.add('activated')
|
|
else:
|
|
div.classList.remove('activated')
|
|
|
|
|
|
def create_editor():
|
|
iframe = E.iframe(sandbox='allow-scripts', seamless=True, style='flex-grow: 10', id=self.id)
|
|
editor = Editor(iframe)
|
|
add_editor(editor)
|
|
return iframe, editor
|
|
|
|
|
|
def action_activated(editor_id, ac_name, evt):
|
|
editor = registry[editor_id]
|
|
if not editor:
|
|
return
|
|
action = all_editor_actions()[ac_name]
|
|
if not action:
|
|
return
|
|
button = evt.currentTarget
|
|
action.execute(editor, button.classList.contains('activated'))
|
|
editor.focus()
|
|
|
|
|
|
def add_action(toolbar, ac_name, action, editor_id):
|
|
b = E.div(svgicon(action.icon), title=action.title, onclick=action_activated.bind(None, editor_id, ac_name), name=ac_name)
|
|
toolbar.appendChild(b)
|
|
|
|
|
|
def create_comments_editor(container):
|
|
iframe, editor = create_editor()
|
|
toolbars = E.div(style='flex-grow: 0')
|
|
toolbar1 = E.div(class_=TOOLBAR_CLASS)
|
|
toolbar2 = E.div(class_=TOOLBAR_CLASS)
|
|
toolbars.appendChild(toolbar1), toolbars.appendChild(toolbar2)
|
|
acmap = all_editor_actions()
|
|
|
|
def add(toolbar, ac_name):
|
|
if ac_name:
|
|
if acmap[ac_name]:
|
|
add_action(toolbar, ac_name, acmap[ac_name], editor.id)
|
|
else:
|
|
toolbar.appendChild(E.div(class_='sep'))
|
|
|
|
for ac_name in 'undo redo select-all remove-format | bold italic underline strikethrough | hr superscript subscript format-block'.split(' '):
|
|
add(toolbar1, ac_name)
|
|
|
|
for ac_name in 'ul ol indent outdent | justify-left justify-center justify-right justify-full | insert-link insert-image fg bg'.split(' '):
|
|
add(toolbar2, ac_name)
|
|
|
|
container.setAttribute('style', (container.getAttribute('style') or '') + ';display: flex; flex-direction: column; align-items: stretch')
|
|
container.appendChild(toolbars)
|
|
container.appendChild(iframe)
|
|
container.classList.add(CLASS_NAME)
|
|
return editor
|
|
|
|
|
|
def focus_comments_editor(container):
|
|
iframe = container.querySelector('iframe')
|
|
editor = registry[iframe.getAttribute('id')]
|
|
if editor:
|
|
editor.focus()
|
|
|
|
|
|
def set_comments_html(container, html):
|
|
iframe = container.querySelector('iframe')
|
|
eid = iframe.getAttribute('id')
|
|
editor = registry[eid]
|
|
editor.set_html(html or '')
|
|
|
|
|
|
def get_comments_html(container, proceed):
|
|
iframe = container.querySelector('iframe')
|
|
eid = iframe.getAttribute('id')
|
|
editor = registry[eid]
|
|
editor.get_html(proceed)
|
|
|
|
|
|
def develop(container):
|
|
container.setAttribute('style', 'width: 100%; min-height: 90vh; display: flex; flex-direction: column; align-items: stretch')
|
|
editor = create_comments_editor(container)
|
|
set_comments_html(container, '<p>Testing, <i>testing</i> 123...')
|
|
focus_comments_editor(container)
|
|
editor.init()
|
|
|
|
|
|
def main():
|
|
main.boss = CommentsEditorBoss()
|