view src/browser/richtext.py @ 463:b62c1cf0dbf7

browser side: a new message being edited should always stay on top position (or last position for a comment)
author souliane <souliane@mailoo.org>
date Wed, 04 Jun 2014 01:27:11 +0200
parents 981ed669d3b3
children
line wrap: on
line source

#!/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
from dialog import ConfirmDialog, InfoDialog
from base_panels import TitlePanel, BaseTextEditor, HTMLTextEditor
from list_manager import ListManager
from html_tools import html_sanitize
import panels


class RichTextEditor(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()
        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"""
        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 = 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 = 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
        BaseTextEditor.edit(self, edit, abort, sync)  # after the UI has been refreshed
        if (edit and abort):
            return  # self.abortEdition is called by BaseTextEditor.edit
        self.setWysiwyg(False, init=True)  # after 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 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_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 = 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
        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:
            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(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())
        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)