Mercurial > libervia-web
diff browser/sat_browser/richtext.py @ 1124:28e3eb3bb217
files reorganisation and installation rework:
- files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory)
- VERSION file is now used, as for other SàT projects
- replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly
- removed check for data_dir if it's empty
- installation tested working in virtual env
- libervia launching script is now in bin/libervia
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 25 Aug 2018 17:59:48 +0200 |
parents | src/browser/sat_browser/richtext.py@2d15b484ca33 |
children | 3048bd137aaf |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/sat_browser/richtext.py Sat Aug 25 17:59:48 2018 +0200 @@ -0,0 +1,360 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat_frontends.tools import composition +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools.common import data_format + +from pyjamas.ui.TextArea import TextArea +from pyjamas.ui.Button import Button +from pyjamas.ui.CheckBox import CheckBox +from pyjamas.ui.Label import Label +from pyjamas.ui.FlexTable import FlexTable +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.KeyboardListener import KeyboardHandler +from pyjamas import Window +from __pyjamas__ import doc + +from constants import Const as C +import dialog +import base_panel +import editor_widget +import html_tools +import list_manager + + +class RichTextEditor(editor_widget.BaseTextEditor, FlexTable): + """Panel for the rich text editor.""" + + STYLE = {'main': 'richTextEditor', + 'title': 'richTextTitle', + 'toolbar': 'richTextToolbar', + 'textarea': 'richTextArea' + } + + def __init__(self, host, content=None, modifiedCb=None, afterEditCb=None, options=None): + """ + + @param host (SatWebFrontend): host instance + @param content (dict): dict with at least a 'text' key + @param modifiedCb (callable): to be called when the text has been modified + @param afterEditCb (callable): to be called when the edition is done + @param options (list[unicode]): UI options ("read_only", "update_msg") + """ + FlexTable.__init__(self) # FIXME + self.host = host + self.wysiwyg = False + self.read_only = 'read_only' in options + self.update_msg = 'update_msg' in options + + indices = (-1, -1, 0, -1, -1) if self.read_only else (0, 1, 2, 3, 4) + self.title_offset, self.toolbar_offset, self.content_offset, self.tags_offset, self.command_offset = indices + self.addStyleName(self.STYLE['main']) + + editor_widget.BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb) + + def addEditListener(self, listener): + """Add a method to be called whenever the text is edited. + + @param listener: method taking two arguments: sender, keycode + """ + editor_widget.BaseTextEditor.addEditListener(self, listener) + if hasattr(self, 'display'): + self.display.addEditListener(listener) + + def refresh(self, edit=None): + """Refresh the UI for edition/display mode. + + @param edit: set to True to display the edition mode + """ + if edit is None: + edit = hasattr(self, 'textarea') and self.textarea.getVisible() + + for widget in ['title_panel', 'tags_panel', 'command']: + if hasattr(self, widget): + getattr(self, widget).setVisible(edit) + + if hasattr(self, 'toolbar'): + self.toolbar.setVisible(False) + + if not hasattr(self, 'display'): + self.display = editor_widget.HTMLTextEditor(options={'enhance_display': False, 'listen_keyboard': False}) # for display mode + for listener in self.edit_listeners: + self.display.addEditListener(listener) + + if not self.read_only and not hasattr(self, 'textarea'): + self.textarea = EditTextArea(self) # for edition mode + self.textarea.addStyleName(self.STYLE['textarea']) + + self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2) + if edit and not self.wysiwyg: + self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why + self.setWidget(self.content_offset, 0, self.textarea) + else: + self.setWidget(self.content_offset, 0, self.display) + if not edit: + return + + if not self.read_only and not hasattr(self, 'title_panel'): + self.title_panel = base_panel.TitlePanel() + self.title_panel.addStyleName(self.STYLE['title']) + self.getFlexCellFormatter().setColSpan(self.title_offset, 0, 2) + self.setWidget(self.title_offset, 0, self.title_panel) + + if not self.read_only and not hasattr(self, 'tags_panel'): + suggested_tags = [] # TODO: feed this list with tags suggestion + self.tags_panel = list_manager.TagsPanel(suggested_tags) + self.getFlexCellFormatter().setColSpan(self.tags_offset, 0, 2) + self.setWidget(self.tags_offset, 0, self.tags_panel) + + if not self.read_only and not hasattr(self, 'command'): + self.command = HorizontalPanel() + self.command.addStyleName("marginAuto") + self.command.add(Button("Cancel", lambda: self.edit(True, True))) + self.command.add(Button("Update" if self.update_msg else "Send message", lambda: self.edit(False))) + self.getFlexCellFormatter().setColSpan(self.command_offset, 0, 2) + self.setWidget(self.command_offset, 0, self.command) + + def setToolBar(self, syntax): + """This method is called asynchronously after the parameter + holding the rich text syntax is retrieved. It is called at + each call of self.edit(True) because the user may + have change his setting since the last time.""" + if syntax is None or syntax not in composition.RICH_SYNTAXES.keys(): + syntax = composition.RICH_SYNTAXES.keys()[0] + if hasattr(self, "toolbar") and self.toolbar.syntax == syntax: + self.toolbar.setVisible(True) + return + self.toolbar = HorizontalPanel() + self.toolbar.syntax = syntax + self.toolbar.addStyleName(self.STYLE['toolbar']) + for key in composition.RICH_SYNTAXES[syntax].keys(): + self.addToolbarButton(syntax, key) + self.wysiwyg_button = CheckBox(_('preview')) + wysiywgCb = lambda sender: self.setWysiwyg(sender.getChecked()) + self.wysiwyg_button.addClickListener(wysiywgCb) + self.toolbar.add(self.wysiwyg_button) + self.syntax_label = Label(_("Syntax: %s") % syntax) + self.syntax_label.addStyleName("richTextSyntaxLabel") + self.toolbar.add(self.syntax_label) + self.toolbar.setCellWidth(self.syntax_label, "100%") + self.getFlexCellFormatter().setColSpan(self.toolbar_offset, 0, 2) + self.setWidget(self.toolbar_offset, 0, self.toolbar) + + def setWysiwyg(self, wysiwyg, init=False): + """Toggle the edition mode between rich content syntax and wysiwyg. + @param wysiwyg: boolean value + @param init: set to True to re-init without switching the widgets.""" + def setWysiwyg(): + self.wysiwyg = wysiwyg + try: + self.wysiwyg_button.setChecked(wysiwyg) + except (AttributeError, TypeError): + pass + try: + if wysiwyg: + self.syntax_label.addStyleName('transparent') + else: + self.syntax_label.removeStyleName('transparent') + except (AttributeError, TypeError): + pass + if not wysiwyg: + self.display.removeStyleName('richTextWysiwyg') + + if init: + setWysiwyg() + return + + self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2) + if wysiwyg: + def syntaxConvertCb(text): + self.display.setContent({'text': text}) + self.textarea.removeFromParent() # XXX: force as it is not always done... + self.setWidget(self.content_offset, 0, self.display) + self.display.addStyleName('richTextWysiwyg') + self.display.edit(True) + content = self.getContent() + if content['text'] and content['syntax'] != C.SYNTAX_XHTML: + self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'], C.SYNTAX_XHTML) + else: + syntaxConvertCb(content['text']) + else: + syntaxConvertCb = lambda text: self.textarea.setText(text) + text = self.display.getContent()['text'] + if text and self.toolbar.syntax != C.SYNTAX_XHTML: + self.host.bridge.call('syntaxConvert', syntaxConvertCb, text) + else: + syntaxConvertCb(text) + self.setWidget(self.content_offset, 0, self.textarea) + self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why + + setWysiwyg() # do it in the end because it affects self.getContent + + def addToolbarButton(self, syntax, key): + """Add a button with the defined parameters.""" + button = Button('<img src="%s" class="richTextIcon" />' % + composition.RICH_BUTTONS[key]["icon"]) + button.setTitle(composition.RICH_BUTTONS[key]["tip"]) + button.addStyleName('richTextToolButton') + self.toolbar.add(button) + + def buttonCb(): + """Generic callback for a toolbar button.""" + text = self.textarea.getText() + cursor_pos = self.textarea.getCursorPos() + selection_length = self.textarea.getSelectionLength() + data = composition.RICH_SYNTAXES[syntax][key] + if selection_length == 0: + middle_text = data[1] + else: + middle_text = text[cursor_pos:cursor_pos + selection_length] + self.textarea.setText(text[:cursor_pos] + + data[0] + + middle_text + + data[2] + + text[cursor_pos + selection_length:]) + self.textarea.setCursorPos(cursor_pos + len(data[0]) + len(middle_text)) + self.textarea.setFocus(True) + self.textarea.onKeyDown() + + def wysiwygCb(): + """Callback for a toolbar button while wysiwyg mode is enabled.""" + data = composition.COMMANDS[key] + + def execCommand(command, arg): + self.display.setFocus(True) + doc().execCommand(command, False, arg.strip() if arg else '') + # use Window.prompt instead of dialog.PromptDialog to not loose the focus + prompt = lambda command, text: execCommand(command, Window.prompt(text)) + if isinstance(data, tuple) or isinstance(data, list): + if data[1]: + prompt(data[0], data[1]) + else: + execCommand(data[0], data[2]) + else: + execCommand(data, False, '') + self.textarea.onKeyDown() + + button.addClickListener(lambda: wysiwygCb() if self.wysiwyg else buttonCb()) + + def getContent(self): + assert(hasattr(self, 'textarea')) + assert(hasattr(self, 'toolbar')) + if self.wysiwyg: + content = {'text': self.display.getContent()['text'], 'syntax': C.SYNTAX_XHTML} + else: + content = {'text': self.strproc(self.textarea.getText()), 'syntax': self.toolbar.syntax} + if hasattr(self, 'title_panel'): + content.update({'title': self.strproc(self.title_panel.getText())}) + if hasattr(self, 'tags_panel'): + data_format.iter2dict('tag', self.tags_panel.getTags(), content) + return content + + def edit(self, edit=False, abort=False, sync=False): + """ + Remark: the editor must be visible before you call this method. + @param edit: set to True to edit the content or False to only display it + @param abort: set to True to cancel the edition and loose the changes. + If edit and abort are both True, self.abortEdition can be used to ask for a + confirmation. When edit is False and abort is True, abortion is actually done. + @param sync: set to True to cancel the edition after the content has been saved somewhere else + """ + if not (edit and abort): + self.refresh(edit) # not when we are asking for a confirmation + editor_widget.BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed + if (edit and abort): + return # self.abortEdition is called by editor_widget.BaseTextEditor.edit + self.setWysiwyg(False, init=True) # after editor_widget.BaseTextEditor (it affects self.getContent) + if sync: + return + # the following must NOT be done at each UI refresh! + content = self._original_content + if edit: + def getParamCb(syntax): + # set the editable text in the current user-selected syntax + def syntaxConvertCb(text=None): + if text is not None: + # Important: this also update self._original_content + content.update({'text': text}) + content.update({'syntax': syntax}) + self.textarea.setText(content['text']) + + if hasattr(self, 'title_panel') and 'title' in content: + self.title_panel.setText(content['title']) + self.title_panel.setStackVisible(0, content['title'] != '') + + if hasattr(self, 'tags_panel'): + tags = list(data_format.dict2iter('tag', content)) + self.tags_panel.setTags(tags) + self.tags_panel.setStackVisible(0, len(tags) > 0) + + self.setToolBar(syntax) + if content['text'] and content['syntax'] != syntax: + self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax']) + else: + syntaxConvertCb() + self.host.bridge.call('asyncGetParamA', getParamCb, composition.PARAM_NAME_SYNTAX, composition.PARAM_KEY_COMPOSITION) + else: + if not self.initialized: + # set the display text in XHTML only during init because a new MicroblogEntry instance is created after each modification + self.setDisplayContent() + self.display.edit(False) + + def setDisplayContent(self): + """Set the content of the editor_widget.HTMLTextEditor which is used for display/wysiwyg""" + content = self._original_content + text = content['text'] + if 'title' in content and content['title']: + title = '<h2>%s</h2>' % html_tools.html_sanitize(content['title']) + else: + title = "" + + tags = "" + for tag in data_format.dict2iter('tag', content): + tags += "<li><a>%s</a></li>" % html_tools.html_sanitize(tag) + if tags: + tags = '<ul class="mblog_tags">%s</ul>' % tags + + self.display.setContent({'text': "%s%s%s" % (title, tags, text)}) + + def setFocus(self, focus): + self.textarea.setFocus(focus) + + def abortEdition(self, content): + """Ask for confirmation before closing the dialog.""" + def confirm_cb(answer): + if answer: + self.edit(False, True) + _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to %s?" % ("cancel your changes" if self.update_msg else "cancel this message")) + _dialog.cancel_button.setText(_("No")) + _dialog.show() + + +class EditTextArea(TextArea, KeyboardHandler): + def __init__(self, _parent): + TextArea.__init__(self) + self._parent = _parent + KeyboardHandler.__init__(self) + self.addKeyboardListener(self) + + def onKeyDown(self, sender=None, keycode=None, modifiers=None): + for listener in self._parent.edit_listeners: + listener(self, keycode, modifiers) # FIXME: edit_listeners must either be removed, or send an action instead of keycode/modifiers