# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2018, Kovid Goyal from __python__ import bound_methods, hash_literals from elementmaker import E from gettext import gettext as _ from book_list.theme import get_color, get_color_as_rgba 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 = '' if is_image else '{}' 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.style.color = data.color 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 rgba = get_color_as_rgba('window-foreground') self.iframe_wrapper.send_message('set_html', html=html, color=f'rgba({rgba[0]},{rgba[1]},{rgba[2]},{rgba[3]})') 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; border: solid 1px currentColor', 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) toolbar3 = E.div(class_=TOOLBAR_CLASS) toolbars.appendChild(toolbar1), toolbars.appendChild(toolbar2), toolbars.appendChild(toolbar3) 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'.split(' '): add(toolbar1, ac_name) for ac_name in 'hr superscript subscript format-block ul ol indent outdent'.split(' '): add(toolbar2, ac_name) for ac_name in 'justify-left justify-center justify-right justify-full insert-link insert-image fg bg'.split(' '): add(toolbar3, 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, '

Testing, testing 123...') focus_comments_editor(container) editor.init() def main(): main.boss = CommentsEditorBoss()