Mercurial > libervia-web
diff src/browser/sat_browser/richtext.py @ 467:97c72fe4a5f2
browser_side: import fixes:
- moved browser modules in a sat_browser packages, to avoid import conflicts with std lib (e.g. logging), and let pyjsbuild work normaly
- refactored bad import practices: classes are most of time not imported directly, module is imported instead.
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 09 Jun 2014 22:15:26 +0200 |
parents | src/browser/richtext.py@981ed669d3b3 |
children | b07f0fe2763a |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/richtext.py Mon Jun 09 22:15:26 2014 +0200 @@ -0,0 +1,536 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2013, 2014 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 pyjamas.ui.TextArea import TextArea +from pyjamas.ui.Button import Button +from pyjamas.ui.CheckBox import CheckBox +from pyjamas.ui.DialogBox import DialogBox +from pyjamas.ui.Label import Label +from pyjamas.ui.HTML import HTML +from pyjamas.ui.FlexTable import FlexTable +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas import Window +from pyjamas.ui.KeyboardListener import KeyboardHandler +from __pyjamas__ import doc + +from constants import Const as C +import dialog +import base_panels +import list_manager +import html_tools +import panels + + +class RichTextEditor(base_panels.BaseTextEditor, FlexTable): + """Panel for the rich text editor.""" + + def __init__(self, host, content=None, modifiedCb=None, afterEditCb=None, options=None, style=None): + """ + @param host: the SatWebFrontend instance + @param content: dict with at least a 'text' key + @param modifiedCb: method to be called when the text has been modified + @param afterEditCb: method to be called when the edition is done + @param options: list of UI options (see self.readOptions) + """ + self.host = host + self._debug = False # TODO: don't forget to set it False before commit + self.wysiwyg = False + self.__readOptions(options) + self.style = {'main': 'richTextEditor', + 'title': 'richTextTitle', + 'toolbar': 'richTextToolbar', + 'textarea': 'richTextArea'} + if isinstance(style, dict): + self.style.update(style) + self._prepareUI() + base_panels.BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb) + + def __readOptions(self, options): + """Set the internal flags according to the given options.""" + if options is None: + options = [] + self.read_only = 'read_only' in options + self.update_msg = 'update_msg' in options + self.no_title = 'no_title' in options or self.read_only + self.no_command = 'no_command' in options or self.read_only + + def _prepareUI(self, y_offset=0): + """Prepare the UI to host title panel, toolbar, text area... + @param y_offset: Y offset to start from (extra rows on top)""" + if not self.read_only: + self.title_offset = y_offset + self.toolbar_offset = self.title_offset + (0 if self.no_title else 1) + self.content_offset = self.toolbar_offset + (len(composition.RICH_SYNTAXES) if self._debug else 1) + self.command_offset = self.content_offset + 1 + else: + self.title_offset = self.toolbar_offset = self.content_offset = y_offset + self.command_offset = self.content_offset + 1 + FlexTable.__init__(self, self.command_offset + (0 if self.no_command else 1), 2) + self.addStyleName(self.style['main']) + + def addEditListener(self, listener): + """Add a method to be called whenever the text is edited. + @param listener: method taking two arguments: sender, keycode""" + base_panels.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', '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 = base_panels.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.no_title and not hasattr(self, 'title_panel'): + self.title_panel = base_panels.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.no_command 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 + count = 0 + for syntax in composition.RICH_SYNTAXES.keys() if self._debug else [syntax]: + 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(_('WYSIWYG edition')) + 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 + count, 0, 2) + self.setWidget(self.toolbar_offset + count, 0, self.toolbar) + count += 1 + + 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 TypeError: + pass + try: + if wysiwyg: + self.syntax_label.addStyleName('transparent') + else: + self.syntax_label.removeStyleName('transparent') + except 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())}) + 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 + base_panels.BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed + if (edit and abort): + return # self.abortEdition is called by base_panels.BaseTextEditor.edit + self.setWysiwyg(False, init=True) # after base_panels.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'] != '') + 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 base_panels.HTMLTextEditor which is used for display/wysiwyg""" + content = self._original_content + text = content['text'] + if 'title' in content and content['title']: + text = '<h1>%s</h1>%s' % (html_tools.html_sanitize(content['title']), content['text']) + self.display.setContent({'text': 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 RichMessageEditor(RichTextEditor): + """Use the rich text editor for sending messages with extended addressing. + Recipient panels are on top and data may be synchronized from/to the unibox.""" + + @classmethod + def getOrCreate(cls, host, parent=None, callback=None): + """Get or create the message editor associated to that host. + Add it to parent if parent is not None, otherwise display it + in a popup dialog. + @param host: the host + @param parent: parent panel (or None to display in a popup). + @return: the RichTextEditor instance if parent is not None, + otherwise a popup DialogBox containing the RichTextEditor. + """ + if not hasattr(host, 'richtext'): + modifiedCb = lambda content: True + + def afterEditCb(content): + if hasattr(host.richtext, 'popup'): + host.richtext.popup.hide() + else: + host.richtext.setVisible(False) + callback() + options = ['no_title'] + style = {'main': 'richMessageEditor', 'textarea': 'richMessageArea'} + host.richtext = RichMessageEditor(host, None, modifiedCb, afterEditCb, options, style) + + def add(widget, parent): + if widget.getParent() is not None: + if widget.getParent() != parent: + widget.removeFromParent() + parent.add(widget) + else: + parent.add(widget) + widget.setVisible(True) + widget.initialized = False # fake a new creation + widget.edit(True) + + if parent is None: + if not hasattr(host.richtext, 'popup'): + host.richtext.popup = DialogBox(autoHide=False, centered=True) + host.richtext.popup.setHTML("Compose your message") + host.richtext.popup.add(host.richtext) + add(host.richtext, host.richtext.popup) + host.richtext.popup.center() + else: + add(host.richtext, parent) + return host.richtext.popup if parent is None else host.richtext + + def _prepareUI(self, y_offset=0): + """Prepare the UI to host recipients panel, toolbar, text area... + @param y_offset: Y offset to start from (extra rows on top)""" + self.recipient_offset = y_offset + self.recipient_spacer_offset = self.recipient_offset + len(composition.RECIPIENT_TYPES) + RichTextEditor._prepareUI(self, self.recipient_spacer_offset + 1) + + def refresh(self, edit=None): + """Refresh the UI between 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() + RichTextEditor.refresh(self, edit) + + for widget in ['recipient', 'recipient_spacer']: + if hasattr(self, widget): + getattr(self, widget).setVisible(edit) + + if not edit: + return + + if not hasattr(self, 'recipient'): + # recipient types sub-panels are automatically added by the manager + self.recipient = RecipientManager(self, self.recipient_offset) + self.recipient.createWidgets(title_format="%s: ") + self.recipient_spacer = HTML('') + self.recipient_spacer.setStyleName('recipientSpacer') + self.getFlexCellFormatter().setColSpan(self.recipient_spacer_offset, 0, 2) + self.setWidget(self.recipient_spacer_offset, 0, self.recipient_spacer) + + if not hasattr(self, 'sync_button'): + self.sync_button = Button("Back to quick box", lambda: self.edit(True, sync=True)) + self.command.insert(self.sync_button, 1) + + def syncToEditor(self): + """Synchronize from unibox.""" + def setContent(target, data): + if hasattr(self, 'recipient'): + self.recipient.setContacts({"To": [target]} if target else {}) + self.setContent({'text': data if data else '', 'syntax': ''}) + self.textarea.setText(data if data else '') + data, target = self.host.uni_box.getTargetAndData() if self.host.uni_box else (None, None) + setContent(target, data) + + def __syncToUniBox(self, recipients=None, emptyText=False): + """Synchronize to unibox if a maximum of one recipient is set. + @return True if the sync could be done, False otherwise""" + if not self.host.uni_box: + return + setText = lambda: self.host.uni_box.setText("" if emptyText else self.getContent()['text']) + if not hasattr(self, 'recipient'): + setText() + return True + if recipients is None: + recipients = self.recipient.getContacts() + target = "" + # we could eventually allow more in the future + allowed = 1 + for key in recipients: + count = len(recipients[key]) + if count == 0: + continue + allowed -= count + if allowed < 0: + return False + # TODO: change this if later more then one recipients are allowed + target = recipients[key][0] + setText() + if target == "": + return True + if target.startswith("@"): + _class = panels.MicroblogPanel + target = None if target == "@@" else target[1:] + else: + _class = panels.ChatPanel + self.host.getOrCreateLiberviaWidget(_class, target) + return True + + def syncFromEditor(self, content): + """Synchronize to unibox and close the dialog afterward. Display + a message and leave the dialog open if the sync was not possible.""" + if self.__syncToUniBox(): + self._afterEditCb(content) + return + dialog.InfoDialog("Too many recipients", + "A message with more than one direct recipient (To)," + + " or with any special recipient (Cc or Bcc), could not be" + + " stored in the quick box.\n\nPlease finish your composing" + + " in the rich text editor, and send your message directly" + + " from here.", Width="400px").center() + + def edit(self, edit=True, abort=False, sync=False): + if not edit and not abort and not sync: # force sending message even when the text has not been modified + if not self.__sendMessage(): # message has not been sent (missing information), do nothing + return + RichTextEditor.edit(self, edit, abort, sync) + + def __sendMessage(self): + """Send the message.""" + recipients = self.recipient.getContacts() + targets = [] + for addr in recipients: + for recipient in recipients[addr]: + if recipient.startswith("@"): + targets.append(("PUBLIC", None, addr) if recipient == "@@" else ("GROUP", recipient[1:], addr)) + else: + targets.append(("chat", recipient, addr)) + # check that we actually have a message target and data + content = self.getContent() + if content['text'] == "" or len(targets) == 0: + dialog.InfoDialog("Missing information", + "Some information are missing and the message hasn't been sent.", Width="400px").center() + return None + self.__syncToUniBox(recipients, emptyText=True) + extra = {'content_rich': content['text']} + if hasattr(self, 'title_panel'): + extra.update({'title': content['title']}) + self.host.send(targets, content['text'], extra=extra) + return True + + +class RecipientManager(list_manager.ListManager): + """A manager for sub-panels to set the recipients for each recipient type.""" + + def __init__(self, parent, y_offset=0): + # TODO: be sure we also display empty groups and disconnected contacts + their groups + # store the full list of potential recipients (groups and contacts) + list_ = [] + list_.append("@@") + list_.extend("@%s" % group for group in parent.host.contact_panel.getGroups()) + list_.extend(contact for contact in parent.host.contact_panel.getContacts()) + list_manager.ListManager.__init__(self, parent, composition.RECIPIENT_TYPES, list_, {'y': y_offset}) + + self.registerPopupMenuPanel(entries=composition.RECIPIENT_TYPES, + hide=lambda sender, key: self.__children[key]["panel"].isVisible(), + callback=self.setContactPanelVisible) + + +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)