view browser_side/base_panels.py @ 412:9977de10b7da

browser_side: editors do not listen for <escape> + display WYSIWIG in upper case
author souliane <souliane@mailoo.org>
date Wed, 19 Mar 2014 20:27:46 +0100
parents 6a6551de4414
children bbdbee25123a
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Libervia: a Salut à Toi frontend
# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson <goffi@goffi.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/>.

import pyjd  # this is dummy in pyjs
from pyjamas.ui.AbsolutePanel import AbsolutePanel
from pyjamas.ui.VerticalPanel import VerticalPanel
from pyjamas.ui.HorizontalPanel import HorizontalPanel
from pyjamas.ui.HTMLPanel import HTMLPanel
from pyjamas.ui.Button import Button
from pyjamas.ui.HTML import HTML
from pyjamas.ui.SimplePanel import SimplePanel
from pyjamas.ui.PopupPanel import PopupPanel
from pyjamas.ui.StackPanel import StackPanel
from pyjamas.ui.TextArea import TextArea
from pyjamas.ui.Event import BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT
from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_SHIFT, KeyboardHandler
from pyjamas.ui.FocusListener import FocusHandler
from pyjamas.ui.ClickListener import ClickHandler
from pyjamas import DOM

from datetime import datetime
from time import time

from html_tools import html_sanitize, html_strip, inlineRoot, convertNewLinesToXHTML

from constants import Const
from sat_frontends.tools.strings import addURLToText, addURLToImage
from sat.core.i18n import _


class ChatText(HTMLPanel):

    def __init__(self, timestamp, nick, mymess, msg, xhtml=None):
        _date = datetime.fromtimestamp(float(timestamp or time()))
        _msg_class = ["chat_text_msg"]
        if mymess:
            _msg_class.append("chat_text_mymess")
        HTMLPanel.__init__(self, "<span class='chat_text_timestamp'>%(timestamp)s</span> <span class='chat_text_nick'>%(nick)s</span> <span class='%(msg_class)s'>%(msg)s</span>" %
                           {"timestamp": _date.strftime("%H:%M"),
                            "nick": "[%s]" % html_sanitize(nick),
                            "msg_class": ' '.join(_msg_class),
                            "msg": addURLToText(html_sanitize(msg)) if not xhtml else inlineRoot(xhtml)}  # FIXME: images and external links must be removed according to preferences
                           )
        self.setStyleName('chatText')


class Occupant(HTML):
    """Occupant of a MUC room"""

    def __init__(self, nick, state=None, special=""):
        """
        @param nick: the user nickname
        @param state: the user chate state (XEP-0085)
        @param special: a string of symbols (e.g: for activities) 
        """
        HTML.__init__(self)
        self.nick = nick
        self._state = state
        self.special = special
        self._refresh()

    def __str__(self):
        return self.nick

    def setState(self, state):
        self._state = state
        self._refresh()

    def addSpecial(self, special):
        """@param special: unicode"""
        if special not in self.special:
            self.special += special
            self._refresh()

    def removeSpecials(self, special):
        """@param special: unicode or list"""
        if not isinstance(special, list):
            special = [special]
        for symbol in special:
            self.special = self.special.replace(symbol, "")
            self._refresh()

    def _refresh(self):
        state = (' %s' % Const.MUC_USER_STATES[self._state]) if self._state else ''
        special = "" if len(self.special) == 0 else " %s" % self.special
        self.setHTML("<div class='occupant'>%s%s%s</div>" % (html_sanitize(self.nick), special, state))


class OccupantsList(AbsolutePanel):
    """Panel user to show occupants of a room"""

    def __init__(self):
        AbsolutePanel.__init__(self)
        self.occupants_list = {}
        self.setStyleName('occupantsList')

    def addOccupant(self, nick):
        _occupant = Occupant(nick)
        self.occupants_list[nick] = _occupant
        self.add(_occupant)

    def removeOccupant(self, nick):
        try:
            self.remove(self.occupants_list[nick])
        except KeyError:
            print "ERROR: trying to remove an unexisting nick"

    def clear(self):
        self.occupants_list.clear()
        AbsolutePanel.clear(self)

    def updateSpecials(self, occupants=[], html=""):
        """Set the specified html "symbol" to the listed occupants,
        and eventually remove it from the others (if they got it).
        This is used for example to visualize who is playing a game.
        @param occupants: list of the occupants that need the symbol
        @param html: unicode symbol (actually one character or more)
        or a list to assign different symbols of the same family.
        """
        index = 0
        special = html
        for occupant in self.occupants_list.keys():
            if occupant in occupants:
                if isinstance(html, list):
                    special = html[index]
                    index = (index + 1) % len(html)
                self.occupants_list[occupant].addSpecial(special)
            else:
                self.occupants_list[occupant].removeSpecials(html)


class PopupMenuPanel(PopupPanel):
    """This implementation of a popup menu (context menu) allow you to assign
    two special methods which are common to all the items, in order to hide
    certain items and also easily define their callbacks. The menu can be
    bound to any of the mouse button (left, middle, right).
    """
    def __init__(self, entries, hide=None, callback=None, vertical=True, style=None, **kwargs):
        """
        @param entries: a dict of dicts, where each sub-dict is representing
        one menu item: the sub-dict key can be used as the item text and
        description, but optional "title" and "desc" entries would be used
        if they exists. The sub-dicts may be extended later to do
        more complicated stuff or overwrite the common methods.
        @param hide: function  with 2 args: widget, key as string and
        returns True if that item should be hidden from the context menu.
        @param callback: function with 2 args: sender, key as string
        @param vertical: True or False, to set the direction
        @param item_style: alternative CSS class for the menu items
        @param menu_style: supplementary CSS class for the sender widget
        """
        PopupPanel.__init__(self, autoHide=True, **kwargs)
        self._entries = entries
        self._hide = hide
        self._callback = callback
        self.vertical = vertical
        self.style = {"selected": None, "menu": "recipientTypeMenu", "item": "popupMenuItem"}
        if isinstance(style, dict):
            self.style.update(style)
        self._senders = {}

    def _show(self, sender):
        """Popup the menu relative to this sender's position.
        @param sender: the widget that has been clicked
        """
        menu = VerticalPanel() if self.vertical is True else HorizontalPanel()
        menu.setStyleName(self.style["menu"])

        def button_cb(item):
            """You can not put that method in the loop and rely
            on _key, because it is overwritten by each step.
            You can rely on item.key instead, which is copied
            from _key after the item creation.
            @param item: the menu item that has been clicked
            """
            if self._callback is not None:
                self._callback(sender=sender, key=item.key)
            self.hide(autoClosed=True)

        for _key in self._entries.keys():
            entry = self._entries[_key]
            if self._hide is not None and self._hide(sender=sender, key=_key) is True:
                continue
            title = entry["title"] if "title" in entry.keys() else _key
            item = Button(title, button_cb)
            item.key = _key
            item.setStyleName(self.style["item"])
            item.setTitle(entry["desc"] if "desc" in entry.keys() else title)
            menu.add(item)
        if len(menu.getChildren()) == 0:
            return
        self.add(menu)
        if self.vertical is True:
            x = sender.getAbsoluteLeft() + sender.getOffsetWidth()
            y = sender.getAbsoluteTop()
        else:
            x = sender.getAbsoluteLeft()
            y = sender.getAbsoluteTop() + sender.getOffsetHeight()
        self.setPopupPosition(x, y)
        self.show()
        if self.style["selected"]:
            sender.addStyleDependentName(self.style["selected"])

        def _onHide(popup):
            if self.style["selected"]:
                sender.removeStyleDependentName(self.style["selected"])
            return PopupPanel.onHideImpl(self, popup)

        self.onHideImpl = _onHide

    def registerClickSender(self, sender, button=BUTTON_LEFT):
        """Bind the menu to the specified sender.
        @param sender: the widget to which the menu should be bound
        @param: BUTTON_LEFT, BUTTON_MIDDLE or BUTTON_RIGHT
        """
        self._senders.setdefault(sender, [])
        self._senders[sender].append(button)

        if button == BUTTON_RIGHT:
            # WARNING: to disable the context menu is a bit tricky...
            # The following seems to work on Firefox 24.0, but:
            # TODO: find a cleaner way to disable the context menu
            sender.getElement().setAttribute("oncontextmenu", "return false")

        def _onBrowserEvent(event):
            button = DOM.eventGetButton(event)
            if DOM.eventGetType(event) == "mousedown" and button in self._senders[sender]:
                self._show(sender)
            return sender.__class__.onBrowserEvent(sender, event)

        sender.onBrowserEvent = _onBrowserEvent

    def registerMiddleClickSender(self, sender):
        self.registerClickSender(sender, BUTTON_MIDDLE)

    def registerRightClickSender(self, sender):
        self.registerClickSender(sender, BUTTON_RIGHT)


class ToggleStackPanel(StackPanel):
    """This is a pyjamas.ui.StackPanel with modified behavior. All sub-panels ca be
    visible at the same time, clicking a sub-panel header will not display it and hide
    the others but only toggle its own visibility. The argument 'visibleStack' is ignored.
    Note that the argument 'visible' has been added to listener's 'onStackChanged' method.
    """

    def __init__(self, **kwargs):
        StackPanel.__init__(self, **kwargs)

    def onBrowserEvent(self, event):
        if DOM.eventGetType(event) == "click":
            index = self.getDividerIndex(DOM.eventGetTarget(event))
            if index != -1:
                self.toggleStack(index)

    def add(self, widget, stackText="", asHTML=False, visible=False):
        StackPanel.add(self, widget, stackText, asHTML)
        self.setStackVisible(self.getWidgetCount() - 1, visible)

    def toggleStack(self, index):
        if index >= self.getWidgetCount():
            return
        visible = not self.getWidget(index).getVisible()
        self.setStackVisible(index, visible)
        for listener in self.stackListeners:
            listener.onStackChanged(self, index, visible)


class TitlePanel(ToggleStackPanel):
    """A toggle panel to set the message title"""
    def __init__(self):
        ToggleStackPanel.__init__(self, Width="100%")
        self.text_area = TextArea()
        self.add(self.text_area, _("Title"))
        self.addStackChangeListener(self)

    def onStackChanged(self, sender, index, visible=None):
        if visible is None:
            visible = sender.getWidget(index).getVisible()
        text = self.text_area.getText()
        suffix = "" if (visible or not text) else (": %s" % text)
        sender.setStackText(index, _("Title") + suffix)

    def getText(self):
        return self.text_area.getText()

    def setText(self, text):
        self.text_area.setText(text)


class BaseTextEditor(object):
    """Basic definition of a text editor. The method edit gets a boolean parameter which
    should be set to True when you want to edit the text and False to only display it."""

    def __init__(self, content=None, strproc=None, modifiedCb=None, afterEditCb=None):
        """
        Remark when inheriting this class: since the setContent method could be
        overwritten by the child class, you should consider calling this __init__
        after all the parameters affecting this setContent method have been set.
        @param content: dict with at least a 'text' key
        @param strproc: method to be applied on strings to clean the content
        @param modifiedCb: method to be called when the text has been modified.
        If this method returns:
        - True: the modification will be saved and afterEditCb called;
        - False: the modification won't be saved and afterEditCb called;
        - None: the modification won't be saved and afterEditCb not called.
        @param afterEditCb: method to be called when the edition is done
        """
        if content is None:
            content = {'text': ''}
        assert('text' in content)
        if strproc is None:
            def strproc(text):
                try:
                    return text.strip()
                except (TypeError, AttributeError):
                    return text
        self.strproc = strproc
        self.__modifiedCb = modifiedCb
        self._afterEditCb = afterEditCb
        self.initialized = False
        self.edit_listeners = []
        self.setContent(content)

    def setContent(self, content=None):
        """Set the editable content. The displayed content, which is set from the child class, could differ.
        @param content: dict with at least a 'text' key
        """
        if content is None:
            content = {'text': ''}
        elif not isinstance(content, dict):
            content = {'text': content}
        assert('text' in content)
        self._original_content = {}
        for key in content:
            self._original_content[key] = self.strproc(content[key])

    def getContent(self):
        """Get the current edited or editable content.
        @return: dict with at least a 'text' key
        """
        raise NotImplementedError

    def setOriginalContent(self, content):
        """Use this method with care! Content initialization should normally be
        done with self.setContent. This method exists to let you trick the editor,
        e.g. for self.modified to return True also when nothing has been modified.
        @param content: dict
        """
        self._original_content = content

    def getOriginalContent(self):
        """
        @return the original content before modification (dict)
        """
        return self._original_content

    def modified(self, content=None):
        """Check if the content has been modified.
        Remark: we don't use the direct comparison because we want to ignore empty elements
        @content: content to be check against the original content or None to use the current content
        @return: True if the content has been modified.
        """
        if content is None:
            content = self.getContent()
        # the following method returns True if one non empty element exists in a but not in b
        diff1 = lambda a, b: [a[key] for key in set(a.keys()).difference(b.keys()) if a[key]] != []
        # the following method returns True if the values for the common keys are not equals
        diff2 = lambda a, b: [1 for key in set(a.keys()).intersection(b.keys()) if a[key] != b[key]] != []
        # finally the combination of both to return True if a difference is found
        diff = lambda a, b: diff1(a, b) or diff1(b, a) or diff2(a, b)

        return diff(content, self._original_content)

    def edit(self, edit, 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 edit:
            if not self.initialized:
                self.syncToEditor()  # e.g.: use the selected target and unibox content
            self.setFocus(True)
            if abort:
                content = self.getContent()
                if not self.modified(content) or self.abortEdition(content):  # e.g: ask for confirmation
                    self.edit(False, True, sync)
                    return
            if sync:
                self.syncFromEditor(content)  # e.g.: save the content to unibox
                return
        else:
            if not self.initialized:
                return
            content = self.getContent()
            if abort:
                self._afterEditCb(content)
                return
            if self.__modifiedCb and self.modified(content):
                result = self.__modifiedCb(content)  # e.g.: send a message or update something
                if result is not None:
                    if self._afterEditCb:
                        self._afterEditCb(content)  # e.g.: restore the display mode
                    if result is True:
                        self.setContent(content)
            elif self._afterEditCb:
                self._afterEditCb(content)

        self.initialized = True

    def setFocus(self, focus):
        """
        @param focus: set to True to focus the editor
        """
        raise NotImplementedError

    def syncToEditor(self):
        pass

    def syncFromEditor(self, content):
        pass

    def abortEdition(self, content):
        return True

    def addEditListener(self, listener):
        """Add a method to be called whenever the text is edited.
        @param listener: method taking two arguments: sender, keycode"""
        self.edit_listeners.append(listener)


class SimpleTextEditor(BaseTextEditor, FocusHandler, KeyboardHandler, ClickHandler):
    """Base class for manage a simple text editor."""

    def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
        """
        @param content
        @param modifiedCb
        @param afterEditCb
        @param options: dict with the following value:
        - no_xhtml: set to True to clean any xhtml content.
        - enhance_display: if True, the display text will be enhanced with addURLToText
        - listen_keyboard: set to True to terminate the edition with <enter> or <escape>.
        - listen_focus: set to True to terminate the edition when the focus is lost.
        - listen_click: set to True to start the edition when you click on the widget.
        """
        self.options = {'no_xhtml': False,
                        'enhance_display': True,
                        'listen_keyboard': True,
                        'listen_focus': False,
                        'listen_click': False
                        }
        if options:
            self.options.update(options)
        self.__shift_down = False
        if self.options['listen_focus']:
            FocusHandler.__init__(self)
        if self.options['listen_click']:
            ClickHandler.__init__(self)
        KeyboardHandler.__init__(self)
        strproc = lambda text: html_sanitize(html_strip(text)) if self.options['no_xhtml'] else html_strip(text)
        BaseTextEditor.__init__(self, content, strproc, modifiedCb, afterEditCb)
        self.textarea = self.display = None

    def setContent(self, content=None):
        BaseTextEditor.setContent(self, content)

    def getContent(self):
        raise NotImplementedError

    def edit(self, edit, abort=False, sync=False):
        BaseTextEditor.edit(self, edit)
        if edit:
            if self.options['listen_focus'] and self not in self.textarea._focusListeners:
                self.textarea.addFocusListener(self)
            if self.options['listen_click']:
                self.display.clearClickListener()
            if self not in self.textarea._keyboardListeners:
                self.textarea.addKeyboardListener(self)
        else:
            self.setDisplayContent()
            if self.options['listen_focus']:
                try:
                    self.textarea.removeFocusListener(self)
                except ValueError:
                    pass
            if self.options['listen_click'] and self not in self.display._clickListeners:
                self.display.addClickListener(self)
            try:
                self.textarea.removeKeyboardListener(self)
            except ValueError:
                pass

    def setDisplayContent(self):
        text = self._original_content['text']
        if not self.options['no_xhtml']:
            text = addURLToImage(text)
        if self.options['enhance_display']:
            text = addURLToText(text)
        self.display.setHTML(convertNewLinesToXHTML(text))

    def setFocus(self, focus):
        raise NotImplementedError

    def onKeyDown(self, sender, keycode, modifiers):
        for listener in self.edit_listeners:
            listener(self.textarea, keycode)
        if not self.options['listen_keyboard']:
            return
        if keycode == KEY_SHIFT or self.__shift_down:  # allow input a new line with <shift> + <enter>
            self.__shift_down = True
            return
        if keycode == KEY_ENTER:  # finish the edition
            self.textarea.setFocus(False)
            if not self.options['listen_focus']:
                self.edit(False)

    def onKeyUp(self, sender, keycode, modifiers):
        if keycode == KEY_SHIFT:
            self.__shift_down = False

    def onLostFocus(self, sender):
        """Finish the edition when focus is lost"""
        if self.options['listen_focus']:
            self.edit(False)

    def onClick(self, sender=None):
        """Start the edition when the widget is clicked"""
        if self.options['listen_click']:
            self.edit(True)

    def onBrowserEvent(self, event):
        if self.options['listen_focus']:
            FocusHandler.onBrowserEvent(self, event)
        if self.options['listen_click']:
            ClickHandler.onBrowserEvent(self, event)
        KeyboardHandler.onBrowserEvent(self, event)


class HTMLTextEditor(SimpleTextEditor, HTML, FocusHandler, KeyboardHandler):
    """Manage a simple text editor with the HTML 5 "contenteditable" property."""

    def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
        HTML.__init__(self)
        SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
        self.textarea = self.display = self

    def getContent(self):
        text = DOM.getInnerHTML(self.getElement())
        return {'text': self.strproc(text) if text else ''}

    def edit(self, edit, abort=False, sync=False):
        if edit:
            self.textarea.setHTML(self._original_content['text'])
        self.getElement().setAttribute('contenteditable', 'true' if edit else 'false')
        SimpleTextEditor.edit(self, edit, abort, sync)

    def setFocus(self, focus):
        if focus:
            self.getElement().focus()
        else:
            self.getElement().blur()


class LightTextEditor(SimpleTextEditor, SimplePanel, FocusHandler, KeyboardHandler):
    """Manage a simple text editor with a TextArea for editing, HTML for display."""

    def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
        SimplePanel.__init__(self)
        SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
        self.textarea = TextArea()
        self.display = HTML()

    def getContent(self):
        text = self.textarea.getText()
        return {'text': self.strproc(text) if text else ''}

    def edit(self, edit, abort=False, sync=False):
        if edit:
            self.textarea.setText(self._original_content['text'])
        self.setWidget(self.textarea if edit else self.display)
        SimpleTextEditor.edit(self, edit, abort, sync)

    def setFocus(self, focus):
        if focus:
            self.textarea.setCursorPos(len(self.textarea.getText()))
        self.textarea.setFocus(focus)