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)